Add browser find focus debug logs (#1162)
* Add browser find focus debug logs * Allow browser find bar focus in portal host * Add split and terminal find debug logs * Avoid stealing search focus across splits * Generalize panel focus restore intent * Unify split focus intent activation * Address focus restore review feedback * Yield inactive panel focus before restore * Gate browser find focus retries by generation * Avoid repeated browser focus invalidation * Keep browser find ownership while find bar is open
This commit is contained in:
parent
527dfa6292
commit
ec10dfdaec
11 changed files with 1019 additions and 116 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<SurfaceSearchOverlay> { 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) " +
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue