Use isolated system toolbar search
This commit is contained in:
1 parent
9e4175485f
commit
3af56496a1
3 files changed
+85
-67
No files matched your search
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user