Repository navigation
Add debug logs for Cmd+F find bar refocus #840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
603df79
e5cb51c
67bd247
3be380d
3d41035
996133f
b2b6247
98b2d0a
7bc4809
f5754e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4669,6 +4669,14 @@ final class GhosttySurfaceScrollView: NSView { | |
| private var dropZoneOverlayAnimationGeneration: UInt64 = 0 | ||
| // Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection. | ||
|
|
||
| /// Tracks whether keyboard focus should go to the search field or the terminal | ||
| /// when the window becomes key while the find bar is open. | ||
| enum SearchFocusTarget { | ||
| case searchField | ||
| case terminal | ||
| } | ||
| private(set) var searchFocusTarget: SearchFocusTarget = .searchField | ||
|
|
||
| private static func panelBackgroundFillColor(for terminalBackgroundColor: NSColor) -> NSColor { | ||
| // The Ghostty renderer already draws translucent terminal backgrounds. If we paint an | ||
| // additional translucent layer here, alpha stacks and appears effectively opaque. | ||
|
|
@@ -4954,6 +4962,17 @@ final class GhosttySurfaceScrollView: NSView { | |
| self?.handleScrollbarUpdate(notification) | ||
| }) | ||
|
|
||
| observers.append(NotificationCenter.default.addObserver( | ||
| forName: .ghosttySearchFocus, | ||
| object: nil, | ||
| queue: .main | ||
| ) { [weak self] notification in | ||
| guard let self, | ||
| let surface = notification.object as? TerminalSurface, | ||
| surface === self.surfaceView.terminalSurface else { return } | ||
| self.searchFocusTarget = .searchField | ||
| }) | ||
|
|
||
| observers.append(NotificationCenter.default.addObserver( | ||
| forName: .ghosttyDidUpdateCellSize, | ||
| object: surfaceView, | ||
|
|
@@ -5055,19 +5074,32 @@ final class GhosttySurfaceScrollView: NSView { | |
| object: window, | ||
| queue: .main | ||
| ) { [weak self] _ in | ||
| self?.applyFirstResponderIfNeeded() | ||
| guard let self else { return } | ||
| let searchActive = self.surfaceView.terminalSurface?.searchState != nil | ||
| #if DEBUG | ||
| dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))") | ||
| #endif | ||
| self.applyFirstResponderIfNeeded() | ||
| }) | ||
| windowObservers.append(NotificationCenter.default.addObserver( | ||
| forName: NSWindow.didResignKeyNotification, | ||
| object: window, | ||
| queue: .main | ||
| ) { [weak self] _ in | ||
| guard let self, let window = self.window else { return } | ||
| let searchActive = self.surfaceView.terminalSurface?.searchState != nil | ||
| // Losing key window does not always trigger first-responder resignation, so force | ||
| // the focused terminal view to yield responder to keep Ghostty cursor/focus state in sync. | ||
| if let fr = window.firstResponder as? NSView, | ||
| fr === self.surfaceView || fr.isDescendant(of: self.surfaceView) { | ||
| #if DEBUG | ||
| dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) resigningFirstResponder") | ||
| #endif | ||
| window.makeFirstResponder(nil) | ||
| } else { | ||
| #if DEBUG | ||
| dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) firstResponder=\(String(describing: window.firstResponder)) (not terminal, skipping)") | ||
| #endif | ||
| } | ||
| }) | ||
| if window.isKeyWindow { applyFirstResponderIfNeeded() } | ||
|
|
@@ -5078,7 +5110,14 @@ final class GhosttySurfaceScrollView: NSView { | |
| } | ||
|
|
||
| func setFocusHandler(_ handler: (() -> Void)?) { | ||
| surfaceView.onFocus = handler | ||
| surfaceView.onFocus = { [weak self] in | ||
| // When the terminal surface gains focus (click, tab, etc.), update the | ||
|
Comment on lines
5121
to
+5127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| // search focus target so window reactivation restores terminal focus. | ||
| if self?.surfaceView.terminalSurface?.searchState != nil { | ||
| self?.searchFocusTarget = .terminal | ||
| } | ||
| handler?() | ||
| } | ||
| } | ||
|
|
||
| func setTriggerFlashHandler(_ handler: (() -> Void)?) { | ||
|
|
@@ -5131,18 +5170,29 @@ final class GhosttySurfaceScrollView: NSView { | |
| // SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces. | ||
| guard let terminalSurface = surfaceView.terminalSurface, | ||
| let searchState else { | ||
| let hadOverlay = searchOverlayHostingView != nil | ||
| #if DEBUG | ||
| dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)") | ||
| #endif | ||
| searchOverlayHostingView?.removeFromSuperview() | ||
| searchOverlayHostingView = nil | ||
| searchFocusTarget = .searchField | ||
| return | ||
| } | ||
|
|
||
| let hadOverlay = searchOverlayHostingView != nil | ||
| #if DEBUG | ||
| dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")") | ||
| #endif | ||
|
|
||
| let tabId = terminalSurface.tabId | ||
| let surfaceId = terminalSurface.id | ||
| let rootView = SurfaceSearchOverlay( | ||
| tabId: tabId, | ||
| surfaceId: surfaceId, | ||
| searchState: searchState, | ||
| onMoveFocusToTerminal: { [weak self] in | ||
| self?.searchFocusTarget = .terminal | ||
| self?.moveFocus() | ||
|
Comment on lines
5207
to
5209
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Setting Useful? React with 👍 / 👎. |
||
| }, | ||
| onNavigateSearch: { [weak terminalSurface] action in | ||
|
|
@@ -5172,6 +5222,7 @@ final class GhosttySurfaceScrollView: NSView { | |
| return | ||
| } | ||
|
|
||
| searchFocusTarget = .searchField | ||
| let overlay = NSHostingView(rootView: rootView) | ||
| overlay.translatesAutoresizingMaskIntoConstraints = false | ||
| addSubview(overlay) | ||
|
|
@@ -5476,7 +5527,9 @@ final class GhosttySurfaceScrollView: NSView { | |
|
|
||
| func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) { | ||
| #if DEBUG | ||
| dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")") | ||
| let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" | ||
| let searchActive = self.surfaceView.terminalSurface?.searchState != nil | ||
| dlog("find.moveFocus to=\(surfaceShort) searchState=\(searchActive ? "active" : "nil")") | ||
| #endif | ||
| let work = { [weak self] in | ||
| guard let self else { return } | ||
|
|
@@ -5626,7 +5679,11 @@ final class GhosttySurfaceScrollView: NSView { | |
| let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor | ||
|
|
||
| guard isActive else { return } | ||
| guard surfaceView.terminalSurface?.searchState == nil else { return } | ||
| if surfaceView.terminalSurface?.searchState != nil { | ||
| guard let window else { return } | ||
| restoreSearchFocus(window: window) | ||
| return | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In Useful? React with 👍 / 👎. |
||
| } | ||
| guard let window else { return } | ||
|
Comment on lines
5698
to
5699
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep the search-active At Line [5691], active search returns before visibility/key-window checks and retry logic. In transient reparent/reactivation states, this can drop focus restoration instead of retrying. ♻️ Proposed adjustment- guard isActive else { return }
- if surfaceView.terminalSurface?.searchState != nil {
- guard let window else { return }
- restoreSearchFocus(window: window)
- return
- }
- guard let window else { return }
+ guard isActive else { return }
+ guard let window else { return }
guard surfaceView.isVisibleInUI else {
retry()
return
}
guard !isHiddenForFocus, hasUsablePortalGeometry else {
@@
retry()
return
}
+
+ if surfaceView.terminalSurface?.searchState != nil {
+ if !window.isKeyWindow {
+ window.makeKeyAndOrderFront(nil)
+ }
+ restoreSearchFocus(window: window)
+ if searchFocusTarget == .terminal, !isSurfaceViewFirstResponder() {
+ retry()
+ }
+ return
+ }🤖 Prompt for AI Agents |
||
| guard surfaceView.isVisibleInUI else { | ||
| retry() | ||
|
|
@@ -5705,27 +5762,97 @@ final class GhosttySurfaceScrollView: NSView { | |
| return size.width > 1 && size.height > 1 | ||
| }() | ||
| let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor | ||
| let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" | ||
|
|
||
| guard isActive else { return } | ||
| guard surfaceView.isVisibleInUI else { return } | ||
| guard !isHiddenForFocus, hasUsablePortalGeometry else { | ||
| #if DEBUG | ||
| dlog( | ||
| "focus.apply.skip surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + | ||
| "focus.apply.skip surface=\(surfaceShort) " + | ||
| "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" | ||
| ) | ||
| #endif | ||
| return | ||
| } | ||
| guard surfaceView.terminalSurface?.searchState == nil else { return } | ||
| guard let window, window.isKeyWindow else { return } | ||
| if surfaceView.terminalSurface?.searchState != nil { | ||
| // Find bar is open. Restore focus based on what the user last intended. | ||
| restoreSearchFocus(window: window) | ||
| return | ||
| } | ||
| if let fr = window.firstResponder as? NSView, | ||
| fr === surfaceView || fr.isDescendant(of: surfaceView) { | ||
| return | ||
| } | ||
| // Don't steal focus from a search overlay on another surface in this window. | ||
| if let fr = window.firstResponder as? NSView, isSearchOverlayOrDescendant(fr) { | ||
| #if DEBUG | ||
| dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused") | ||
| #endif | ||
| return | ||
| } | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| #if DEBUG | ||
| dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))") | ||
| #endif | ||
| window.makeFirstResponder(surfaceView) | ||
| } | ||
|
|
||
| /// Restore focus when window becomes key and the find bar is open. | ||
| /// Respects `searchFocusTarget` so Escape-to-terminal intent is preserved across window switches. | ||
| private func restoreSearchFocus(window: NSWindow) { | ||
| let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" | ||
| switch searchFocusTarget { | ||
| case .searchField: | ||
| // Two-phase focus restore: | ||
| // 1. AppKit: make the backing NSTextField first responder so typing works immediately. | ||
| // (notification alone fails when first responder is NSWindow) | ||
| // 2. SwiftUI: post .ghosttySearchFocus so @FocusState syncs and .onExitCommand/onKeyPress work. | ||
| // (AppKit alone breaks SwiftUI event handlers like Escape and Return) | ||
| var usedAppKit = false | ||
| if let overlay = searchOverlayHostingView, | ||
| let textField = findTextField(in: overlay) { | ||
| window.makeFirstResponder(textField) | ||
| usedAppKit = true | ||
| } | ||
| if let terminalSurface = surfaceView.terminalSurface { | ||
| NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) | ||
| } | ||
| #if DEBUG | ||
| dlog("find.restoreSearchFocus surface=\(surfaceShort) target=searchField via=\(usedAppKit ? "appkit+notification" : "notification_only")") | ||
| #endif | ||
| case .terminal: | ||
| window.makeFirstResponder(surfaceView) | ||
| #if DEBUG | ||
| dlog("find.restoreSearchFocus surface=\(surfaceShort) target=terminal") | ||
| #endif | ||
| } | ||
| } | ||
|
|
||
| /// Find the first NSTextField descendant in a view hierarchy. | ||
| /// Used to locate the SwiftUI TextField's backing NSTextField inside the search overlay. | ||
| private func findTextField(in view: NSView) -> NSTextField? { | ||
| for subview in view.subviews { | ||
| if let textField = subview as? NSTextField, textField.isEditable { | ||
| return textField | ||
| } | ||
| if let found = findTextField(in: subview) { | ||
| return found | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| /// Check if a view is a search overlay hosting view or a descendant of one. | ||
| private func isSearchOverlayOrDescendant(_ view: NSView) -> Bool { | ||
| var current: NSView? = view | ||
| while let v = current { | ||
| if v is NSHostingView<SurfaceSearchOverlay> { return true } | ||
| current = v.superview | ||
|
Comment on lines
+5856
to
+5859
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| } | ||
| return false | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| #if DEBUG | ||
| struct DebugRenderStats { | ||
| let drawCount: Int | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new focus-change hook only logs and never updates
searchFocusTarget, so clicking from the find field back into the terminal leaves the target stale as.searchField. On the nextdidBecomeKey,restoreSearchFocuswill re-focus the search UI even though the user's last in-window focus was the terminal, which regresses expected Cmd+F workflows that keep the find bar open while returning to terminal input.Useful? React with 👍 / 👎.