Use isolated system toolbar search

This commit is contained in:
Kaede Akino committed 2026-08-29 13:40:40 +08:00
1 parent 9e4175485f
commit 3af56496a1
3 files changed
+85 -67

No files matched your search

+2 -4
View File
@@ -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
@@ -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.
///
+83 -34
View File
@@ -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<String> {
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)
}
}