diff --git a/Termind/App/RootView.swift b/Termind/App/RootView.swift index 7556f436..9704fde3 100644 --- a/Termind/App/RootView.swift +++ b/Termind/App/RootView.swift @@ -56,6 +56,7 @@ struct RootView: View { // window hierarchy. .background { ActiveRemoteWindowTitleReporter(sshSession: window.focusedSSHSession) + WindowToolbarSearchHost() } .overlay { ExternalAuthWindowPresenter() } .frame( @@ -68,9 +69,6 @@ struct RootView: View { .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { layoutWidth = $0 } .toolbarBackground(.hidden, for: .windowToolbar) .navigationTitle(WindowTitleCatalog.standard(hosts: app.hostLibrary).title(in: window)) - // Match the system search presentation used by sing-box-for-apple: - // `.searchable` owns the toolbar field, its material and its focus ring. - .windowToolbarSearch() // The window's only toolbar. Creation on the left, one Find on the // right, both following the content in front rather than the sidebar — // see `WindowToolbarLeadingActions` and `WindowModel.findKind`. @@ -79,7 +77,7 @@ struct RootView: View { WindowToolbarLeadingActions() } ToolbarItem(placement: .primaryAction) { - WindowToolbarHeightReservation() + WindowToolbarSearchSlot() } } .sheet(item: $presentations.hostChooser) { purpose in diff --git a/Termind/Features/Terminal/PersistentTerminalSurfaceView.swift b/Termind/Features/Terminal/PersistentTerminalSurfaceView.swift index e4b4ece8..56dc9760 100644 --- a/Termind/Features/Terminal/PersistentTerminalSurfaceView.swift +++ b/Termind/Features/Terminal/PersistentTerminalSurfaceView.swift @@ -250,16 +250,6 @@ if appliedVisibility != isVisible { appliedVisibility = isVisible terminalView.setSurfaceVisible(isVisible) - } else if didReparent { - // Re-stamp occlusion on the same live surface. Moving between - // SwiftUI containers in one window does not necessarily call - // `viewDidMoveToWindow`, so Ghostty cannot rely on that hook to - // learn that its still-visible view has returned to the deck. - terminalView.setSurfaceVisible(isVisible) - } - - if didReparent, isVisible { - scheduleSurfaceRefresh(afterReparentTo: newContainer) } synchronizeFocus( @@ -353,25 +343,6 @@ container.mountOrdinals[ownerIdentifier] = nextMountOrdinal } - /// A root-level SwiftUI modifier such as `.searchable` can replace the - /// hosting branch while preserving this native terminal. Constraints - /// are active immediately, but AppKit has not laid out the destination - /// container yet. Refresh one run-loop later so Ghostty receives the - /// final viewport and owes a frame without waiting for an unrelated - /// state change such as Command-F. - private func scheduleSurfaceRefresh( - afterReparentTo expectedContainer: TerminalMountView - ) { - DispatchQueue.main.async { [weak self, weak expectedContainer] in - guard let self, let expectedContainer, - container === expectedContainer, - terminalView.superview === expectedContainer, - appliedVisibility == true - else { return } - terminalView.fitToSize() - } - } - /// Moves the keyboard to or from the terminal — but only when something /// actually asked for it to move. /// diff --git a/Termind/UI/Components/WindowToolbar.swift b/Termind/UI/Components/WindowToolbar.swift index 73a44ab4..9be7e5ca 100644 --- a/Termind/UI/Components/WindowToolbar.swift +++ b/Termind/UI/Components/WindowToolbar.swift @@ -400,32 +400,37 @@ struct NativeSearchField: NSViewRepresentable { // MARK: - Toolbar content -/// Lets SwiftUI install its own native toolbar search item, matching the -/// structure used by sing-box-for-apple. In particular, the system owns the -/// focus ring outside the search material instead of an `NSSearchField` being -/// clipped inside a manually hosted toolbar item. -private struct WindowToolbarSearchModifier: ViewModifier { +/// Contributes the macOS 26 system search item from a background child that +/// owns no application content. Adding or removing this host can update toolbar +/// preferences without changing the `NavigationSplitView` branch that owns +/// persistent Ghostty surfaces. +struct WindowToolbarSearchHost: View { @Environment(WindowModel.self) private var window @State private var isPresented = false @ViewBuilder - func body(content: Content) -> some View { - if let context = window.findContext() { - searchable(content, context: context) + var body: some View { + if #available(macOS 26.0, *) { + Group { + if window.findKind != nil { + searchableHost + } else { + Color.clear + .onAppear { dismissSearch() } + } + } + .frame(width: 0, height: 0) } else { - content - .onAppear { dismissSearch() } + Color.clear.frame(width: 0, height: 0) } } - private func searchable( - _ content: Content, - context: WindowToolbarFindContext - ) -> some View { - content + private var searchableHost: some View { + Color.clear .searchable( - text: context.query, + text: searchText, isPresented: $isPresented, + placement: .automatic, prompt: Text("Search") ) .onAppear { installFocusHandler() } @@ -433,22 +438,29 @@ private struct WindowToolbarSearchModifier: ViewModifier { window.isToolbarFindFocused = presented } .onKeyPress(.return, phases: .down) { press in - guard isPresented, let navigate = context.navigate else { - return .ignored - } + guard isPresented, + let navigate = window.findContext()?.navigate + else { return .ignored } navigate(press.modifiers.contains(.shift) ? .next : .previous) return .handled } .onKeyPress(.escape, phases: .down) { _ in - guard isPresented, let cancel = context.cancel else { - return .ignored - } + guard isPresented, + let cancel = window.findContext()?.cancel + else { return .ignored } cancel() dismissSearch() return .handled } } + private var searchText: Binding { + Binding( + get: { window.findContext()?.query.wrappedValue ?? "" }, + set: { window.findContext()?.query.wrappedValue = $0 } + ) + } + private func installFocusHandler() { let presentation = $isPresented window.focusToolbarFindField = { presentation.wrappedValue = true } @@ -458,22 +470,59 @@ private struct WindowToolbarSearchModifier: ViewModifier { private func dismissSearch() { isPresented = false window.isToolbarFindFocused = false - window.focusToolbarFindField = nil } } -extension View { - func windowToolbarSearch() -> some View { - modifier(WindowToolbarSearchModifier()) - } -} - -/// A toolbar with no items is shorter than one with Search, moving all window -/// content when destinations change. This zero-width item keeps that height -/// stable while `.searchable` owns the actual Search control. -struct WindowToolbarHeightReservation: View { +/// The toolbar slot is selected once by OS availability, never by the current +/// page. Runtime navigation therefore cannot reparent persistent terminal views. +struct WindowToolbarSearchSlot: View { + @ViewBuilder var body: some View { - Color.clear.frame(width: 0, height: Metrics.Toolbar.height) + if #available(macOS 26.0, *) { + Color.clear.frame(width: 0, height: Metrics.Toolbar.height) + } else { + WindowToolbarFindField() + } + } +} + +/// The trailing half of the titlebar: one Find field, or nothing at all on +/// Welcome and on a workspace that has no searchable pane in front. +struct WindowToolbarFindField: View { + @Environment(WindowModel.self) private var window + @State private var searchFieldOwner = NativeSearchFieldOwner() + + var body: some View { + Group { + if let context = window.findContext() { + NativeSearchField( + text: context.query, + placeholder: String(localized: "Search"), + owner: searchFieldOwner, + focusRequest: context.focusRequest, + maintainsKeyboardFocus: window.isToolbarFindFocused, + navigate: context.navigate, + cancel: context.cancel, + menuItems: context.menuItems, + installFocusHandler: { window.focusToolbarFindField = $0 }, + onFocusChange: { window.isToolbarFindFocused = $0 } + ) + .frame(width: Metrics.Toolbar.searchFieldWidth) + .help(Text(String(localized: "Search"))) + .accessibilityLabel(Text(String(localized: "Search"))) + } else { + // Zero width, full height. A toolbar with nothing in it is + // shorter than one holding a search field, and the titlebar + // resizing takes the window's content up with it — which is + // what made a connection started from the sidebar shove the + // journey view upward as the destination changed from Vault + // to a Workspace. Reserving the height keeps the toolbar one + // size for every destination, without leaving a phantom slot. + Color.clear.frame(width: 0) + .onAppear { window.isToolbarFindFocused = false } + } + } + .frame(height: Metrics.Toolbar.height) } }