Add DEBUG logging for browser omnibar/content focus handoff (#910)
* Add browser focus debug logging around omnibar/content handoff * Avoid release-only unused-value warnings in focus debug logs * Add omnibar focus writer trace logs * Fix omnibar tap focus race * Stabilize omnibar focus state transitions * Propagate event context into responder guard * Fix webview pointer hit testing in focus guard * Stop omnibar reacquire on pointer blur intent * Blur omnibar on webview click intent * Preserve pointer intent through webview focus handoff * Restore page input focus after omnibar escape * Fix omnibar escape focus handoff and restore retry * Track editable focus for omnibar restore and improve test diagnostics * Add omnibar focus tracker telemetry to failing UI test * Wait for page readiness before seeding focused input in UI test setup * Strengthen omnibar escape focus regression with post-click assertion * Use deterministic window offsets for post-escape web input click test * Always enforce webview responder on omnibar escape * Harden omnibar focus restore and address PR review feedback --------- Co-authored-by: tiffanysun1 <tiffanysun8@gmail.com>
This commit is contained in:
parent
58bcc929b2
commit
e680f1de55
6 changed files with 1659 additions and 71 deletions
|
|
@ -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 "<invalid>"
|
||||
}
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
return components.string ?? "<redacted>"
|
||||
}
|
||||
#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?) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,8 +381,16 @@ struct BrowserPanelView: View {
|
|||
"addressFocused=\(addressBarFocused ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
if addressBarFocused {
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "addressBarFocus.webViewClickBlur")
|
||||
#endif
|
||||
setAddressBarFocused(false, reason: "webView.clickIntent")
|
||||
}
|
||||
if !isFocused {
|
||||
onRequestPanelFocus()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
UserDefaults.standard.register(defaults: [
|
||||
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
|
||||
|
|
@ -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 }
|
||||
panel.clearWebViewFocusSuppression()
|
||||
window.makeFirstResponder(panel.webView)
|
||||
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()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>(_ 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue