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:
Lawrence Chen 2026-03-07 03:05:13 -08:00 committed by GitHub
parent 58bcc929b2
commit e680f1de55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1659 additions and 71 deletions

View file

@ -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
}
}