diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6f710549..1447c796 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -10327,7 +10327,14 @@ private extension NSWindow { } if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"), let portalWebView = cmuxUniqueBrowserWebView(in: candidate) { - return portalWebView + // Portal-hosted browser chrome (for example the Cmd+F overlay) is a + // sibling of the hosted WKWebView inside WindowBrowserSlotView, not a + // descendant of it. Treating every view in that slot as "web-owned" + // blocks legitimate first-responder changes to overlay text fields. + if view === portalWebView || view.isDescendant(of: portalWebView) { + return portalWebView + } + return nil } current = candidate.superview } diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index d9a8cf26..992c5195 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -6,6 +6,7 @@ import WebKit private var cmuxWindowBrowserPortalKey: UInt8 = 0 private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0 +private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0 #if DEBUG private func browserPortalDebugToken(_ view: NSView?) -> String { @@ -31,6 +32,17 @@ private extension NSObject { } } +private extension NSResponder { + var browserPortalOwningView: NSView? { + if let editor = self as? NSTextView, + editor.isFieldEditor, + let editedView = editor.delegate as? NSView { + return editedView + } + return self as? NSView + } +} + private extension WKWebView { func browserPortalNotifyHidden(reason: String) { let firedSelectors = ["viewDidHide", "_exitInWindow"].filter { @@ -978,9 +990,12 @@ private final class BrowserDropZoneOverlayView: NSView { struct BrowserPortalSearchOverlayConfiguration { let panelId: UUID let searchState: BrowserSearchState + let focusRequestGeneration: UInt64 + let canApplyFocusRequest: (UInt64) -> Bool let onNext: () -> Void let onPrevious: () -> Void let onClose: () -> Void + let onFieldDidFocus: () -> Void } struct BrowserPaneDropContext: Equatable { @@ -1420,23 +1435,63 @@ final class WindowBrowserSlotView: NSView { applyResolvedDropZoneOverlay() } + private func logSearchOverlayEvent(_ action: String, panelId: UUID?) { +#if DEBUG + let firstResponderSummary: String = { + guard let firstResponder = window?.firstResponder else { return "nil" } + if let editor = firstResponder as? NSTextView, editor.isFieldEditor { + let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil" + return "fieldEditor(delegate=\(delegateSummary))" + } + return String(describing: type(of: firstResponder)) + }() + dlog( + "browser.findbar.portal action=\(action) " + + "panel=\(panelId?.uuidString.prefix(5) ?? "nil") " + + "window=\(window?.windowNumber ?? -1) " + + "firstResponder=\(firstResponderSummary) " + + "hasOverlay=\(searchOverlayHostingView != nil ? 1 : 0)" + ) +#endif + } + func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) { guard let configuration else { + logSearchOverlayEvent("remove", panelId: nil) + if let overlay = searchOverlayHostingView { + objc_setAssociatedObject( + overlay, + &cmuxBrowserSearchOverlayPanelIdAssociationKey, + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } searchOverlayHostingView?.removeFromSuperview() searchOverlayHostingView = nil return } + logSearchOverlayEvent("set", panelId: configuration.panelId) let rootView = BrowserSearchOverlay( panelId: configuration.panelId, searchState: configuration.searchState, + focusRequestGeneration: configuration.focusRequestGeneration, + canApplyFocusRequest: configuration.canApplyFocusRequest, onNext: configuration.onNext, onPrevious: configuration.onPrevious, - onClose: configuration.onClose + onClose: configuration.onClose, + onFieldDidFocus: configuration.onFieldDidFocus ) if let overlay = searchOverlayHostingView { + logSearchOverlayEvent("updateExisting", panelId: configuration.panelId) overlay.rootView = rootView + objc_setAssociatedObject( + overlay, + &cmuxBrowserSearchOverlayPanelIdAssociationKey, + configuration.panelId, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) if overlay.superview !== self { overlay.removeFromSuperview() addSubview(overlay) @@ -1452,6 +1507,12 @@ final class WindowBrowserSlotView: NSView { let overlay = NSHostingView(rootView: rootView) overlay.translatesAutoresizingMaskIntoConstraints = false + objc_setAssociatedObject( + overlay, + &cmuxBrowserSearchOverlayPanelIdAssociationKey, + configuration.panelId, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) addSubview(overlay) NSLayoutConstraint.activate([ overlay.topAnchor.constraint(equalTo: topAnchor), @@ -1460,6 +1521,25 @@ final class WindowBrowserSlotView: NSView { overlay.trailingAnchor.constraint(equalTo: trailingAnchor), ]) searchOverlayHostingView = overlay + logSearchOverlayEvent("create", panelId: configuration.panelId) + } + + func searchOverlayPanelId(for responder: NSResponder) -> UUID? { + guard let overlay = searchOverlayHostingView, + let view = responder.browserPortalOwningView, + view.isDescendant(of: overlay) else { + return nil + } + return objc_getAssociatedObject(overlay, &cmuxBrowserSearchOverlayPanelIdAssociationKey) as? UUID + } + + @discardableResult + func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool { + guard let firstResponder = window.firstResponder, + searchOverlayPanelId(for: firstResponder) == panelId else { + return false + } + return window.makeFirstResponder(nil) } func pinHostedWebView(_ webView: WKWebView) { @@ -1872,7 +1952,9 @@ final class WindowBrowserPortal: NSObject { case (nil, nil): return true case let (lhs?, rhs?): - return lhs.panelId == rhs.panelId && lhs.searchState === rhs.searchState + return lhs.panelId == rhs.panelId && + lhs.searchState === rhs.searchState && + lhs.focusRequestGeneration == rhs.focusRequestGeneration default: return false } @@ -2144,6 +2226,26 @@ final class WindowBrowserPortal: NSObject { entry.containerView?.setSearchOverlay(configuration) } + func searchOverlayPanelId(for responder: NSResponder) -> UUID? { + for entry in entriesByWebViewId.values { + if let panelId = entry.containerView?.searchOverlayPanelId(for: responder) { + return panelId + } + } + return nil + } + + @discardableResult + func yieldSearchOverlayFocusIfOwned(by panelId: UUID) -> Bool { + guard let window else { return false } + for entry in entriesByWebViewId.values { + if entry.containerView?.yieldSearchOverlayFocusIfOwned(by: panelId, in: window) == true { + return true + } + } + return false + } + func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) { guard var entry = entriesByWebViewId[webViewId] else { return } let resolvedHeight = max(0, height) @@ -3031,6 +3133,19 @@ enum BrowserWindowPortalRegistry { portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration) } + static func searchOverlayPanelId(for responder: NSResponder, in window: NSWindow) -> UUID? { + let windowId = ObjectIdentifier(window) + guard let portal = portalsByWindowId[windowId] else { return nil } + return portal.searchOverlayPanelId(for: responder) + } + + @discardableResult + static func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool { + let windowId = ObjectIdentifier(window) + guard let portal = portalsByWindowId[windowId] else { return false } + return portal.yieldSearchOverlayFocusIfOwned(by: panelId) + } + static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) { let webViewId = ObjectIdentifier(webView) guard let windowId = webViewToWindowId[webViewId], diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6248cd0d..3473c398 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1398,15 +1398,10 @@ struct ContentView: View { } } - private enum CommandPaletteRestoreFocusIntent { - case panel - case browserAddressBar - } - private struct CommandPaletteRestoreFocusTarget { let workspaceId: UUID let panelId: UUID - let intent: CommandPaletteRestoreFocusIntent + let intent: PanelFocusIntent } private enum CommandPaletteInputFocusTarget { @@ -5337,7 +5332,7 @@ struct ContentView: View { static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( focusedPanelIsBrowser: Bool, focusedBrowserAddressBarPanelId: UUID?, - focusedPanelId: UUID + focusedPanelId: UUID? ) -> Bool { focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId } @@ -5383,15 +5378,10 @@ struct ContentView: View { private func presentCommandPalette(initialQuery: String) { if let panelContext = focusedPanelContext { - let shouldRestoreBrowserAddressBar = Self.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( - focusedPanelIsBrowser: panelContext.panel.panelType == .browser, - focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(), - focusedPanelId: panelContext.panelId - ) commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget( workspaceId: panelContext.workspace.id, panelId: panelContext.panelId, - intent: shouldRestoreBrowserAddressBar ? .browserAddressBar : .panel + intent: panelContext.panel.captureFocusIntent(in: observedWindow) ) } else { commandPaletteRestoreFocusTarget = nil @@ -5468,7 +5458,7 @@ struct ContentView: View { if let clickedFocusTarget { dlog( "palette.dismiss.backdrop focusTarget panel=\(clickedFocusTarget.panelId.uuidString.prefix(5)) " + - "workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(clickedFocusTarget.intent == .browserAddressBar ? "addressBar" : "panel")" + "workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(debugCommandPaletteFocusIntent(clickedFocusTarget.intent))" ) } else { dlog("palette.dismiss.backdrop focusTarget=nil") @@ -5507,10 +5497,11 @@ struct ContentView: View { let workspaceId = terminalView.tabId, let panelId = terminalView.terminalSurface?.id, tabManager.tabs.contains(where: { $0.id == workspaceId }) { - return CommandPaletteRestoreFocusTarget( + return commandPaletteRestoreFocusTarget( workspaceId: workspaceId, panelId: panelId, - intent: .panel + fallbackIntent: .terminal(.surface), + in: window ) } @@ -5522,10 +5513,11 @@ struct ContentView: View { let workspaceId = terminalView.tabId, let panelId = terminalView.terminalSurface?.id, tabManager.tabs.contains(where: { $0.id == workspaceId }) { - return CommandPaletteRestoreFocusTarget( + return commandPaletteRestoreFocusTarget( workspaceId: workspaceId, panelId: panelId, - intent: .panel + fallbackIntent: .terminal(.surface), + in: observedWindow ) } @@ -5563,16 +5555,35 @@ struct ContentView: View { continue } - return CommandPaletteRestoreFocusTarget( + return commandPaletteRestoreFocusTarget( workspaceId: workspace.id, panelId: panelId, - intent: .panel + fallbackIntent: .browser(.webView), + in: observedWindow ) } return nil } + private func commandPaletteRestoreFocusTarget( + workspaceId: UUID, + panelId: UUID, + fallbackIntent: PanelFocusIntent, + in window: NSWindow? + ) -> CommandPaletteRestoreFocusTarget { + let intent = tabManager.tabs + .first(where: { $0.id == workspaceId })? + .panels[panelId]? + .captureFocusIntent(in: window) ?? fallbackIntent + + return CommandPaletteRestoreFocusTarget( + workspaceId: workspaceId, + panelId: panelId, + intent: intent + ) + } + private func restoreCommandPaletteFocus( target: CommandPaletteRestoreFocusTarget, attemptsRemaining: Int @@ -5588,8 +5599,9 @@ struct ContentView: View { if let context = focusedPanelContext, context.workspace.id == target.workspaceId, context.panelId == target.panelId { - restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6) - return + if context.panel.restoreFocusIntent(target.intent) { + return + } } guard attemptsRemaining > 0 else { return } @@ -5598,33 +5610,32 @@ struct ContentView: View { if let context = focusedPanelContext, context.workspace.id == target.workspaceId, context.panelId == target.panelId { - restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6) - return + if context.panel.restoreFocusIntent(target.intent) { + return + } } restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1) } } - private func restoreCommandPaletteInputFocusIfNeeded( - target: CommandPaletteRestoreFocusTarget, - attemptsRemaining: Int - ) { - guard !isCommandPalettePresented else { return } - guard target.intent == .browserAddressBar else { return } - guard attemptsRemaining > 0 else { return } - guard let appDelegate = AppDelegate.shared else { return } - - if appDelegate.requestBrowserAddressBarFocus(panelId: target.panelId) { - return - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { - restoreCommandPaletteInputFocusIfNeeded( - target: target, - attemptsRemaining: attemptsRemaining - 1 - ) +#if DEBUG + private func debugCommandPaletteFocusIntent(_ intent: PanelFocusIntent) -> String { + switch intent { + case .panel: + return "panel" + case .terminal(.surface): + return "terminal.surface" + case .terminal(.findField): + return "terminal.findField" + case .browser(.webView): + return "browser.webView" + case .browser(.addressBar): + return "browser.addressBar" + case .browser(.findField): + return "browser.findField" } } +#endif private func resetCommandPaletteSearchFocus() { applyCommandPaletteInputFocusPolicy(.search) diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift index b7f874ea..5fde1163 100644 --- a/Sources/Find/BrowserSearchOverlay.swift +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -1,12 +1,16 @@ +import AppKit import Bonsplit import SwiftUI struct BrowserSearchOverlay: View { let panelId: UUID @ObservedObject var searchState: BrowserSearchState + let focusRequestGeneration: UInt64 + let canApplyFocusRequest: (UInt64) -> Bool let onNext: () -> Void let onPrevious: () -> Void let onClose: () -> Void + let onFieldDidFocus: () -> Void @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero @@ -14,12 +18,58 @@ struct BrowserSearchOverlay: View { private let padding: CGFloat = 8 - private func requestSearchFieldFocus(maxAttempts: Int = 3) { +#if DEBUG + private func debugFirstResponderSummary() -> String { + guard let window = NSApp.keyWindow else { return "nil" } + guard let firstResponder = window.firstResponder else { return "nil" } + if let editor = firstResponder as? NSTextView, editor.isFieldEditor { + let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil" + return "fieldEditor(delegate=\(delegateSummary))" + } + return String(describing: type(of: firstResponder)) + } +#endif + + private func logFocusState(_ event: String) { +#if DEBUG + let keyWindow = NSApp.keyWindow + dlog( + "browser.findbar.focus panel=\(panelId.uuidString.prefix(5)) " + + "event=\(event) keyWindow=\(keyWindow?.windowNumber ?? -1) " + + "firstResponder=\(debugFirstResponderSummary()) " + + "focused=\(isSearchFieldFocused ? 1 : 0)" + ) +#endif + } + + private func requestSearchFieldFocus(maxAttempts: Int = 3, origin: String) { guard maxAttempts > 0 else { return } + guard canApplyFocusRequest(focusRequestGeneration) else { +#if DEBUG + logFocusState("request.skip origin=\(origin) generation=\(focusRequestGeneration)") +#endif + return + } + logFocusState("request.begin origin=\(origin) remaining=\(maxAttempts)") isSearchFieldFocused = true +#if DEBUG + DispatchQueue.main.async { + guard canApplyFocusRequest(focusRequestGeneration) else { + logFocusState("request.skipAsync origin=\(origin) generation=\(focusRequestGeneration)") + return + } + logFocusState("request.afterAsync origin=\(origin) remaining=\(maxAttempts)") + } +#endif guard maxAttempts > 1 else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - requestSearchFieldFocus(maxAttempts: maxAttempts - 1) + guard canApplyFocusRequest(focusRequestGeneration) else { +#if DEBUG + logFocusState("request.skipRetry origin=\(origin) generation=\(focusRequestGeneration)") +#endif + return + } + requestSearchFieldFocus(maxAttempts: maxAttempts - 1, origin: origin) } } @@ -102,16 +152,24 @@ struct BrowserSearchOverlay: View { .clipShape(clipShape) .shadow(radius: 4) .onAppear { - #if DEBUG +#if DEBUG dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))") - #endif - requestSearchFieldFocus() +#endif + logFocusState("appear") + requestSearchFieldFocus(origin: "appear") + } + .onChange(of: isSearchFieldFocused) { _, focused in + logFocusState("focusState.change next=\(focused ? 1 : 0)") + if focused { + onFieldDidFocus() + } } .onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in guard let notifiedPanelId = notification.object as? UUID, notifiedPanelId == panelId else { return } + logFocusState("notification.received") DispatchQueue.main.async { - requestSearchFieldFocus() + requestSearchFieldFocus(origin: "notification") } } .background( diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index aee272e9..f6ad9a40 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -328,10 +328,19 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { field.currentEditor() != nil || ((fr as? NSTextView)?.delegate as? NSTextField) === field #if DEBUG - dlog("find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) alreadyFocused=\(alreadyFocused)") + dlog( + "find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " + + "alreadyFocused=\(alreadyFocused) firstResponder=\(String(describing: fr))" + ) #endif guard !alreadyFocused else { return } - window.makeFirstResponder(field) + let result = window.makeFirstResponder(field) +#if DEBUG + dlog( + "find.nativeField.searchFocusApply surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " + + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" + ) +#endif } return field diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 49c059f0..f43efdb6 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6406,10 +6406,8 @@ final class GhosttySurfaceScrollView: NSView { #endif if active { applyFirstResponderIfNeeded() - } else if let window, - let fr = window.firstResponder as? NSView, - fr === surfaceView || fr.isDescendant(of: surfaceView) { - window.makeFirstResponder(nil) + } else { + resignOwnedFirstResponderIfNeeded(reason: "setActive(false)") } } @@ -6459,15 +6457,29 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG 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")") + dlog( + "find.moveFocus to=\(surfaceShort) " + + "from=\(previous?.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "searchState=\(searchActive ? "active" : "nil") " + + "delayMs=\(Int((delay ?? 0) * 1000))" + ) #endif let work = { [weak self] in guard let self else { return } guard let window = self.window else { return } +#if DEBUG + let before = String(describing: window.firstResponder) +#endif if let previous, previous !== self { _ = previous.surfaceView.resignFirstResponder() } - window.makeFirstResponder(self.surfaceView) + let result = window.makeFirstResponder(self.surfaceView) +#if DEBUG + dlog( + "find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))" + ) +#endif } if let delay, delay > 0 { @@ -6611,6 +6623,12 @@ final class GhosttySurfaceScrollView: NSView { guard isActive else { return } guard let window else { return } guard surfaceView.isVisibleInUI else { +#if DEBUG + dlog( + "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "reason=not_visible attempts=\(attemptsRemaining)" + ) +#endif retry() return } @@ -6650,6 +6668,13 @@ final class GhosttySurfaceScrollView: NSView { // Search focus restoration — only after confirming this is the active tab/pane. if surfaceView.terminalSurface?.searchState != nil { +#if DEBUG + dlog( + "focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + + "attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))" + ) +#endif restoreSearchFocus(window: window) return } @@ -6663,7 +6688,15 @@ final class GhosttySurfaceScrollView: NSView { if !window.isKeyWindow { window.makeKeyAndOrderFront(nil) } - _ = window.makeFirstResponder(surfaceView) + let result = window.makeFirstResponder(surfaceView) +#if DEBUG + dlog( + "focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " + + "attempts=\(attemptsRemaining)" + ) +#endif if !isSurfaceViewFirstResponder() { retry() @@ -6793,6 +6826,18 @@ final class GhosttySurfaceScrollView: NSView { let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" switch searchFocusTarget { case .searchField: + if let firstResponder = window.firstResponder, + isSearchOverlayOrDescendant(firstResponder), + !isCurrentSurfaceSearchResponder(firstResponder) { + surfaceView.terminalSurface?.setFocus(false) +#if DEBUG + dlog( + "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " + + "reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))" + ) +#endif + return + } // Explicitly unfocus the terminal so cursor stops blinking immediately. // The notification observer also does this, but it runs async when posted from main. surfaceView.terminalSurface?.setFocus(false) @@ -6802,16 +6847,152 @@ final class GhosttySurfaceScrollView: NSView { NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) } #if DEBUG - dlog("find.restoreSearchFocus surface=\(surfaceShort) target=searchField via=notification") + dlog( + "find.restoreSearchFocus surface=\(surfaceShort) target=searchField " + + "via=notification firstResponder=\(String(describing: window.firstResponder))" + ) #endif case .terminal: - window.makeFirstResponder(surfaceView) + let result = window.makeFirstResponder(surfaceView) #if DEBUG - dlog("find.restoreSearchFocus surface=\(surfaceShort) target=terminal") + dlog( + "find.restoreSearchFocus surface=\(surfaceShort) target=terminal " + + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" + ) #endif } } + func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent { + if surfaceView.terminalSurface?.searchState != nil { + if let firstResponder = window?.firstResponder as? NSView, + (firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) { + return .surface + } + if let firstResponder = window?.firstResponder, + isCurrentSurfaceSearchResponder(firstResponder) { + return .findField + } + if searchFocusTarget == .searchField { + return .findField + } + } + return .surface + } + + func preferredPanelFocusIntentForActivation() -> TerminalPanelFocusIntent { + if surfaceView.terminalSurface?.searchState != nil, searchFocusTarget == .searchField { + return .findField + } + return .surface + } + + func preparePanelFocusIntentForActivation(_ intent: TerminalPanelFocusIntent) { + switch intent { + case .surface: + searchFocusTarget = .terminal + case .findField: + guard surfaceView.terminalSurface?.searchState != nil else { return } + searchFocusTarget = .searchField + } +#if DEBUG + dlog( + "find.preparePanelFocusIntent surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "target=\(intent == .findField ? "searchField" : "terminal")" + ) +#endif + } + + @discardableResult + func restorePanelFocusIntent(_ intent: TerminalPanelFocusIntent) -> Bool { + switch intent { + case .surface: + searchFocusTarget = .terminal + setActive(true) + applyFirstResponderIfNeeded() + return true + case .findField: + guard let terminalSurface = surfaceView.terminalSurface, + terminalSurface.searchState != nil else { + return false + } + searchFocusTarget = .searchField + setActive(true) + if let window { + restoreSearchFocus(window: window) + } else { + terminalSurface.setFocus(false) + NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) + } +#if DEBUG + dlog( + "find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "target=searchField firstResponder=\(String(describing: window?.firstResponder))" + ) +#endif + return true + } + } + + func ownedPanelFocusIntent(for responder: NSResponder) -> TerminalPanelFocusIntent? { + if isCurrentSurfaceSearchResponder(responder) { + return .findField + } + + let resolvedResponder: NSResponder + if let editor = responder as? NSTextView, + editor.isFieldEditor, + let editedView = editor.delegate as? NSView { + resolvedResponder = editedView + } else { + resolvedResponder = responder + } + + guard let view = resolvedResponder as? NSView else { return nil } + if view === surfaceView || view.isDescendant(of: surfaceView) { + return .surface + } + return nil + } + + @discardableResult + func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool { + guard let firstResponder = window.firstResponder, + ownedPanelFocusIntent(for: firstResponder) == intent else { + return false + } + + surfaceView.terminalSurface?.setFocus(false) + resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent") +#if DEBUG + dlog( + "focus.handoff.yield surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "target=\(intent == .findField ? "searchField" : "terminal")" + ) +#endif + return true + } + + private func resignOwnedFirstResponderIfNeeded(reason: String) { + guard let window, + let firstResponder = window.firstResponder else { return } + + let ownsSurfaceResponder: Bool = { + guard let view = firstResponder as? NSView else { return false } + return view === surfaceView || view.isDescendant(of: surfaceView) + }() + + guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return } + +#if DEBUG + dlog( + "focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "reason=\(reason) firstResponder=\(String(describing: firstResponder))" + ) +#endif + window.makeFirstResponder(nil) + } + /// Check if a responder is inside a search overlay hosting view. /// Handles the AppKit field-editor case: when an NSTextField is being edited, /// window.firstResponder is the shared NSTextView field editor, not the text field. @@ -6827,11 +7008,27 @@ final class GhosttySurfaceScrollView: NSView { var current: NSView? = view while let v = current { if v is NSHostingView { return true } + let typeName = String(describing: type(of: v)) + if typeName.contains("BrowserSearchOverlay") { return true } current = v.superview } return false } + private func isCurrentSurfaceSearchResponder(_ responder: NSResponder) -> Bool { + let resolvedResponder: NSResponder + if let editor = responder as? NSTextView, + editor.isFieldEditor, + let editedView = editor.delegate as? NSView { + resolvedResponder = editedView + } else { + resolvedResponder = responder + } + + guard let view = resolvedResponder as? NSView else { return false } + return view.isDescendant(of: self) + } + #if DEBUG struct DebugRenderStats { let drawCount: Int diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d9c06dc6..37714bbb 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1684,10 +1684,17 @@ final class BrowserPanel: Panel, ObservableObject { /// cleared only after BrowserPanelView acknowledges handling it. @Published private(set) var pendingAddressBarFocusRequestId: UUID? + /// Semantic in-panel focus target used by split switching and transient overlays. + private(set) var preferredFocusIntent: BrowserPanelFocusIntent = .webView + + /// Incremented whenever async browser find focus ownership changes. + @Published private(set) var searchFocusRequestGeneration: UInt64 = 0 + /// Find-in-page state. Non-nil when the find bar is visible. @Published var searchState: BrowserSearchState? = nil { didSet { if let searchState { + preferredFocusIntent = .findField NSLog("Find: browser search state created panel=%@", id.uuidString) searchNeedleCancellable = searchState.$needle .removeDuplicates() @@ -1707,6 +1714,10 @@ final class BrowserPanel: Panel, ObservableObject { } } else if oldValue != nil { searchNeedleCancellable = nil + if preferredFocusIntent == .findField { + preferredFocusIntent = .webView + } + invalidateSearchFocusRequests(reason: "searchStateCleared") NSLog("Find: browser search state cleared panel=%@", id.uuidString) executeFindClear() } @@ -2298,12 +2309,16 @@ final class BrowserPanel: Panel, ObservableObject { } if Self.responderChainContains(window.firstResponder, target: webView) { + noteWebViewFocused() return } - window.makeFirstResponder(webView) + if window.makeFirstResponder(webView) { + noteWebViewFocused() + } } func unfocus() { + invalidateSearchFocusRequests(reason: "panelUnfocus") guard let window = webView.window else { return } if Self.responderChainContains(window.firstResponder, target: webView) { window.makeFirstResponder(nil) @@ -3043,21 +3058,52 @@ extension BrowserPanel { // MARK: - Find in Page func startFind() { - if searchState == nil { + preferredFocusIntent = .findField + let created = searchState == nil + if created { searchState = BrowserSearchState() } - postBrowserSearchFocusNotification() + let generation = beginSearchFocusRequest(reason: "startFind") +#if DEBUG + let window = webView.window + dlog( + "browser.find.start panel=\(id.uuidString.prefix(5)) " + + "created=\(created ? 1 : 0) render=\(shouldRenderWebView ? 1 : 0) " + + "generation=\(generation) " + + "window=\(window?.windowNumber ?? -1) key=\(NSApp.keyWindow === window ? 1 : 0) " + + "firstResponder=\(String(describing: window?.firstResponder))" + ) +#endif + postBrowserSearchFocusNotification(reason: "immediate", generation: generation) // Focus notification can race with portal overlay mount. Re-post on the // next runloop and shortly after so the find field can claim first responder. DispatchQueue.main.async { [weak self] in - self?.postBrowserSearchFocusNotification() + self?.postBrowserSearchFocusNotification(reason: "async0", generation: generation) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.postBrowserSearchFocusNotification() + self?.postBrowserSearchFocusNotification(reason: "async50ms", generation: generation) } } - private func postBrowserSearchFocusNotification() { + private func postBrowserSearchFocusNotification(reason: String, generation: UInt64) { + guard canApplySearchFocusRequest(generation) else { +#if DEBUG + dlog( + "browser.find.focusNotification.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) generation=\(generation)" + ) +#endif + return + } +#if DEBUG + let window = webView.window + dlog( + "browser.find.focusNotification panel=\(id.uuidString.prefix(5)) " + + "generation=\(generation) " + + "reason=\(reason) window=\(window?.windowNumber ?? -1) " + + "firstResponder=\(String(describing: window?.firstResponder))" + ) +#endif NotificationCenter.default.post(name: .browserSearchFocus, object: id) } @@ -3078,6 +3124,7 @@ extension BrowserPanel { } func hideFind() { + invalidateSearchFocusRequests(reason: "hideFind") searchState = nil } @@ -3088,7 +3135,10 @@ extension BrowserPanel { if replaySearch, !state.needle.isEmpty { executeFindSearch(state.needle) } - postBrowserSearchFocusNotification() + postBrowserSearchFocusNotification( + reason: "restoreAfterNavigation", + generation: searchFocusRequestGeneration + ) } private func executeFindSearch(_ needle: String) { @@ -3215,6 +3265,8 @@ extension BrowserPanel { @discardableResult func requestAddressBarFocus() -> UUID { + preferredFocusIntent = .addressBar + invalidateSearchFocusRequests(reason: "requestAddressBarFocus") beginSuppressWebViewFocusForAddressBar() if let pendingAddressBarFocusRequestId { #if DEBUG @@ -3236,6 +3288,173 @@ extension BrowserPanel { return requestId } + func noteWebViewFocused() { + guard searchState == nil else { return } + guard preferredFocusIntent != .webView else { return } + preferredFocusIntent = .webView + invalidateSearchFocusRequests(reason: "webViewFocused") + } + + func noteAddressBarFocused() { + guard preferredFocusIntent != .addressBar else { return } + preferredFocusIntent = .addressBar + invalidateSearchFocusRequests(reason: "addressBarFocused") + } + + func noteFindFieldFocused() { + guard preferredFocusIntent != .findField else { return } + preferredFocusIntent = .findField + } + + func canApplySearchFocusRequest(_ generation: UInt64) -> Bool { + generation != 0 && + generation == searchFocusRequestGeneration && + searchState != nil && + preferredFocusIntent == .findField + } + + func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent { + if pendingAddressBarFocusRequestId != nil || AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id { + return .browser(.addressBar) + } + + if searchState != nil && preferredFocusIntent == .findField { + return .browser(.findField) + } + + if let window, + Self.responderChainContains(window.firstResponder, target: webView) { + return .browser(.webView) + } + + return .browser(preferredFocusIntent) + } + + func preferredFocusIntentForActivation() -> PanelFocusIntent { + if pendingAddressBarFocusRequestId != nil { + return .browser(.addressBar) + } + if searchState != nil && preferredFocusIntent == .findField { + return .browser(.findField) + } + return .browser(preferredFocusIntent) + } + + func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) { + guard case .browser(let target) = intent else { return } + + switch target { + case .webView: + preferredFocusIntent = .webView + invalidateSearchFocusRequests(reason: "prepareWebView") + endSuppressWebViewFocusForAddressBar() + case .addressBar: + preferredFocusIntent = .addressBar + invalidateSearchFocusRequests(reason: "prepareAddressBar") + beginSuppressWebViewFocusForAddressBar() + case .findField: + preferredFocusIntent = .findField + } +#if DEBUG + dlog( + "browser.focus.prepare panel=\(id.uuidString.prefix(5)) " + + "target=\(String(describing: target)) suppressWeb=\(shouldSuppressWebViewFocus() ? 1 : 0)" + ) +#endif + } + + @discardableResult + func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool { + guard case .browser(let target) = intent else { return false } + + switch target { + case .webView: + noteWebViewFocused() + focus() + return true + case .addressBar: + let requestId = requestAddressBarFocus() + NotificationCenter.default.post(name: .browserFocusAddressBar, object: id) +#if DEBUG + dlog( + "browser.focus.restore panel=\(id.uuidString.prefix(5)) " + + "target=addressBar request=\(requestId.uuidString.prefix(8))" + ) +#endif + return true + case .findField: + startFind() + return true + } + } + + func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? { + if AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id { + return .browser(.addressBar) + } + + if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) == id { + return .browser(.findField) + } + + if Self.responderChainContains(responder, target: webView) { + return .browser(.webView) + } + + return nil + } + + @discardableResult + func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool { + guard case .browser(let target) = intent else { return false } + + switch target { + case .findField: + invalidateSearchFocusRequests(reason: "yieldFindField") + let yielded = BrowserWindowPortalRegistry.yieldSearchOverlayFocusIfOwned(by: id, in: window) +#if DEBUG + if yielded { + dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=browserFind") + } +#endif + return yielded + case .addressBar: + guard AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id else { return false } + let yielded = window.makeFirstResponder(nil) +#if DEBUG + if yielded { + dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=addressBar") + } +#endif + return yielded + case .webView: + guard Self.responderChainContains(window.firstResponder, target: webView) else { return false } + return window.makeFirstResponder(nil) + } + } + + @discardableResult + private func beginSearchFocusRequest(reason: String) -> UInt64 { + searchFocusRequestGeneration &+= 1 +#if DEBUG + dlog( + "browser.find.focusLease.begin panel=\(id.uuidString.prefix(5)) " + + "generation=\(searchFocusRequestGeneration) reason=\(reason)" + ) +#endif + return searchFocusRequestGeneration + } + + private func invalidateSearchFocusRequests(reason: String) { + searchFocusRequestGeneration &+= 1 +#if DEBUG + dlog( + "browser.find.focusLease.invalidate panel=\(id.uuidString.prefix(5)) " + + "generation=\(searchFocusRequestGeneration) reason=\(reason)" + ) +#endif + } + func acknowledgeAddressBarFocusRequest(_ requestId: UUID) { guard pendingAddressBarFocusRequestId == requestId else { #if DEBUG diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 98ae7485..d452bb52 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -338,9 +338,14 @@ struct BrowserPanelView: View { BrowserSearchOverlay( panelId: panel.id, searchState: searchState, + focusRequestGeneration: panel.searchFocusRequestGeneration, + canApplyFocusRequest: { generation in + panel.canApplySearchFocusRequest(generation) + }, onNext: { panel.findNext() }, onPrevious: { panel.findPrevious() }, - onClose: { panel.hideFind() } + onClose: { panel.hideFind() }, + onFieldDidFocus: { panel.noteFindFieldFocused() } ) } } @@ -816,9 +821,14 @@ struct BrowserPanelView: View { BrowserPortalSearchOverlayConfiguration( panelId: panel.id, searchState: searchState, + focusRequestGeneration: panel.searchFocusRequestGeneration, + canApplyFocusRequest: { generation in + panel.canApplySearchFocusRequest(generation) + }, onNext: { panel.findNext() }, onPrevious: { panel.findPrevious() }, - onClose: { panel.hideFind() } + onClose: { panel.hideFind() }, + onFieldDidFocus: { panel.noteFindFieldFocused() } ) }, paneTopChromeHeight: addressBarHeight @@ -911,6 +921,9 @@ struct BrowserPanelView: View { } #endif addressBarFocused = focused + if focused { + panel.noteAddressBarFocused() + } } private func browserFocusResponderChainContains( @@ -1514,6 +1527,9 @@ struct BrowserPanelView: View { syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.handoff") panel.clearWebViewFocusSuppression() let focusedWebView = window.makeFirstResponder(panel.webView) + if focusedWebView { + panel.noteWebViewFocused() + } #if DEBUG dlog( "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + @@ -1531,10 +1547,11 @@ struct BrowserPanelView: View { NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) return } - let hasWebViewResponder = + var hasWebViewResponder = browserFocusResponderChainContains(window.firstResponder, target: panel.webView) if !hasWebViewResponder { let fallbackFocusedWebView = window.makeFirstResponder(panel.webView) + hasWebViewResponder = fallbackFocusedWebView #if DEBUG dlog( "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + @@ -1543,6 +1560,9 @@ struct BrowserPanelView: View { ) #endif } + if hasWebViewResponder { + panel.noteWebViewFocused() + } NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) } } @@ -4552,6 +4572,9 @@ struct WebViewRepresentable: NSViewRepresentable { #endif return } + if isPanelFocused && responderChainContains(window.firstResponder, target: webView) { + panel.noteWebViewFocused() + } if shouldFocusWebView { if panel.shouldSuppressWebViewFocus() { #if DEBUG @@ -4572,6 +4595,9 @@ struct WebViewRepresentable: NSViewRepresentable { return } let result = window.makeFirstResponder(webView) + if result { + panel.noteWebViewFocused() + } #if DEBUG dlog( "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index 09ec66b6..bcbf5b7d 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import AppKit /// Type of panel content public enum PanelType: String, Codable, Sendable { @@ -8,6 +9,23 @@ public enum PanelType: String, Codable, Sendable { case markdown } +public enum TerminalPanelFocusIntent: Equatable { + case surface + case findField +} + +public enum BrowserPanelFocusIntent: Equatable { + case webView + case addressBar + case findField +} + +public enum PanelFocusIntent: Equatable { + case panel + case terminal(TerminalPanelFocusIntent) + case browser(BrowserPanelFocusIntent) +} + enum FocusFlashCurve: Equatable { case easeIn case easeOut @@ -72,10 +90,63 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI /// Trigger a focus flash animation for this panel. func triggerFlash() + + /// Capture the panel-local focus target that should be restored later. + func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent + + /// Return the best focus target to restore when this panel becomes active again. + func preferredFocusIntentForActivation() -> PanelFocusIntent + + /// Prime panel-local focus state before activation side effects run. + func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) + + /// Restore a previously captured focus target. + @discardableResult + func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool + + /// Return the semantic focus target currently owned by this panel, if any. + func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? + + /// Explicitly yield a previously owned focus target before another panel restores focus. + @discardableResult + func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool } /// Extension providing default implementations extension Panel { public var displayIcon: String? { nil } public var isDirty: Bool { false } + + func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent { + _ = window + return preferredFocusIntentForActivation() + } + + func preferredFocusIntentForActivation() -> PanelFocusIntent { + .panel + } + + func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) { + _ = intent + } + + @discardableResult + func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool { + guard intent == .panel else { return false } + focus() + return true + } + + func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? { + _ = responder + _ = window + return nil + } + + @discardableResult + func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool { + _ = intent + _ = window + return false + } } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 7e863f5d..f9d197a3 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -197,4 +197,42 @@ final class TerminalPanel: Panel, ObservableObject { func applyWindowBackgroundIfActive() { surface.applyWindowBackgroundIfActive() } + + func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent { + .terminal(hostedView.capturePanelFocusIntent(in: window)) + } + + func preferredFocusIntentForActivation() -> PanelFocusIntent { + .terminal(hostedView.preferredPanelFocusIntentForActivation()) + } + + func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) { + guard case .terminal(let target) = intent else { return } + hostedView.preparePanelFocusIntentForActivation(target) + } + + @discardableResult + func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool { + switch intent { + case .panel: + focus() + return true + case .terminal(let target): + return hostedView.restorePanelFocusIntent(target) + default: + return false + } + } + + func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? { + _ = window + guard let intent = hostedView.ownedPanelFocusIntent(for: responder) else { return nil } + return .terminal(intent) + } + + @discardableResult + func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool { + guard case .terminal(let target) = intent else { return false } + return hostedView.yieldPanelFocusIntent(target, in: window) + } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8962b2ec..007c1883 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1230,7 +1230,14 @@ final class Workspace: Identifiable, ObservableObject { private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:] private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] private var isApplyingTabSelection = false - private var pendingTabSelection: (tabId: TabID, pane: PaneID)? + private struct PendingTabSelectionRequest { + let tabId: TabID + let pane: PaneID + let reassertAppKitFocus: Bool + let focusIntent: PanelFocusIntent? + let previousTerminalHostedView: GhosttySurfaceScrollView? + } + private var pendingTabSelection: PendingTabSelectionRequest? private var isReconcilingFocusState = false private var focusReconcileScheduled = false #if DEBUG @@ -3061,6 +3068,19 @@ final class Workspace: Identifiable, ObservableObject { }() let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged #if DEBUG + let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let selectedTabShort = bonsplitController.focusedPaneId + .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } + .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" + let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "focus.panel.begin workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " + + "targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " + + "converged=\(selectionAlreadyConverged ? 1 : 0) " + + "currentPanel=\(currentPanelShort)" + ) if shouldSuppressReentrantRefocus { dlog( "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + @@ -3070,46 +3090,36 @@ final class Workspace: Identifiable, ObservableObject { #endif if let targetPaneId, !selectionAlreadyConverged { +#if DEBUG + dlog( + "focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))" + ) +#endif bonsplitController.focusPane(targetPaneId) } if !selectionAlreadyConverged { +#if DEBUG + dlog( + "focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))" + ) +#endif bonsplitController.selectTab(tabId) } - // Also focus the underlying panel - if let panel = panels[panelId] { - if (currentlyFocusedPanelId != panelId || !selectionAlreadyConverged) && !shouldSuppressReentrantRefocus { - panel.focus() - } - - if !shouldSuppressReentrantRefocus, let terminalPanel = panel as? TerminalPanel { - // Avoid re-entrant focus loops when focus was initiated by AppKit first-responder - // (becomeFirstResponder -> onFocus -> focusPanel). - if !terminalPanel.hostedView.isSurfaceViewFirstResponder() { - terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView) - } - } - } if let targetPaneId { + let activationIntent = panels[panelId]?.preferredFocusIntentForActivation() applyTabSelection( tabId: tabId, inPane: targetPaneId, - reassertAppKitFocus: !shouldSuppressReentrantRefocus + reassertAppKitFocus: !shouldSuppressReentrantRefocus, + focusIntent: activationIntent, + previousTerminalHostedView: previousTerminalHostedView ) } - if let browserPanel = panels[panelId] as? BrowserPanel { - // Keep browser find focus behavior aligned with terminal find behavior. - // When switching back to a pane with an already-open find bar, reassert - // focus to that field instead of leaving first responder stale. - if browserPanel.searchState != nil { - browserPanel.startFind() - } else { - maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) - } - } - if trigger == .terminalFirstResponder, panels[panelId] is TerminalPanel { scheduleTerminalFirstResponderReassert(panelId: panelId) @@ -4044,9 +4054,17 @@ extension Workspace: BonsplitDelegate { private func applyTabSelection( tabId: TabID, inPane pane: PaneID, - reassertAppKitFocus: Bool = true + reassertAppKitFocus: Bool = true, + focusIntent: PanelFocusIntent? = nil, + previousTerminalHostedView: GhosttySurfaceScrollView? = nil ) { - pendingTabSelection = (tabId: tabId, pane: pane) + pendingTabSelection = PendingTabSelectionRequest( + tabId: tabId, + pane: pane, + reassertAppKitFocus: reassertAppKitFocus, + focusIntent: focusIntent, + previousTerminalHostedView: previousTerminalHostedView + ) guard !isApplyingTabSelection else { return } isApplyingTabSelection = true defer { @@ -4062,7 +4080,9 @@ extension Workspace: BonsplitDelegate { applyTabSelectionNow( tabId: request.tabId, inPane: request.pane, - reassertAppKitFocus: reassertAppKitFocus + reassertAppKitFocus: request.reassertAppKitFocus, + focusIntent: request.focusIntent, + previousTerminalHostedView: request.previousTerminalHostedView ) } } @@ -4070,9 +4090,23 @@ extension Workspace: BonsplitDelegate { private func applyTabSelectionNow( tabId: TabID, inPane pane: PaneID, - reassertAppKitFocus: Bool + reassertAppKitFocus: Bool, + focusIntent: PanelFocusIntent?, + previousTerminalHostedView: GhosttySurfaceScrollView? ) { let previousFocusedPanelId = focusedPanelId +#if DEBUG + let focusedPaneBefore = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let selectedTabBefore = bonsplitController.focusedPaneId + .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } + .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" + dlog( + "focus.split.apply.begin workspace=\(id.uuidString.prefix(5)) " + + "pane=\(pane.id.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5)) " + + "focusedPane=\(focusedPaneBefore) selectedTab=\(selectedTabBefore) " + + "reassert=\(reassertAppKitFocus ? 1 : 0)" + ) +#endif if bonsplitController.allPaneIds.contains(pane) { if bonsplitController.focusedPaneId != pane { bonsplitController.focusPane(pane) @@ -4107,6 +4141,8 @@ extension Workspace: BonsplitDelegate { if shouldTreatCurrentEventAsExplicitFocusIntent() { markExplicitFocusIntent(on: panelId) } + let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation() + panel.prepareFocusIntentForActivation(activationIntent) syncPinnedStateForTab(selectedTabId, panelId: panelId) syncUnreadBadgeStateForPanel(panelId) @@ -4116,11 +4152,24 @@ extension Workspace: BonsplitDelegate { p.unfocus() } - activatePanel(panel, reassertAppKitFocus: reassertAppKitFocus) + if let focusWindow = activationWindow(for: panel) { + yieldForeignOwnedFocusIfNeeded( + in: focusWindow, + targetPanelId: panelId, + targetIntent: activationIntent + ) + } + + activatePanel( + panel, + focusIntent: activationIntent, + reassertAppKitFocus: reassertAppKitFocus + ) let focusIntentAllowsBrowserOmnibarAutofocus = shouldTreatCurrentEventAsExplicitFocusIntent() || TerminalController.socketCommandAllowsInAppFocusMutations() if let browserPanel = panel as? BrowserPanel, + shouldAllowBrowserOmnibarAutofocus(for: activationIntent), previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus { maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard) } @@ -4149,9 +4198,32 @@ extension Workspace: BonsplitDelegate { // Converge AppKit first responder with bonsplit's selected tab in the focused pane. // Without this, keyboard input can remain on a different terminal than the blue tab indicator. if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel { + if shouldMoveTerminalSurfaceFocus(for: activationIntent), + !terminalPanel.hostedView.isSurfaceViewFirstResponder() { +#if DEBUG + let previousExists = previousTerminalHostedView != nil ? 1 : 0 + dlog( + "focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " + + "to=\(panelId.uuidString.prefix(5))" + ) +#endif + terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView) + } +#if DEBUG + dlog( + "focus.split.ensureFocus workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) pane=\(focusedPane.id.uuidString.prefix(5)) " + + "tab=\(selectedTabId.uuid.uuidString.prefix(5)) intent=\(String(describing: activationIntent))" + ) +#endif terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId) } + if shouldRestoreFocusIntentAfterActivation(activationIntent) { + _ = panel.restoreFocusIntent(activationIntent) + } + // Update current directory if this is a terminal if let dir = panelDirectories[panelId] { currentDirectory = dir @@ -4168,28 +4240,108 @@ extension Workspace: BonsplitDelegate { GhosttyNotificationKey.surfaceId: panelId ] ) +#if DEBUG + let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "focus.split.apply.end workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) type=\(String(describing: type(of: panel))) " + + "focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " + + "prevPanel=\(prevPanelShort)" + ) +#endif } private func activatePanel( _ panel: any Panel, + focusIntent: PanelFocusIntent, reassertAppKitFocus: Bool ) { - guard !reassertAppKitFocus else { - panel.focus() - return - } - - // `GhosttyNSView.becomeFirstResponder -> onFocus -> focusPanel` is already inside - // AppKit's responder transition. Re-running `makeFirstResponder` here can recurse, - // but we still need to converge the selected panel's active/focus state and clear any - // stale sibling terminal activation so split-pane clicks recover cleanly. if let terminalPanel = panel as? TerminalPanel { - terminalPanel.surface.setFocus(true) + let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent) + terminalPanel.surface.setFocus(shouldFocusTerminalSurface) terminalPanel.hostedView.setActive(true) + if reassertAppKitFocus && shouldFocusTerminalSurface { + terminalPanel.focus() + } return } - panel.focus() + if let browserPanel = panel as? BrowserPanel { + guard shouldFocusBrowserWebView(for: focusIntent) else { return } + browserPanel.focus() + return + } + + if reassertAppKitFocus { + panel.focus() + } + } + + private func activationWindow(for panel: any Panel) -> NSWindow? { + if let terminalPanel = panel as? TerminalPanel { + return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + if let browserPanel = panel as? BrowserPanel { + return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + return NSApp.keyWindow ?? NSApp.mainWindow + } + + private func yieldForeignOwnedFocusIfNeeded( + in window: NSWindow, + targetPanelId: UUID, + targetIntent: PanelFocusIntent + ) { + guard let firstResponder = window.firstResponder else { return } + + for (panelId, panel) in panels where panelId != targetPanelId { + guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue } +#if DEBUG + dlog( + "focus.handoff.begin workspace=\(id.uuidString.prefix(5)) " + + "fromPanel=\(panelId.uuidString.prefix(5)) toPanel=\(targetPanelId.uuidString.prefix(5)) " + + "fromIntent=\(String(describing: ownedIntent)) toIntent=\(String(describing: targetIntent))" + ) +#endif + _ = panel.yieldFocusIntent(ownedIntent, in: window) + return + } + } + + private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .terminal(.findField): + return false + default: + return true + } + } + + private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.addressBar), .browser(.findField): + return false + default: + return true + } + } + + private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.webView), .panel: + return true + default: + return false + } + } + + private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.addressBar), .browser(.findField), .terminal(.findField): + return true + case .panel, .browser(.webView), .terminal(.surface): + return false + } } private func beginNonFocusSplitFocusReassert(