Add DEBUG logging for browser omnibar/content focus handoff (#910)
* Add browser focus debug logging around omnibar/content handoff * Avoid release-only unused-value warnings in focus debug logs * Add omnibar focus writer trace logs * Fix omnibar tap focus race * Stabilize omnibar focus state transitions * Propagate event context into responder guard * Fix webview pointer hit testing in focus guard * Stop omnibar reacquire on pointer blur intent * Blur omnibar on webview click intent * Preserve pointer intent through webview focus handoff * Restore page input focus after omnibar escape * Fix omnibar escape focus handoff and restore retry * Track editable focus for omnibar restore and improve test diagnostics * Add omnibar focus tracker telemetry to failing UI test * Wait for page readiness before seeding focused input in UI test setup * Strengthen omnibar escape focus regression with post-click assertion * Use deterministic window offsets for post-escape web input click test * Always enforce webview responder on omnibar escape * Harden omnibar focus restore and address PR review feedback --------- Co-authored-by: tiffanysun1 <tiffanysun8@gmail.com>
This commit is contained in:
parent
58bcc929b2
commit
e680f1de55
6 changed files with 1659 additions and 71 deletions
|
|
@ -232,6 +232,8 @@ struct BrowserPanelView: View {
|
|||
@State private var omnibarPillFrame: CGRect = .zero
|
||||
@State private var addressBarHeight: CGFloat = 0
|
||||
@State private var lastHandledAddressBarFocusRequestId: UUID?
|
||||
@State private var pendingAddressBarFocusRetryRequestId: UUID?
|
||||
@State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0
|
||||
@State private var isBrowserThemeMenuPresented = false
|
||||
@State private var ghosttyBackgroundGeneration: Int = 0
|
||||
// Keep this below half of the compact omnibar height so it reads as a squircle,
|
||||
|
|
@ -379,7 +381,15 @@ struct BrowserPanelView: View {
|
|||
"addressFocused=\(addressBarFocused ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
onRequestPanelFocus()
|
||||
if addressBarFocused {
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.webViewClickBlur")
|
||||
#endif
|
||||
setAddressBarFocused(false, reason: "webView.clickIntent")
|
||||
}
|
||||
if !isFocused {
|
||||
onRequestPanelFocus()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
UserDefaults.standard.register(defaults: [
|
||||
|
|
@ -399,6 +409,9 @@ struct BrowserPanelView: View {
|
|||
autoFocusOmnibarIfBlank()
|
||||
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
|
||||
BrowserHistoryStore.shared.loadIfNeeded()
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "view.onAppear")
|
||||
#endif
|
||||
}
|
||||
.onChange(of: panel.focusFlashToken) { _ in
|
||||
triggerFocusFlashAnimation()
|
||||
|
|
@ -412,7 +425,7 @@ struct BrowserPanelView: View {
|
|||
!panel.shouldSuppressWebViewFocus(),
|
||||
addressWasEmpty,
|
||||
!isWebViewBlank() {
|
||||
addressBarFocused = false
|
||||
setAddressBarFocused(false, reason: "panel.currentURL.loaded")
|
||||
}
|
||||
}
|
||||
.onChange(of: browserThemeModeRaw) { _ in
|
||||
|
|
@ -429,17 +442,30 @@ struct BrowserPanelView: View {
|
|||
applyPendingAddressBarFocusRequestIfNeeded()
|
||||
}
|
||||
.onChange(of: isFocused) { focused in
|
||||
#if DEBUG
|
||||
logBrowserFocusState(
|
||||
event: "panelFocus.onChange",
|
||||
detail: "next=\(focused ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
// Ensure this view doesn't retain focus while hidden (bonsplit keepAllAlive).
|
||||
if focused {
|
||||
applyPendingAddressBarFocusRequestIfNeeded()
|
||||
autoFocusOmnibarIfBlank()
|
||||
} else {
|
||||
panel.invalidateAddressBarPageFocusRestoreAttempts()
|
||||
hideSuggestions()
|
||||
addressBarFocused = false
|
||||
setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused")
|
||||
}
|
||||
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
|
||||
}
|
||||
.onChange(of: addressBarFocused) { focused in
|
||||
#if DEBUG
|
||||
logBrowserFocusState(
|
||||
event: "addressBarFocus.onChange",
|
||||
detail: "next=\(focused ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
let urlString = panel.preferredURLStringForOmnibar() ?? ""
|
||||
if focused {
|
||||
panel.beginSuppressWebViewFocusForAddressBar()
|
||||
|
|
@ -447,6 +473,9 @@ struct BrowserPanelView: View {
|
|||
// Only request panel focus if this pane isn't currently focused. When already
|
||||
// focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit.
|
||||
if !isFocused {
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.requestPanelFocus")
|
||||
#endif
|
||||
onRequestPanelFocus()
|
||||
}
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString))
|
||||
|
|
@ -466,11 +495,17 @@ struct BrowserPanelView: View {
|
|||
inlineCompletion = nil
|
||||
}
|
||||
syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged")
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.onChange.applied")
|
||||
#endif
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
|
||||
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
|
||||
guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return }
|
||||
guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return }
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.moveSelection", detail: "delta=\(delta)")
|
||||
#endif
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta))
|
||||
applyOmnibarEffects(effects)
|
||||
refreshInlineCompletion()
|
||||
|
|
@ -484,7 +519,10 @@ struct BrowserPanelView: View {
|
|||
return panelId == panel.id
|
||||
}) { _ in
|
||||
if addressBarFocused {
|
||||
addressBarFocused = false
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.externalBlur")
|
||||
#endif
|
||||
setAddressBarFocused(false, reason: "notification.externalBlur")
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in
|
||||
|
|
@ -696,14 +734,14 @@ struct BrowserPanelView: View {
|
|||
panel.navigateSmart(omnibarState.buffer)
|
||||
hideSuggestions()
|
||||
suppressNextFocusLostRevert = true
|
||||
addressBarFocused = false
|
||||
setAddressBarFocused(false, reason: "omnibar.submit.navigate")
|
||||
}
|
||||
},
|
||||
onEscape: {
|
||||
handleOmnibarEscape()
|
||||
},
|
||||
onFieldLostFocus: {
|
||||
addressBarFocused = false
|
||||
setAddressBarFocused(false, reason: "omnibar.fieldLostFocus")
|
||||
},
|
||||
onMoveSelection: { delta in
|
||||
guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return }
|
||||
|
|
@ -773,6 +811,7 @@ struct BrowserPanelView: View {
|
|||
},
|
||||
paneTopChromeHeight: addressBarHeight
|
||||
)
|
||||
.accessibilityIdentifier("BrowserWebViewSurface")
|
||||
// Keep the host stable for normal pane churn, but force a remount when
|
||||
// BrowserPanel replaces its underlying WKWebView after process termination.
|
||||
.id(panel.webViewInstanceID)
|
||||
|
|
@ -782,7 +821,10 @@ struct BrowserPanelView: View {
|
|||
// Chrome-like behavior: clicking web content while editing the
|
||||
// omnibar should commit blur and revert transient edits.
|
||||
if addressBarFocused {
|
||||
addressBarFocused = false
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "webContent.tapBlur")
|
||||
#endif
|
||||
setAddressBarFocused(false, reason: "webContent.tapBlur")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
|
|
@ -792,7 +834,7 @@ struct BrowserPanelView: View {
|
|||
.onTapGesture {
|
||||
onRequestPanelFocus()
|
||||
if addressBarFocused {
|
||||
addressBarFocused = false
|
||||
setAddressBarFocused(false, reason: "placeholderContent.tapBlur")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -839,6 +881,82 @@ struct BrowserPanelView: View {
|
|||
cmuxWebView.allowsFirstResponderAcquisition = next
|
||||
}
|
||||
|
||||
private func setAddressBarFocused(_ focused: Bool, reason: String) {
|
||||
#if DEBUG
|
||||
if addressBarFocused == focused {
|
||||
logBrowserFocusState(
|
||||
event: "addressBarFocus.write.noop",
|
||||
detail: "reason=\(reason) value=\(focused ? 1 : 0)"
|
||||
)
|
||||
} else {
|
||||
logBrowserFocusState(
|
||||
event: "addressBarFocus.write",
|
||||
detail: "reason=\(reason) old=\(addressBarFocused ? 1 : 0) new=\(focused ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
addressBarFocused = focused
|
||||
}
|
||||
|
||||
private func browserFocusResponderChainContains(
|
||||
_ start: NSResponder?,
|
||||
target: NSResponder
|
||||
) -> Bool {
|
||||
var current = start
|
||||
var hops = 0
|
||||
while let responder = current, hops < 64 {
|
||||
if responder === target { return true }
|
||||
current = responder.nextResponder
|
||||
hops += 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isPanelFocusedInModel() -> Bool {
|
||||
guard let app = AppDelegate.shared,
|
||||
let manager = app.tabManagerFor(tabId: panel.workspaceId),
|
||||
manager.selectedTabId == panel.workspaceId,
|
||||
let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }) else {
|
||||
return false
|
||||
}
|
||||
return workspace.focusedPanelId == panel.id
|
||||
}
|
||||
|
||||
private func shouldApplyAddressBarExitFallback(in window: NSWindow) -> Bool {
|
||||
panel.webView.window === window && isPanelFocusedInModel()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func browserFocusWindow() -> NSWindow? {
|
||||
panel.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
|
||||
private func browserFocusResponderDescription(_ responder: NSResponder?) -> String {
|
||||
guard let responder else { return "nil" }
|
||||
return String(describing: type(of: responder))
|
||||
}
|
||||
|
||||
private func logBrowserFocusState(event: String, detail: String = "") {
|
||||
let window = browserFocusWindow()
|
||||
let firstResponder = window?.firstResponder
|
||||
let firstResponderType = browserFocusResponderDescription(firstResponder)
|
||||
let webResponder = browserFocusResponderChainContains(firstResponder, target: panel.webView) ? 1 : 0
|
||||
var line =
|
||||
"browser.focus.trace event=\(event) panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"panelFocused=\(isFocused ? 1 : 0) addrFocused=\(addressBarFocused ? 1 : 0) " +
|
||||
"suppressWeb=\(panel.shouldSuppressWebViewFocus() ? 1 : 0) " +
|
||||
"suppressAuto=\(panel.shouldSuppressOmnibarAutofocus() ? 1 : 0) " +
|
||||
"webResponder=\(webResponder) win=\(window?.windowNumber ?? -1) fr=\(firstResponderType)"
|
||||
if let pending = panel.pendingAddressBarFocusRequestId {
|
||||
line += " pending=\(pending.uuidString.prefix(8))"
|
||||
}
|
||||
if !detail.isEmpty {
|
||||
line += " \(detail)"
|
||||
}
|
||||
dlog(line)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func syncURLFromPanel() {
|
||||
let urlString = panel.preferredURLStringForOmnibar() ?? ""
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .panelURLChanged(currentURLString: urlString))
|
||||
|
|
@ -868,12 +986,57 @@ struct BrowserPanelView: View {
|
|||
return false
|
||||
}
|
||||
|
||||
private func clearPendingAddressBarFocusRetry() {
|
||||
pendingAddressBarFocusRetryRequestId = nil
|
||||
pendingAddressBarFocusRetryGeneration &+= 1
|
||||
}
|
||||
|
||||
private func schedulePendingAddressBarFocusRetryIfNeeded(requestId: UUID) {
|
||||
guard pendingAddressBarFocusRetryRequestId != requestId else { return }
|
||||
pendingAddressBarFocusRetryRequestId = requestId
|
||||
pendingAddressBarFocusRetryGeneration &+= 1
|
||||
let generation = pendingAddressBarFocusRetryGeneration
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) {
|
||||
guard pendingAddressBarFocusRetryGeneration == generation else { return }
|
||||
pendingAddressBarFocusRetryRequestId = nil
|
||||
guard panel.pendingAddressBarFocusRequestId == requestId else { return }
|
||||
applyPendingAddressBarFocusRequestIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPendingAddressBarFocusRequestIfNeeded() {
|
||||
guard let requestId = panel.pendingAddressBarFocusRequestId else { return }
|
||||
guard !isCommandPaletteVisibleForPanelWindow() else { return }
|
||||
guard lastHandledAddressBarFocusRequestId != requestId else { return }
|
||||
guard let requestId = panel.pendingAddressBarFocusRequestId else {
|
||||
clearPendingAddressBarFocusRetry()
|
||||
return
|
||||
}
|
||||
guard !isCommandPaletteVisibleForPanelWindow() else {
|
||||
#if DEBUG
|
||||
logBrowserFocusState(
|
||||
event: "addressBarFocus.request.apply.skip",
|
||||
detail: "reason=command_palette_visible request=\(requestId.uuidString.prefix(8))"
|
||||
)
|
||||
#endif
|
||||
schedulePendingAddressBarFocusRetryIfNeeded(requestId: requestId)
|
||||
return
|
||||
}
|
||||
clearPendingAddressBarFocusRetry()
|
||||
guard lastHandledAddressBarFocusRequestId != requestId else {
|
||||
#if DEBUG
|
||||
logBrowserFocusState(
|
||||
event: "addressBarFocus.request.apply.skip",
|
||||
detail: "reason=already_handled request=\(requestId.uuidString.prefix(8))"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
lastHandledAddressBarFocusRequestId = requestId
|
||||
panel.beginSuppressWebViewFocusForAddressBar()
|
||||
#if DEBUG
|
||||
logBrowserFocusState(
|
||||
event: "addressBarFocus.request.apply",
|
||||
detail: "request=\(requestId.uuidString.prefix(8))"
|
||||
)
|
||||
#endif
|
||||
|
||||
if addressBarFocused {
|
||||
// Re-run focus behavior (select-all/refresh suggestions) when focus is
|
||||
|
|
@ -882,11 +1045,29 @@ struct BrowserPanelView: View {
|
|||
let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString))
|
||||
applyOmnibarEffects(effects)
|
||||
refreshInlineCompletion()
|
||||
#if DEBUG
|
||||
logBrowserFocusState(
|
||||
event: "addressBarFocus.request.apply",
|
||||
detail: "request=\(requestId.uuidString.prefix(8)) mode=refresh"
|
||||
)
|
||||
#endif
|
||||
} else {
|
||||
addressBarFocused = true
|
||||
setAddressBarFocused(true, reason: "request.apply")
|
||||
#if DEBUG
|
||||
logBrowserFocusState(
|
||||
event: "addressBarFocus.request.apply",
|
||||
detail: "request=\(requestId.uuidString.prefix(8)) mode=set_focused"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
panel.acknowledgeAddressBarFocusRequest(requestId)
|
||||
#if DEBUG
|
||||
logBrowserFocusState(
|
||||
event: "addressBarFocus.request.ack",
|
||||
detail: "request=\(requestId.uuidString.prefix(8))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes.
|
||||
|
|
@ -896,15 +1077,48 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
private func autoFocusOmnibarIfBlank() {
|
||||
guard isFocused else { return }
|
||||
guard !addressBarFocused else { return }
|
||||
guard !isCommandPaletteVisibleForPanelWindow() else { return }
|
||||
guard isFocused else {
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=panel_not_focused")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
guard !addressBarFocused else {
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=already_focused")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
guard !isCommandPaletteVisibleForPanelWindow() else {
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=command_palette_visible")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
// If a test/automation explicitly focused WebKit, don't steal focus back.
|
||||
guard !panel.shouldSuppressOmnibarAutofocus() else { return }
|
||||
guard !panel.shouldSuppressOmnibarAutofocus() else {
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=autofocus_suppressed")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
// If a real navigation is underway (e.g. open_browser https://...), don't steal focus.
|
||||
guard !panel.webView.isLoading else { return }
|
||||
guard isWebViewBlank() else { return }
|
||||
addressBarFocused = true
|
||||
guard !panel.webView.isLoading else {
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=webview_loading")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
guard isWebViewBlank() else {
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=webview_not_blank")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
setAddressBarFocused(true, reason: "autoFocus.blank")
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.autoFocus.apply")
|
||||
#endif
|
||||
}
|
||||
|
||||
private func openDevTools() {
|
||||
|
|
@ -924,13 +1138,15 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
private func handleOmnibarTap() {
|
||||
onRequestPanelFocus()
|
||||
guard !addressBarFocused else { return }
|
||||
// `focusPane` converges selection and can transiently move first responder to WebKit.
|
||||
// Reassert omnibar focus on the next runloop for click-to-type behavior.
|
||||
DispatchQueue.main.async {
|
||||
addressBarFocused = true
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBar.tap")
|
||||
#endif
|
||||
if !addressBarFocused {
|
||||
// Mark focused before pane selection converges so WebKit focus is not
|
||||
// briefly re-acquired during `focusPane`.
|
||||
setAddressBarFocused(true, reason: "omnibar.tap")
|
||||
}
|
||||
onRequestPanelFocus()
|
||||
}
|
||||
|
||||
private func hideSuggestions() {
|
||||
|
|
@ -961,7 +1177,7 @@ struct BrowserPanelView: View {
|
|||
hideSuggestions()
|
||||
inlineCompletion = nil
|
||||
suppressNextFocusLostRevert = true
|
||||
addressBarFocused = false
|
||||
setAddressBarFocused(false, reason: "suggestion.commit")
|
||||
}
|
||||
|
||||
private func handleOmnibarEscape() {
|
||||
|
|
@ -1262,14 +1478,58 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
if effects.shouldBlurToWebView {
|
||||
hideSuggestions()
|
||||
addressBarFocused = false
|
||||
// This transition is stateful: drop omnibar focus suppression before
|
||||
// attempting responder handoff so WKWebView can actually become first responder.
|
||||
panel.endSuppressWebViewFocusForAddressBar()
|
||||
syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.preHandoff")
|
||||
setAddressBarFocused(false, reason: "effects.blurToWebView")
|
||||
DispatchQueue.main.async {
|
||||
guard isFocused else { return }
|
||||
guard let window = panel.webView.window,
|
||||
!panel.webView.isHiddenOrHasHiddenAncestor else { return }
|
||||
guard shouldApplyAddressBarExitFallback(in: window) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"result=skip_not_focused"
|
||||
)
|
||||
#endif
|
||||
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
|
||||
return
|
||||
}
|
||||
syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.handoff")
|
||||
panel.clearWebViewFocusSuppression()
|
||||
window.makeFirstResponder(panel.webView)
|
||||
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
|
||||
let focusedWebView = window.makeFirstResponder(panel.webView)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"focusedWebView=\(focusedWebView ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
panel.restoreAddressBarPageFocusIfNeeded { restored in
|
||||
guard shouldApplyAddressBarExitFallback(in: window) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"result=skip_stale_restore restored=\(restored ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
|
||||
return
|
||||
}
|
||||
let hasWebViewResponder =
|
||||
browserFocusResponderChainContains(window.firstResponder, target: panel.webView)
|
||||
if !hasWebViewResponder {
|
||||
let fallbackFocusedWebView = window.makeFirstResponder(panel.webView)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"fallbackFocusedWebView=\(fallbackFocusedWebView ? 1 : 0) " +
|
||||
"restored=\(restored ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2282,10 +2542,10 @@ struct OmnibarSuggestion: Identifiable, Hashable {
|
|||
}
|
||||
|
||||
func browserOmnibarShouldReacquireFocusAfterEndEditing(
|
||||
suppressWebViewFocus: Bool,
|
||||
desiredOmnibarFocus: Bool,
|
||||
nextResponderIsOtherTextField: Bool
|
||||
) -> Bool {
|
||||
suppressWebViewFocus && !nextResponderIsOtherTextField
|
||||
desiredOmnibarFocus && !nextResponderIsOtherTextField
|
||||
}
|
||||
|
||||
private final class OmnibarNativeTextField: NSTextField {
|
||||
|
|
@ -2310,7 +2570,11 @@ private final class OmnibarNativeTextField: NSTextField {
|
|||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
dlog("browser.omnibarClick")
|
||||
let frType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
dlog(
|
||||
"browser.omnibarClick win=\(window?.windowNumber ?? -1) " +
|
||||
"fr=\(frType) hasEditor=\(currentEditor() == nil ? 0 : 1)"
|
||||
)
|
||||
#endif
|
||||
onPointerDown?()
|
||||
|
||||
|
|
@ -2318,7 +2582,14 @@ private final class OmnibarNativeTextField: NSTextField {
|
|||
// First click — activate editing and select all (standard URL bar behavior).
|
||||
// Avoids NSTextView's tracking loop which can spin forever if text layout
|
||||
// enters an infinite invalidation cycle (e.g. under memory pressure).
|
||||
window?.makeFirstResponder(self)
|
||||
let result = window?.makeFirstResponder(self) ?? false
|
||||
#if DEBUG
|
||||
let frAfter = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
dlog(
|
||||
"browser.omnibarClick.makeFirstResponder result=\(result ? 1 : 0) " +
|
||||
"win=\(window?.windowNumber ?? -1) fr=\(frAfter)"
|
||||
)
|
||||
#endif
|
||||
currentEditor()?.selectAll(nil)
|
||||
shiftClickAnchor = nil
|
||||
} else {
|
||||
|
|
@ -2432,6 +2703,35 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
self.parent = parent
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func logFocusEvent(_ event: String, detail: String = "") {
|
||||
let window = parentField?.window
|
||||
let responder = window?.firstResponder
|
||||
let responderType = responder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
let responderIsField: Int = {
|
||||
guard let field = parentField else { return 0 }
|
||||
if responder === field { return 1 }
|
||||
if let editor = responder as? NSTextView,
|
||||
(editor.delegate as? NSTextField) === field {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}()
|
||||
let pendingValue: String = {
|
||||
guard let pendingFocusRequest else { return "nil" }
|
||||
return pendingFocusRequest ? "focus" : "blur"
|
||||
}()
|
||||
var line =
|
||||
"browser.focus.field event=\(event) focused=\(parent.isFocused ? 1 : 0) " +
|
||||
"pending=\(pendingValue) suppressWeb=\(parent.shouldSuppressWebViewFocus() ? 1 : 0) " +
|
||||
"win=\(window?.windowNumber ?? -1) fr=\(responderType) frIsField=\(responderIsField)"
|
||||
if !detail.isEmpty {
|
||||
line += " \(detail)"
|
||||
}
|
||||
dlog(line)
|
||||
}
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
if let selectionObserver {
|
||||
NotificationCenter.default.removeObserver(selectionObserver)
|
||||
|
|
@ -2454,16 +2754,77 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
return false
|
||||
}
|
||||
|
||||
private func isPointerDownEvent(_ event: NSEvent) -> Bool {
|
||||
switch event.type {
|
||||
case .leftMouseDown, .rightMouseDown, .otherMouseDown:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func topHitViewForCurrentPointerEvent(window: NSWindow) -> NSView? {
|
||||
guard let event = NSApp.currentEvent, isPointerDownEvent(event) else {
|
||||
return nil
|
||||
}
|
||||
if event.windowNumber != 0, event.windowNumber != window.windowNumber {
|
||||
return nil
|
||||
}
|
||||
if let eventWindow = event.window, eventWindow !== window {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let contentView = window.contentView,
|
||||
let themeFrame = contentView.superview {
|
||||
let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil)
|
||||
if let hitInTheme = themeFrame.hitTest(pointInTheme) {
|
||||
return hitInTheme
|
||||
}
|
||||
}
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
return nil
|
||||
}
|
||||
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
|
||||
return contentView.hitTest(pointInContent)
|
||||
}
|
||||
|
||||
private func pointerDownBlurIntent(window: NSWindow?) -> Bool {
|
||||
guard let window, let field = parentField else { return false }
|
||||
guard let hitView = topHitViewForCurrentPointerEvent(window: window) else {
|
||||
return false
|
||||
}
|
||||
|
||||
if hitView === field || hitView.isDescendant(of: field) {
|
||||
return false
|
||||
}
|
||||
if let textView = hitView as? NSTextView,
|
||||
let delegateField = textView.delegate as? NSTextField,
|
||||
delegateField === field {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool {
|
||||
if pointerDownBlurIntent(window: window) {
|
||||
return false
|
||||
}
|
||||
return browserOmnibarShouldReacquireFocusAfterEndEditing(
|
||||
suppressWebViewFocus: parent.shouldSuppressWebViewFocus(),
|
||||
desiredOmnibarFocus: parent.isFocused,
|
||||
nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window)
|
||||
)
|
||||
}
|
||||
|
||||
func controlTextDidBeginEditing(_ obj: Notification) {
|
||||
#if DEBUG
|
||||
logFocusEvent("controlTextDidBeginEditing")
|
||||
#endif
|
||||
if !parent.isFocused {
|
||||
DispatchQueue.main.async {
|
||||
#if DEBUG
|
||||
self.logFocusEvent("controlTextDidBeginEditing.asyncSetFocused", detail: "old=0 new=1")
|
||||
#endif
|
||||
self.parent.isFocused = true
|
||||
}
|
||||
}
|
||||
|
|
@ -2472,16 +2833,33 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
}
|
||||
|
||||
func controlTextDidEndEditing(_ obj: Notification) {
|
||||
#if DEBUG
|
||||
let nextOther = nextResponderIsOtherTextField(window: parentField?.window)
|
||||
let pointerBlur = pointerDownBlurIntent(window: parentField?.window)
|
||||
logFocusEvent(
|
||||
"controlTextDidEndEditing",
|
||||
detail: "nextOther=\(nextOther ? 1 : 0) pointerBlur=\(pointerBlur ? 1 : 0) shouldReacquire=\(shouldReacquireFocusAfterEndEditing(window: parentField?.window) ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
if parent.isFocused {
|
||||
if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
|
||||
#if DEBUG
|
||||
logFocusEvent("controlTextDidEndEditing.reacquire.begin")
|
||||
#endif
|
||||
guard pendingFocusRequest != true else { return }
|
||||
pendingFocusRequest = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pendingFocusRequest = nil
|
||||
#if DEBUG
|
||||
self.logFocusEvent("controlTextDidEndEditing.reacquire.tick")
|
||||
#endif
|
||||
guard self.parent.isFocused else { return }
|
||||
guard let field = self.parentField, let window = field.window else { return }
|
||||
guard self.shouldReacquireFocusAfterEndEditing(window: window) else {
|
||||
#if DEBUG
|
||||
self.logFocusEvent("controlTextDidEndEditing.reacquire.cancel")
|
||||
#endif
|
||||
self.parent.onFieldLostFocus()
|
||||
return
|
||||
}
|
||||
|
|
@ -2492,11 +2870,21 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
field.currentEditor() != nil ||
|
||||
((fr as? NSTextView)?.delegate as? NSTextField) === field
|
||||
if !isAlreadyFocused {
|
||||
#if DEBUG
|
||||
self.logFocusEvent("controlTextDidEndEditing.reacquire.apply")
|
||||
#endif
|
||||
window.makeFirstResponder(field)
|
||||
} else {
|
||||
#if DEBUG
|
||||
self.logFocusEvent("controlTextDidEndEditing.reacquire.skip", detail: "reason=already_focused")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
logFocusEvent("controlTextDidEndEditing.blur")
|
||||
#endif
|
||||
parent.onFieldLostFocus()
|
||||
}
|
||||
detachSelectionObserver()
|
||||
|
|
@ -2725,28 +3113,66 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
nsView.currentEditor() != nil ||
|
||||
((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView
|
||||
if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
|
||||
#if DEBUG
|
||||
context.coordinator.logFocusEvent(
|
||||
"updateNSView.requestFocus.begin",
|
||||
detail: "isFocused=1 isFirstResponder=0"
|
||||
)
|
||||
#endif
|
||||
// Defer to avoid triggering input method XPC during layout pass,
|
||||
// which can crash via re-entrant view hierarchy modification.
|
||||
context.coordinator.pendingFocusRequest = true
|
||||
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
|
||||
coordinator?.pendingFocusRequest = nil
|
||||
guard let nsView, let window = nsView.window else { return }
|
||||
#if DEBUG
|
||||
if coordinator?.parent.isFocused != true {
|
||||
coordinator?.logFocusEvent("updateNSView.requestFocus.cancel", detail: "reason=stale_state")
|
||||
return
|
||||
}
|
||||
#endif
|
||||
guard coordinator?.parent.isFocused == true else { return }
|
||||
#if DEBUG
|
||||
coordinator?.logFocusEvent("updateNSView.requestFocus.tick")
|
||||
#endif
|
||||
let fr = window.firstResponder
|
||||
let alreadyFocused = fr === nsView ||
|
||||
nsView.currentEditor() != nil ||
|
||||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
|
||||
guard !alreadyFocused else { return }
|
||||
#if DEBUG
|
||||
coordinator?.logFocusEvent("updateNSView.requestFocus.apply")
|
||||
#endif
|
||||
window.makeFirstResponder(nsView)
|
||||
}
|
||||
} else if !isFocused, isFirstResponder, context.coordinator.pendingFocusRequest != false {
|
||||
#if DEBUG
|
||||
context.coordinator.logFocusEvent(
|
||||
"updateNSView.requestBlur.begin",
|
||||
detail: "isFocused=0 isFirstResponder=1"
|
||||
)
|
||||
#endif
|
||||
context.coordinator.pendingFocusRequest = false
|
||||
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
|
||||
coordinator?.pendingFocusRequest = nil
|
||||
guard let nsView, let window = nsView.window else { return }
|
||||
#if DEBUG
|
||||
if coordinator?.parent.isFocused == true {
|
||||
coordinator?.logFocusEvent("updateNSView.requestBlur.cancel", detail: "reason=stale_state")
|
||||
return
|
||||
}
|
||||
#endif
|
||||
guard coordinator?.parent.isFocused == false else { return }
|
||||
#if DEBUG
|
||||
coordinator?.logFocusEvent("updateNSView.requestBlur.tick")
|
||||
#endif
|
||||
let fr = window.firstResponder
|
||||
let stillFirst = fr === nsView ||
|
||||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
|
||||
guard stillFirst else { return }
|
||||
#if DEBUG
|
||||
coordinator?.logFocusEvent("updateNSView.requestBlur.apply")
|
||||
#endif
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
}
|
||||
|
|
@ -3995,20 +4421,53 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
isPanelFocused: Bool
|
||||
) {
|
||||
// Focus handling. Avoid fighting the address bar when it is focused.
|
||||
guard let window = nsView.window else { return }
|
||||
guard let window = nsView.window else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"action=skip reason=no_window shouldFocus=\(shouldFocusWebView ? 1 : 0) " +
|
||||
"panelFocused=\(isPanelFocused ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
if shouldFocusWebView {
|
||||
if panel.shouldSuppressWebViewFocus() {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"action=skip reason=suppressed panelFocused=\(isPanelFocused ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
if responderChainContains(window.firstResponder, target: webView) {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"action=skip reason=already_first_responder_chain"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
window.makeFirstResponder(webView)
|
||||
let result = window.makeFirstResponder(webView)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"action=focus result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
} else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) {
|
||||
// Only force-resign WebView focus when this panel itself is not focused.
|
||||
// If the panel is focused but the omnibar-focus state is briefly stale, aggressively
|
||||
// clearing first responder here can undo programmatic webview focus (socket tests).
|
||||
window.makeFirstResponder(nil)
|
||||
let result = window.makeFirstResponder(nil)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"action=resign result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue