From e680f1de55fdedaacf9dccd23ca159fcbf4dd1f3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 7 Mar 2026 03:05:13 -0800 Subject: [PATCH] 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 --- Sources/AppDelegate.swift | 618 +++++++++++++++++- Sources/Panels/BrowserPanel.swift | 415 +++++++++++- Sources/Panels/BrowserPanelView.swift | 535 +++++++++++++-- Sources/Panels/CmuxWebView.swift | 4 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 10 +- .../BrowserPaneNavigationKeybindUITests.swift | 148 +++++ 6 files changed, 1659 insertions(+), 71 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fa539d3d..28b80d6f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5510,7 +5510,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - guard let self else { return } + guard self != nil else { return } runSetupWhenWindowReady() } } @@ -5607,6 +5607,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "ghosttyGotoSplitDownShortcut": ghosttyGotoSplitDownShortcut?.displayString ?? "", "webViewFocused": "true" ]) + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] == "1" { + setupFocusedInputForGotoSplitUITest(panel: browserPanel) + } return } @@ -5652,6 +5655,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return } guard let panelId = notification.object as? UUID else { return } self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarFocus") + self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarFocus") }) gotoSplitUITestObservers.append(NotificationCenter.default.addObserver( @@ -5662,6 +5666,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return } guard let panelId = notification.object as? UUID else { return } self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarExit") + self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarExit") }) } @@ -5689,6 +5694,329 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel, attempt: Int = 0) { + let maxAttempts = 80 + guard attempt < maxAttempts else { + writeGotoSplitTestData([ + "webInputFocusSeeded": "false", + "setupError": "Timed out focusing page input for omnibar restore test" + ]) + return + } + + let script = """ + (() => { + try { + const trackerInstalled = window.__cmuxAddressBarFocusTrackerInstalled === true; + const readyState = String(document.readyState || ""); + if (!trackerInstalled || readyState !== "complete") { + const active = document.activeElement; + return { + focused: false, + id: "", + activeId: active && typeof active.id === "string" ? active.id : "", + activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", + trackerInstalled, + trackedStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + readyState + }; + } + + const ensureInput = (id, value) => { + const existing = document.getElementById(id); + const input = (existing && existing.tagName && existing.tagName.toLowerCase() === "input") + ? existing + : (() => { + const created = document.createElement("input"); + created.id = id; + created.type = "text"; + created.value = value; + return created; + })(); + input.autocapitalize = "off"; + input.autocomplete = "off"; + input.spellcheck = false; + input.style.display = "block"; + input.style.width = "100%"; + input.style.margin = "0"; + input.style.padding = "8px 10px"; + input.style.border = "1px solid #5f6368"; + input.style.borderRadius = "6px"; + input.style.boxSizing = "border-box"; + input.style.fontSize = "14px"; + input.style.fontFamily = "system-ui, -apple-system, sans-serif"; + input.style.background = "white"; + input.style.color = "black"; + return input; + }; + + let container = document.getElementById("cmux-ui-test-focus-container"); + if (!container || !container.tagName || container.tagName.toLowerCase() !== "div") { + container = document.createElement("div"); + container.id = "cmux-ui-test-focus-container"; + document.body.appendChild(container); + } + container.style.position = "fixed"; + container.style.left = "24px"; + container.style.top = "24px"; + container.style.width = "min(520px, calc(100vw - 48px))"; + container.style.display = "grid"; + container.style.rowGap = "12px"; + container.style.padding = "12px"; + container.style.background = "rgba(255,255,255,0.92)"; + container.style.border = "1px solid rgba(95,99,104,0.55)"; + container.style.borderRadius = "8px"; + container.style.boxShadow = "0 2px 10px rgba(0,0,0,0.2)"; + container.style.zIndex = "2147483647"; + + const input = ensureInput("cmux-ui-test-focus-input", "cmux-ui-focus-primary"); + const secondaryInput = ensureInput("cmux-ui-test-focus-input-secondary", "cmux-ui-focus-secondary"); + if (input.parentElement !== container) { + container.appendChild(input); + } + if (secondaryInput.parentElement !== container) { + container.appendChild(secondaryInput); + } + + input.focus({ preventScroll: true }); + if (typeof input.setSelectionRange === "function") { + const end = input.value.length; + input.setSelectionRange(end, end); + } + + let trackedFocusId = input.getAttribute("data-cmux-addressbar-focus-id"); + if (!trackedFocusId) { + trackedFocusId = "cmux-ui-test-focus-input-tracked"; + input.setAttribute("data-cmux-addressbar-focus-id", trackedFocusId); + } + const selectionStart = typeof input.selectionStart === "number" ? input.selectionStart : null; + const selectionEnd = typeof input.selectionEnd === "number" ? input.selectionEnd : null; + if ( + !window.__cmuxAddressBarFocusState || + typeof window.__cmuxAddressBarFocusState.id !== "string" || + window.__cmuxAddressBarFocusState.id !== trackedFocusId + ) { + window.__cmuxAddressBarFocusState = { id: trackedFocusId, selectionStart, selectionEnd }; + } + + const secondaryRect = secondaryInput.getBoundingClientRect(); + const viewportWidth = Math.max(Number(window.innerWidth) || 0, 1); + const viewportHeight = Math.max(Number(window.innerHeight) || 0, 1); + const secondaryCenterX = Math.min( + 0.98, + Math.max(0.02, (secondaryRect.left + (secondaryRect.width / 2)) / viewportWidth) + ); + const secondaryCenterY = Math.min( + 0.98, + Math.max(0.02, (secondaryRect.top + (secondaryRect.height / 2)) / viewportHeight) + ); + const active = document.activeElement; + return { + focused: active === input, + id: input.id || "", + secondaryId: secondaryInput.id || "", + secondaryCenterX, + secondaryCenterY, + activeId: active && typeof active.id === "string" ? active.id : "", + activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", + trackerInstalled, + trackedStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + readyState + }; + } catch (_) { + return { + focused: false, + id: "", + secondaryId: "", + secondaryCenterX: -1, + secondaryCenterY: -1, + activeId: "", + activeTag: "", + trackerInstalled: false, + trackedStateId: "", + readyState: "" + }; + } + })(); + """ + + panel.webView.evaluateJavaScript(script) { [weak self] result, _ in + guard let self else { return } + let payload = result as? [String: Any] + let focused = (payload?["focused"] as? Bool) ?? false + let inputId = (payload?["id"] as? String) ?? "" + let secondaryInputId = (payload?["secondaryId"] as? String) ?? "" + let secondaryCenterX = (payload?["secondaryCenterX"] as? NSNumber)?.doubleValue ?? -1 + let secondaryCenterY = (payload?["secondaryCenterY"] as? NSNumber)?.doubleValue ?? -1 + let activeId = (payload?["activeId"] as? String) ?? "" + let trackerInstalled = (payload?["trackerInstalled"] as? Bool) ?? false + let trackedStateId = (payload?["trackedStateId"] as? String) ?? "" + let readyState = (payload?["readyState"] as? String) ?? "" + var secondaryClickOffsetX = -1.0 + var secondaryClickOffsetY = -1.0 + if let window = panel.webView.window { + let webFrame = panel.webView.convert(panel.webView.bounds, to: nil) + let contentHeight = Double(window.contentView?.bounds.height ?? 0) + if webFrame.width > 1, + webFrame.height > 1, + contentHeight > 1, + secondaryCenterX > 0, + secondaryCenterX < 1, + secondaryCenterY > 0, + secondaryCenterY < 1 { + let xInContent = Double(webFrame.minX) + (secondaryCenterX * Double(webFrame.width)) + let yFromTopInWeb = secondaryCenterY * Double(webFrame.height) + let yInContent = Double(webFrame.maxY) - yFromTopInWeb + let yFromTopInContent = contentHeight - yInContent + let titlebarHeight = max(0, Double(window.frame.height) - contentHeight) + secondaryClickOffsetX = xInContent + secondaryClickOffsetY = titlebarHeight + yFromTopInContent + } + } + if focused, + !inputId.isEmpty, + !secondaryInputId.isEmpty, + inputId == activeId, + trackerInstalled, + !trackedStateId.isEmpty, + secondaryCenterX > 0, + secondaryCenterX < 1, + secondaryCenterY > 0, + secondaryCenterY < 1, + secondaryClickOffsetX > 0, + secondaryClickOffsetY > 0 { + self.writeGotoSplitTestData([ + "webInputFocusSeeded": "true", + "webInputFocusElementId": inputId, + "webInputFocusSecondaryElementId": secondaryInputId, + "webInputFocusSecondaryCenterX": "\(secondaryCenterX)", + "webInputFocusSecondaryCenterY": "\(secondaryCenterY)", + "webInputFocusSecondaryClickOffsetX": "\(secondaryClickOffsetX)", + "webInputFocusSecondaryClickOffsetY": "\(secondaryClickOffsetY)", + "webInputFocusActiveElementId": activeId, + "webInputFocusTrackerInstalled": trackerInstalled ? "true" : "false", + "webInputFocusTrackedStateId": trackedStateId, + "webInputFocusReadyState": readyState + ]) + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.setupFocusedInputForGotoSplitUITest(panel: panel, attempt: attempt + 1) + } + } + } + + private func recordGotoSplitUITestActiveElement(panelId: UUID, keyPrefix: String) { + recordGotoSplitUITestActiveElementRetry(panelId: panelId, keyPrefix: keyPrefix, attempt: 0) + } + + private func recordGotoSplitUITestActiveElementRetry(panelId: UUID, keyPrefix: String, attempt: Int) { + let delays: [Double] = [0.05, 0.1, 0.25, 0.5] + let delay = attempt < delays.count ? delays[attempt] : delays.last! + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, + let tabManager, + let tab = tabManager.selectedWorkspace, + let panel = tab.browserPanel(for: panelId) else { return } + + self.evaluateGotoSplitUITestActiveElement(panel: panel) { snapshot in + let activeId = snapshot["id"] ?? "" + let expectedInputId = self.gotoSplitUITestExpectedInputId() ?? "" + if keyPrefix == "addressBarExit", + !expectedInputId.isEmpty, + activeId != expectedInputId, + attempt < delays.count - 1 { + self.recordGotoSplitUITestActiveElementRetry( + panelId: panelId, + keyPrefix: keyPrefix, + attempt: attempt + 1 + ) + return + } + + self.writeGotoSplitTestData([ + "\(keyPrefix)PanelId": panelId.uuidString, + "\(keyPrefix)ActiveElementId": activeId, + "\(keyPrefix)ActiveElementTag": snapshot["tag"] ?? "", + "\(keyPrefix)ActiveElementType": snapshot["type"] ?? "", + "\(keyPrefix)ActiveElementEditable": snapshot["editable"] ?? "false", + "\(keyPrefix)TrackedFocusStateId": snapshot["trackedFocusStateId"] ?? "", + "\(keyPrefix)FocusTrackerInstalled": snapshot["focusTrackerInstalled"] ?? "false" + ]) + } + } + } + + private func evaluateGotoSplitUITestActiveElement( + panel: BrowserPanel, + completion: @escaping ([String: String]) -> Void + ) { + let script = """ + (() => { + try { + const active = document.activeElement; + if (!active) { + return { id: "", tag: "", type: "", editable: "false" }; + } + const tag = (active.tagName || "").toLowerCase(); + const type = (active.type || "").toLowerCase(); + const editable = + !!active.isContentEditable || + tag === "textarea" || + (tag === "input" && type !== "hidden"); + return { + id: typeof active.id === "string" ? active.id : "", + tag, + type, + editable: editable ? "true" : "false", + trackedFocusStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + focusTrackerInstalled: + window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" + }; + } catch (_) { + return { + id: "", + tag: "", + type: "", + editable: "false", + trackedFocusStateId: "", + focusTrackerInstalled: "false" + }; + } + })(); + """ + + panel.webView.evaluateJavaScript(script) { result, _ in + let payload = result as? [String: Any] + completion([ + "id": (payload?["id"] as? String) ?? "", + "tag": (payload?["tag"] as? String) ?? "", + "type": (payload?["type"] as? String) ?? "", + "editable": (payload?["editable"] as? String) ?? "false", + "trackedFocusStateId": (payload?["trackedFocusStateId"] as? String) ?? "", + "focusTrackerInstalled": (payload?["focusTrackerInstalled"] as? String) ?? "false" + ]) + } + } + + private func gotoSplitUITestExpectedInputId() -> String? { + let env = ProcessInfo.processInfo.environment + guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return nil } + return loadGotoSplitTestData(at: path)["webInputFocusElementId"] + } + private func recordGotoSplitMoveIfNeeded(direction: NavigationDirection) { guard isGotoSplitUITestRecordingEnabled() else { return } guard let tabManager, let workspace = tabManager.selectedWorkspace else { return } @@ -6591,7 +6919,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if browserAddressBarFocusedPanelId != nil, cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { #if DEBUG - dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId") + let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + + "reason=terminal_first_responder fr=\(firstResponderType)" + ) #endif browserAddressBarFocusedPanelId = nil stopBrowserOmnibarSelectionRepeat() @@ -7274,6 +7607,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } dlog(line) } + + private func browserFocusStateSnapshot() -> String { + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let focused = tabManager?.selectedWorkspace?.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let addressBar = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let keyWindow = NSApp.keyWindow?.windowNumber ?? -1 + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + return "selected=\(selected) focused=\(focused) addr=\(addressBar) keyWin=\(keyWindow) fr=\(firstResponderType)" + } + + private func redactedDebugURL(_ url: URL?) -> String { + guard let url else { return "nil" } + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return "" + } + components.user = nil + components.password = nil + components.query = nil + components.fragment = nil + return components.string ?? "" + } #endif @discardableResult @@ -7281,9 +7635,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let tabManager, let workspace = tabManager.selectedWorkspace, let panel = workspace.browserPanel(for: panelId) else { +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " + + "result=miss \(browserFocusStateSnapshot())" + ) +#endif return false } +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) result=hit \(browserFocusStateSnapshot())" + ) +#endif workspace.focusPanel(panel.id) +#if DEBUG + let focusedAfter = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) focusedAfter=\(focusedAfter)" + ) +#endif focusBrowserAddressBar(in: panel) return true } @@ -7291,16 +7664,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { guard let panelId = tabManager?.openBrowser(url: url, insertAtEnd: insertAtEnd) else { +#if DEBUG + dlog( + "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + + "url=\(redactedDebugURL(url)) \(browserFocusStateSnapshot())" + ) +#endif return nil } +#if DEBUG + dlog( + "browser.focus.openAndFocus result=open_ok panel=\(panelId.uuidString.prefix(5)) " + + "insertAtEnd=\(insertAtEnd ? 1 : 0) url=\(redactedDebugURL(url))" + ) +#endif +#if DEBUG + let didFocus = focusBrowserAddressBar(panelId: panelId) + dlog( + "browser.focus.openAndFocus result=focus_request panel=\(panelId.uuidString.prefix(5)) " + + "focused=\(didFocus ? 1 : 0) \(browserFocusStateSnapshot())" + ) +#else _ = focusBrowserAddressBar(panelId: panelId) +#endif return panelId } private func focusBrowserAddressBar(in panel: BrowserPanel) { +#if DEBUG + let requestId = panel.requestAddressBarFocus() + dlog( + "browser.focus.addressBar.request panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" + ) +#else _ = panel.requestAddressBarFocus() +#endif browserAddressBarFocusedPanelId = panel.id +#if DEBUG + dlog( + "browser.focus.addressBar.sticky panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" + ) +#endif NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) +#if DEBUG + dlog( + "browser.focus.addressBar.notify panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8))" + ) +#endif } func focusedBrowserAddressBarPanelId() -> UUID? { @@ -7309,11 +7722,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { guard let panelId = browserAddressBarFocusedPanelId else { return nil } - guard let context = preferredMainWindowContextForShortcutRouting(event: event), - let workspace = context.tabManager.selectedWorkspace, - workspace.browserPanel(for: panelId) != nil else { + + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" + ) +#endif return nil } + + guard let workspace = context.tabManager.selectedWorkspace else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } + + guard workspace.browserPanel(for: panelId) != nil else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=panel_not_in_workspace workspace=\(workspace.id.uuidString.prefix(5)) " + + "event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } + +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" + ) +#endif return panelId } @@ -7330,7 +7776,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] guard isCommandOrControlOnly else { return false } - return chars == "n" || chars == "p" + let shouldBypass = chars == "n" || chars == "p" +#if DEBUG + if shouldBypass { + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.shortcutBypass panel=\(panelToken) " + + "chars=\(chars) flags=\(normalizedFlags.rawValue)" + ) + } +#endif + return shouldBypass } private func commandOmnibarSelectionDelta( @@ -7347,6 +7803,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func dispatchBrowserOmnibarSelectionMove(delta: Int) { guard delta != 0 else { return } guard let panelId = browserAddressBarFocusedPanelId else { return } +#if DEBUG + dlog( + "browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " + + "delta=\(delta) repeatKey=\(browserOmnibarRepeatKeyCode.map(String.init) ?? "nil")" + ) +#endif NotificationCenter.default.post( name: .browserMoveOmnibarSelection, object: panelId, @@ -7356,15 +7818,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) { guard delta != 0 else { return } - guard browserAddressBarFocusedPanelId != nil else { return } + guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " + + "result=skip_no_focused_address_bar" + ) +#endif + return + } if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta { +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=reuse" + ) +#endif return } stopBrowserOmnibarSelectionRepeat() browserOmnibarRepeatKeyCode = keyCode browserOmnibarRepeatDelta = delta +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=armed" + ) +#endif let start = DispatchWorkItem { [weak self] in self?.scheduleBrowserOmnibarSelectionRepeatTick() @@ -7376,11 +7860,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func scheduleBrowserOmnibarSelectionRepeatTick() { browserOmnibarRepeatStartWorkItem = nil guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") +#endif stopBrowserOmnibarSelectionRepeat() return } guard browserOmnibarRepeatKeyCode != nil else { return } +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.tick panel=\(panelToken) " + + "delta=\(browserOmnibarRepeatDelta)" + ) +#endif dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta) let tick = DispatchWorkItem { [weak self] in @@ -7391,12 +7885,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func stopBrowserOmnibarSelectionRepeat() { +#if DEBUG + let previousKeyCode = browserOmnibarRepeatKeyCode + let previousDelta = browserOmnibarRepeatDelta +#endif browserOmnibarRepeatStartWorkItem?.cancel() browserOmnibarRepeatTickWorkItem?.cancel() browserOmnibarRepeatStartWorkItem = nil browserOmnibarRepeatTickWorkItem = nil browserOmnibarRepeatKeyCode = nil browserOmnibarRepeatDelta = 0 +#if DEBUG + if previousKeyCode != nil || previousDelta != 0 { + dlog( + "browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " + + "delta=\(previousDelta)" + ) + } +#endif } private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) { @@ -7405,11 +7911,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent switch event.type { case .keyUp: if event.keyCode == browserOmnibarRepeatKeyCode { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " + + "action=stop" + ) +#endif stopBrowserOmnibarSelectionRepeat() } case .flagsChanged: let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if !flags.contains(.command) { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + + "flags=\(flags.rawValue) action=stop" + ) +#endif stopBrowserOmnibarSelectionRepeat() } default: @@ -9180,6 +9698,9 @@ enum MenuBarIconRenderer { private var cmuxFirstResponderGuardCurrentEventOverride: NSEvent? private var cmuxFirstResponderGuardHitViewOverride: NSView? #endif +private var cmuxFirstResponderGuardCurrentEventContext: NSEvent? +private var cmuxFirstResponderGuardHitViewContext: NSView? +private var cmuxFirstResponderGuardContextWindowNumber: Int? private var cmuxBrowserReturnForwardingDepth = 0 private var cmuxWindowFirstResponderBypassDepth = 0 private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 @@ -9221,6 +9742,7 @@ private extension NSWindow { let responderWebView = responder.flatMap { Self.cmuxOwningWebView(for: $0, in: self, event: currentEvent) } + var pointerInitiatedWebFocus = false if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( window: self, @@ -9244,6 +9766,7 @@ private extension NSWindow { event: currentEvent ) if pointerInitiatedFocus { + pointerInitiatedWebFocus = true #if DEBUG dlog( "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + @@ -9280,7 +9803,16 @@ private extension NSWindow { ) } #endif - let result = cmux_makeFirstResponder(responder) + let result: Bool + if pointerInitiatedWebFocus, let webView = responderWebView { + // `NSWindow.makeFirstResponder` may run before `CmuxWebView.mouseDown(with:)`. + // Preserve pointer intent during this synchronous responder change. + result = webView.withPointerFocusAllowance { + cmux_makeFirstResponder(responder) + } + } else { + result = cmux_makeFirstResponder(responder) + } if result { if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor { Self.cmuxTrackFieldEditor(fieldEditor, owningWebView: responderWebView) @@ -9292,6 +9824,18 @@ private extension NSWindow { } @objc func cmux_sendEvent(_ event: NSEvent) { + let previousContextEvent = cmuxFirstResponderGuardCurrentEventContext + let previousContextHitView = cmuxFirstResponderGuardHitViewContext + let previousContextWindowNumber = cmuxFirstResponderGuardContextWindowNumber + cmuxFirstResponderGuardCurrentEventContext = event + cmuxFirstResponderGuardHitViewContext = Self.cmuxHitViewForEventDispatch(in: self, event: event) + cmuxFirstResponderGuardContextWindowNumber = self.windowNumber + defer { + cmuxFirstResponderGuardCurrentEventContext = previousContextEvent + cmuxFirstResponderGuardHitViewContext = previousContextHitView + cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber + } + guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), let contentView = self.contentView else { cmux_sendEvent(event) @@ -9549,37 +10093,63 @@ private extension NSWindow { return found } - private static func cmuxCurrentEvent(for _: NSWindow) -> NSEvent? { + private static func cmuxCurrentEvent(for window: NSWindow) -> NSEvent? { #if DEBUG if let override = cmuxFirstResponderGuardCurrentEventOverride { return override } #endif + if cmuxFirstResponderGuardContextWindowNumber == window.windowNumber { + return cmuxFirstResponderGuardCurrentEventContext + } return NSApp.currentEvent } + private static func cmuxHitViewInThemeFrame(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView, + let themeFrame = contentView.superview else { + return nil + } + let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) + return themeFrame.hitTest(pointInTheme) + } + + private static func cmuxHitViewInContentView(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView else { + return nil + } + let pointInContent = contentView.convert(event.locationInWindow, from: nil) + return contentView.hitTest(pointInContent) + } + + private static func cmuxTopHitViewForEvent(in window: NSWindow, event: NSEvent) -> NSView? { + if let hitInThemeFrame = cmuxHitViewInThemeFrame(in: window, event: event) { + return hitInThemeFrame + } + return cmuxHitViewInContentView(in: window, event: event) + } + + private static func cmuxHitViewForEventDispatch(in window: NSWindow, event: NSEvent) -> NSView? { + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + return cmuxTopHitViewForEvent(in: window, event: event) + } + private static func cmuxHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { #if DEBUG if let override = cmuxFirstResponderGuardHitViewOverride { return override } #endif - guard let contentView = window.contentView else { return nil } - - if contentView.className == "NSGlassEffectView" { - let pointInContent = contentView.convert(event.locationInWindow, from: nil) - return contentView.hitTest(pointInContent) + if cmuxFirstResponderGuardContextWindowNumber == window.windowNumber, + let contextHitView = cmuxFirstResponderGuardHitViewContext { + return contextHitView } - - if let themeFrame = contentView.superview { - let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) - if let hit = themeFrame.hitTest(pointInTheme) { - return hit - } - } - - let pointInContent = contentView.convert(event.locationInWindow, from: nil) - return contentView.hitTest(pointInContent) + return cmuxTopHitViewForEvent(in: window, event: event) } private static func cmuxTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: CmuxWebView?) { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index fc803000..9a8d76c2 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1412,7 +1412,230 @@ final class BrowserPanel: Panel, ObservableObject { /// Used to keep omnibar text-field focus from being immediately stolen by panel focus. private var suppressWebViewFocusUntil: Date? private var suppressWebViewFocusForAddressBar: Bool = false + private var addressBarFocusRestoreGeneration: UInt64 = 0 private let blankURLString = "about:blank" + private static let addressBarFocusCaptureScript = """ + (() => { + try { + const syncState = (state) => { + window.__cmuxAddressBarFocusState = state; + try { + if (window.top && window.top !== window) { + window.top.postMessage({ cmuxAddressBarFocusState: state }, "*"); + } else if (window.top) { + window.top.__cmuxAddressBarFocusState = state; + } + } catch (_) {} + }; + + const active = document.activeElement; + if (!active) { + syncState(null); + return "cleared:none"; + } + + const tag = (active.tagName || "").toLowerCase(); + const type = (active.type || "").toLowerCase(); + const isEditable = + !!active.isContentEditable || + tag === "textarea" || + (tag === "input" && type !== "hidden"); + if (!isEditable) { + syncState(null); + return "cleared:noneditable"; + } + + let id = active.getAttribute("data-cmux-addressbar-focus-id"); + if (!id) { + id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); + active.setAttribute("data-cmux-addressbar-focus-id", id); + } + + const state = { id, selectionStart: null, selectionEnd: null }; + if (typeof active.selectionStart === "number" && typeof active.selectionEnd === "number") { + state.selectionStart = active.selectionStart; + state.selectionEnd = active.selectionEnd; + } + syncState(state); + return "captured:" + id; + } catch (_) { + return "error"; + } + })(); + """ + private static let addressBarFocusTrackingBootstrapScript = """ + (() => { + try { + if (window.__cmuxAddressBarFocusTrackerInstalled) return true; + window.__cmuxAddressBarFocusTrackerInstalled = true; + + const syncState = (state) => { + window.__cmuxAddressBarFocusState = state; + try { + if (window.top && window.top !== window) { + window.top.postMessage({ cmuxAddressBarFocusState: state }, "*"); + } else if (window.top) { + window.top.__cmuxAddressBarFocusState = state; + } + } catch (_) {} + }; + + if (window.top === window && !window.__cmuxAddressBarFocusMessageBridgeInstalled) { + window.__cmuxAddressBarFocusMessageBridgeInstalled = true; + window.addEventListener("message", (ev) => { + try { + const data = ev ? ev.data : null; + if (!data || !Object.prototype.hasOwnProperty.call(data, "cmuxAddressBarFocusState")) return; + window.__cmuxAddressBarFocusState = data.cmuxAddressBarFocusState || null; + } catch (_) {} + }, true); + } + + const isEditable = (el) => { + if (!el) return false; + const tag = (el.tagName || "").toLowerCase(); + const type = (el.type || "").toLowerCase(); + return !!el.isContentEditable || tag === "textarea" || (tag === "input" && type !== "hidden"); + }; + + const ensureFocusId = (el) => { + let id = el.getAttribute("data-cmux-addressbar-focus-id"); + if (!id) { + id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); + el.setAttribute("data-cmux-addressbar-focus-id", id); + } + return id; + }; + + const snapshot = (el) => { + if (!isEditable(el)) { + syncState(null); + return; + } + const state = { + id: ensureFocusId(el), + selectionStart: null, + selectionEnd: null + }; + if (typeof el.selectionStart === "number" && typeof el.selectionEnd === "number") { + state.selectionStart = el.selectionStart; + state.selectionEnd = el.selectionEnd; + } + syncState(state); + }; + + document.addEventListener("focusin", (ev) => { + snapshot(ev && ev.target ? ev.target : document.activeElement); + }, true); + document.addEventListener("selectionchange", () => { + snapshot(document.activeElement); + }, true); + document.addEventListener("input", () => { + snapshot(document.activeElement); + }, true); + document.addEventListener("mousedown", (ev) => { + const target = ev && ev.target ? ev.target : null; + if (!isEditable(target)) { + syncState(null); + } + }, true); + window.addEventListener("beforeunload", () => { + syncState(null); + }, true); + + snapshot(document.activeElement); + return true; + } catch (_) { + return false; + } + })(); + """ + private static let addressBarFocusRestoreScript = """ + (() => { + try { + const readState = () => { + let state = window.__cmuxAddressBarFocusState; + try { + if ((!state || typeof state.id !== "string" || !state.id) && + window.top && window.top.__cmuxAddressBarFocusState) { + state = window.top.__cmuxAddressBarFocusState; + } + } catch (_) {} + return state; + }; + + const clearState = () => { + window.__cmuxAddressBarFocusState = null; + try { + if (window.top && window.top !== window) { + window.top.postMessage({ cmuxAddressBarFocusState: null }, "*"); + } else if (window.top) { + window.top.__cmuxAddressBarFocusState = null; + } + } catch (_) {} + }; + + const state = readState(); + if (!state || typeof state.id !== "string" || !state.id) { + return "no_state"; + } + + const selector = '[data-cmux-addressbar-focus-id="' + state.id + '"]'; + const findTarget = (doc) => { + if (!doc) return null; + const direct = doc.querySelector(selector); + if (direct && direct.isConnected) return direct; + const frames = doc.querySelectorAll("iframe,frame"); + for (let i = 0; i < frames.length; i += 1) { + const frame = frames[i]; + try { + const childDoc = frame.contentDocument; + if (!childDoc) continue; + const nested = findTarget(childDoc); + if (nested) return nested; + } catch (_) {} + } + return null; + }; + + const target = findTarget(document); + if (!target) { + clearState(); + return "missing_target"; + } + + try { + target.focus({ preventScroll: true }); + } catch (_) { + try { target.focus(); } catch (_) {} + } + + let focused = false; + try { + focused = + target === target.ownerDocument.activeElement || + (typeof target.matches === "function" && target.matches(":focus")); + } catch (_) {} + if (!focused) { + return "not_focused"; + } + + if ( + typeof state.selectionStart === "number" && + typeof state.selectionEnd === "number" && + typeof target.setSelectionRange === "function" + ) { + try { + target.setSelectionRange(state.selectionStart, state.selectionEnd); + } catch (_) {} + } + clearState(); + return "restored"; + } catch (_) { + return "error"; + } + })(); + """ /// Published URL being displayed @Published private(set) var currentURL: URL? @@ -1561,6 +1784,15 @@ final class BrowserPanel: Panel, ObservableObject { forMainFrameOnly: false ) ) + // Track the last editable focused element continuously so omnibar exit can + // restore page input focus even if capture runs after first-responder handoff. + config.userContentController.addUserScript( + WKUserScript( + source: Self.addressBarFocusTrackingBootstrapScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + ) let webView = CmuxWebView(frame: .zero, configuration: config) webView.allowsBackForwardNavigationGestures = true @@ -2750,14 +2982,29 @@ extension BrowserPanel { func suppressOmnibarAutofocus(for seconds: TimeInterval) { suppressOmnibarAutofocusUntil = Date().addingTimeInterval(seconds) +#if DEBUG + dlog( + "browser.focus.omnibarAutofocus.suppress panel=\(id.uuidString.prefix(5)) " + + "seconds=\(String(format: "%.2f", seconds))" + ) +#endif } func suppressWebViewFocus(for seconds: TimeInterval) { suppressWebViewFocusUntil = Date().addingTimeInterval(seconds) +#if DEBUG + dlog( + "browser.focus.webView.suppress panel=\(id.uuidString.prefix(5)) " + + "seconds=\(String(format: "%.2f", seconds))" + ) +#endif } func clearWebViewFocusSuppression() { suppressWebViewFocusUntil = nil +#if DEBUG + dlog("browser.focus.webView.suppress.clear panel=\(id.uuidString.prefix(5))") +#endif } func shouldSuppressOmnibarAutofocus() -> Bool { @@ -2781,12 +3028,17 @@ extension BrowserPanel { } func beginSuppressWebViewFocusForAddressBar() { - if !suppressWebViewFocusForAddressBar { + let enteringAddressBar = !suppressWebViewFocusForAddressBar + if enteringAddressBar { #if DEBUG dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))") #endif + invalidateAddressBarPageFocusRestoreAttempts() } suppressWebViewFocusForAddressBar = true + if enteringAddressBar { + captureAddressBarPageFocusIfNeeded() + } } func endSuppressWebViewFocusForAddressBar() { @@ -2802,16 +3054,175 @@ extension BrowserPanel { func requestAddressBarFocus() -> UUID { beginSuppressWebViewFocusForAddressBar() if let pendingAddressBarFocusRequestId { +#if DEBUG + dlog( + "browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " + + "request=\(pendingAddressBarFocusRequestId.uuidString.prefix(8)) result=reuse_pending" + ) +#endif return pendingAddressBarFocusRequestId } let requestId = UUID() pendingAddressBarFocusRequestId = requestId +#if DEBUG + dlog( + "browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) result=new" + ) +#endif return requestId } func acknowledgeAddressBarFocusRequest(_ requestId: UUID) { - guard pendingAddressBarFocusRequestId == requestId else { return } + guard pendingAddressBarFocusRequestId == requestId else { +#if DEBUG + dlog( + "browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) result=ignored " + + "pending=\(pendingAddressBarFocusRequestId?.uuidString.prefix(8) ?? "nil")" + ) +#endif + return + } pendingAddressBarFocusRequestId = nil +#if DEBUG + dlog( + "browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) result=cleared" + ) +#endif + } + + private func captureAddressBarPageFocusIfNeeded() { + webView.evaluateJavaScript(Self.addressBarFocusCaptureScript) { [weak self] result, error in +#if DEBUG + guard let self else { return } + if let error { + dlog( + "browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " + + "result=error message=\(error.localizedDescription)" + ) + return + } + let resultValue = (result as? String) ?? "unknown" + dlog( + "browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " + + "result=\(resultValue)" + ) +#else + _ = self + _ = result + _ = error +#endif + } + } + + private enum AddressBarPageFocusRestoreStatus: String { + case restored + case noState = "no_state" + case missingTarget = "missing_target" + case notFocused = "not_focused" + case error + } + + private static func addressBarPageFocusRestoreStatus( + from result: Any?, + error: Error? + ) -> AddressBarPageFocusRestoreStatus { + if error != nil { return .error } + guard let raw = result as? String else { return .error } + return AddressBarPageFocusRestoreStatus(rawValue: raw) ?? .error + } + + func invalidateAddressBarPageFocusRestoreAttempts() { + addressBarFocusRestoreGeneration &+= 1 +#if DEBUG + dlog( + "browser.focus.addressBar.restore.invalidate panel=\(id.uuidString.prefix(5)) " + + "generation=\(addressBarFocusRestoreGeneration)" + ) +#endif + } + + func restoreAddressBarPageFocusIfNeeded(completion: @escaping (Bool) -> Void) { + addressBarFocusRestoreGeneration &+= 1 + let generation = addressBarFocusRestoreGeneration + let delays: [TimeInterval] = [0.0, 0.03, 0.09, 0.2] + restoreAddressBarPageFocusAttemptIfNeeded( + attempt: 0, + delays: delays, + generation: generation, + completion: completion + ) + } + + private func restoreAddressBarPageFocusAttemptIfNeeded( + attempt: Int, + delays: [TimeInterval], + generation: UInt64, + completion: @escaping (Bool) -> Void + ) { + guard generation == addressBarFocusRestoreGeneration else { + completion(false) + return + } + webView.evaluateJavaScript(Self.addressBarFocusRestoreScript) { [weak self] result, error in + guard let self else { + completion(false) + return + } + guard generation == self.addressBarFocusRestoreGeneration else { + completion(false) + return + } + + let status = Self.addressBarPageFocusRestoreStatus(from: result, error: error) + let canRetry = (status == .notFocused || status == .error) + let hasNextAttempt = attempt + 1 < delays.count + +#if DEBUG + if let error { + dlog( + "browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " + + "attempt=\(attempt) status=\(status.rawValue) " + + "message=\(error.localizedDescription)" + ) + } else { + dlog( + "browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " + + "attempt=\(attempt) status=\(status.rawValue)" + ) + } +#endif + + if status == .restored { + completion(true) + return + } + + if canRetry && hasNextAttempt { + let delay = delays[attempt + 1] + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { + completion(false) + return + } + guard generation == self.addressBarFocusRestoreGeneration else { + completion(false) + return + } + self.restoreAddressBarPageFocusAttemptIfNeeded( + attempt: attempt + 1, + delays: delays, + generation: generation, + completion: completion + ) + } + return + } + + completion(false) + } } /// Returns the most reliable URL string for omnibar-related matching and UI decisions. diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index cbb7aaff..c2a00b58 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -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 } } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 723dedb9..ed00bbd9 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -93,7 +93,7 @@ final class CmuxWebView: WKWebView { /// Temporarily permits focus acquisition for explicit pointer-driven interactions /// (mouse click into this webview) while keeping background autofocus blocked. - func withPointerFocusAllowance(_ body: () -> Void) { + func withPointerFocusAllowance(_ body: () -> T) -> T { pointerFocusAllowanceDepth += 1 #if DEBUG dlog( @@ -110,7 +110,7 @@ final class CmuxWebView: WKWebView { ) #endif } - body() + return body() } override func performKeyEquivalent(with event: NSEvent) -> Bool { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index df3ecd42..8526ceba 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -11553,10 +11553,10 @@ final class TerminalControllerSocketTextChunkTests: XCTestCase { } final class BrowserOmnibarFocusPolicyTests: XCTestCase { - func testReacquiresFocusWhenWebViewSuppressionIsActiveAndNextResponderIsNotAnotherTextField() { + func testReacquiresFocusWhenOmnibarStillWantsFocusAndNextResponderIsNotAnotherTextField() { XCTAssertTrue( browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: true, + desiredOmnibarFocus: true, nextResponderIsOtherTextField: false ) ) @@ -11565,16 +11565,16 @@ final class BrowserOmnibarFocusPolicyTests: XCTestCase { func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() { XCTAssertFalse( browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: true, + desiredOmnibarFocus: true, nextResponderIsOtherTextField: true ) ) } - func testDoesNotReacquireFocusWhenWebViewSuppressionIsInactive() { + func testDoesNotReacquireFocusWhenOmnibarNoLongerWantsFocus() { XCTAssertFalse( browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: false, + desiredOmnibarFocus: false, nextResponderIsOtherTextField: false ) ) diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index f1c6b630..5f76cb57 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -171,6 +171,154 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testEscapeRestoresFocusedPageInputAfterCmdL() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1" + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData( + keys: [ + "browserPanelId", + "webViewFocused", + "webInputFocusSeeded", + "webInputFocusElementId", + "webInputFocusSecondaryElementId", + "webInputFocusSecondaryClickOffsetX", + "webInputFocusSecondaryClickOffsetY" + ], + timeout: 12.0 + ), + "Expected setup data including focused page input to be written" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") + XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page input to be focused before Cmd+L") + + guard let expectedInputId = setup["webInputFocusElementId"], !expectedInputId.isEmpty else { + XCTFail("Missing webInputFocusElementId in setup data") + return + } + guard let expectedSecondaryInputId = setup["webInputFocusSecondaryElementId"], !expectedSecondaryInputId.isEmpty else { + XCTFail("Missing webInputFocusSecondaryElementId in setup data") + return + } + guard let secondaryClickOffsetXRaw = setup["webInputFocusSecondaryClickOffsetX"], + let secondaryClickOffsetYRaw = setup["webInputFocusSecondaryClickOffsetY"], + let secondaryClickOffsetX = Double(secondaryClickOffsetXRaw), + let secondaryClickOffsetY = Double(secondaryClickOffsetYRaw) else { + XCTFail( + "Missing or invalid secondary input click offsets in setup data. " + + "webInputFocusSecondaryClickOffsetX=\(setup["webInputFocusSecondaryClickOffsetX"] ?? "nil") " + + "webInputFocusSecondaryClickOffsetY=\(setup["webInputFocusSecondaryClickOffsetY"] ?? "nil")" + ) + return + } + + app.typeKey("l", modifierFlags: [.command]) + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["webViewFocusedAfterAddressBarFocus"] == "false" + }, + "Expected Cmd+L to focus omnibar" + ) + + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + if !waitForDataMatch(timeout: 2.0, predicate: { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedInputId && + data["addressBarExitActiveElementEditable"] == "true" + }) { + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + } + + let restoredExpectedInput = waitForDataMatch(timeout: 6.0) { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedInputId && + data["addressBarExitActiveElementEditable"] == "true" + } + if !restoredExpectedInput { + let snapshot = loadData() ?? [:] + XCTFail( + "Expected Escape to restore focus to the previously focused page input. " + + "expectedInputId=\(expectedInputId) " + + "webViewFocusedAfterAddressBarExit=\(snapshot["webViewFocusedAfterAddressBarExit"] ?? "nil") " + + "addressBarExitActiveElementId=\(snapshot["addressBarExitActiveElementId"] ?? "nil") " + + "addressBarExitActiveElementTag=\(snapshot["addressBarExitActiveElementTag"] ?? "nil") " + + "addressBarExitActiveElementType=\(snapshot["addressBarExitActiveElementType"] ?? "nil") " + + "addressBarExitActiveElementEditable=\(snapshot["addressBarExitActiveElementEditable"] ?? "nil") " + + "addressBarExitTrackedFocusStateId=\(snapshot["addressBarExitTrackedFocusStateId"] ?? "nil") " + + "addressBarExitFocusTrackerInstalled=\(snapshot["addressBarExitFocusTrackerInstalled"] ?? "nil") " + + "addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " + + "addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " + + "addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil") " + + "webInputFocusElementId=\(snapshot["webInputFocusElementId"] ?? "nil") " + + "webInputFocusTrackerInstalled=\(snapshot["webInputFocusTrackerInstalled"] ?? "nil") " + + "webInputFocusTrackedStateId=\(snapshot["webInputFocusTrackedStateId"] ?? "nil")" + ) + } + + let window = app.windows.firstMatch + XCTAssertTrue( + window.waitForExistence(timeout: 6.0), + "Expected app window for post-escape click regression check" + ) + + RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + window + .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)) + .withOffset(CGVector(dx: secondaryClickOffsetX, dy: secondaryClickOffsetY)) + .click() + RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + + app.typeKey("l", modifierFlags: [.command]) + let clickMovedFocusToSecondary = waitForDataMatch(timeout: 6.0) { data in + data["webViewFocusedAfterAddressBarFocus"] == "false" && + data["addressBarFocusActiveElementId"] == expectedSecondaryInputId && + data["addressBarFocusActiveElementEditable"] == "true" + } + if !clickMovedFocusToSecondary { + let snapshot = loadData() ?? [:] + XCTFail( + "Expected post-escape click to focus secondary page input before Cmd+L. " + + "secondaryInputId=\(expectedSecondaryInputId) " + + "addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " + + "addressBarFocusActiveElementTag=\(snapshot["addressBarFocusActiveElementTag"] ?? "nil") " + + "addressBarFocusActiveElementType=\(snapshot["addressBarFocusActiveElementType"] ?? "nil") " + + "addressBarFocusActiveElementEditable=\(snapshot["addressBarFocusActiveElementEditable"] ?? "nil") " + + "addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " + + "addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil")" + ) + } + + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + if !waitForDataMatch(timeout: 2.0, predicate: { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedSecondaryInputId && + data["addressBarExitActiveElementEditable"] == "true" + }) { + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + } + + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedSecondaryInputId && + data["addressBarExitActiveElementEditable"] == "true" + }, + "Expected Escape to restore focus to the clicked secondary page input" + ) + } + func testCmdLOpensBrowserWhenTerminalFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath