Merge remote-tracking branch 'origin/main' into fix-popover-arrow
This commit is contained in:
commit
7fc71fc7cc
66 changed files with 4235 additions and 440 deletions
|
|
@ -3713,7 +3713,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
var refreshedCount = 0
|
||||
forEachTerminalPanel { terminalPanel in
|
||||
terminalPanel.hostedView.reconcileGeometryNow()
|
||||
terminalPanel.surface.forceRefresh()
|
||||
terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload")
|
||||
refreshedCount += 1
|
||||
}
|
||||
#if DEBUG
|
||||
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ final class WindowBrowserHostView: NSView {
|
|||
private var activeDividerCursorKind: DividerCursorKind?
|
||||
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
|
||||
|
||||
deinit {
|
||||
if let trackingArea {
|
||||
removeTrackingArea(trackingArea)
|
||||
}
|
||||
clearActiveDividerCursor(restoreArrow: false)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool {
|
||||
switch event?.type {
|
||||
|
|
@ -1765,6 +1772,20 @@ final class WindowBrowserPortal: NSObject {
|
|||
)
|
||||
}
|
||||
|
||||
private static func searchOverlayConfigurationsEquivalent(
|
||||
_ lhs: BrowserPortalSearchOverlayConfiguration?,
|
||||
_ rhs: BrowserPortalSearchOverlayConfiguration?
|
||||
) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (nil, nil):
|
||||
return true
|
||||
case let (lhs?, rhs?):
|
||||
return lhs.panelId == rhs.panelId && lhs.searchState === rhs.searchState
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping.
|
||||
/// SwiftUI/AppKit hosting layers can briefly report an anchor bounds rect larger than the
|
||||
/// visible split pane during rearrangement; intersecting through ancestor bounds keeps the
|
||||
|
|
@ -1953,6 +1974,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
/// do not keep an old anchor visible.
|
||||
func updateEntryVisibility(forWebViewId webViewId: ObjectIdentifier, visibleInUI: Bool, zPriority: Int) {
|
||||
guard var entry = entriesByWebViewId[webViewId] else { return }
|
||||
guard entry.visibleInUI != visibleInUI || entry.zPriority != zPriority else { return }
|
||||
entry.visibleInUI = visibleInUI
|
||||
entry.zPriority = zPriority
|
||||
entriesByWebViewId[webViewId] = entry
|
||||
|
|
@ -1968,6 +1990,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
|
||||
func updateDropZoneOverlay(forWebViewId webViewId: ObjectIdentifier, zone: DropZone?) {
|
||||
guard var entry = entriesByWebViewId[webViewId] else { return }
|
||||
guard entry.dropZone != zone else { return }
|
||||
entry.dropZone = zone
|
||||
entriesByWebViewId[webViewId] = entry
|
||||
entry.containerView?.setDropZoneOverlay(zone: zone)
|
||||
|
|
@ -1975,6 +1998,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
|
||||
func updatePaneDropContext(forWebViewId webViewId: ObjectIdentifier, context: BrowserPaneDropContext?) {
|
||||
guard var entry = entriesByWebViewId[webViewId] else { return }
|
||||
guard entry.paneDropContext != context else { return }
|
||||
entry.paneDropContext = context
|
||||
entriesByWebViewId[webViewId] = entry
|
||||
entry.containerView?.setPaneDropContext(context)
|
||||
|
|
@ -1985,6 +2009,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
configuration: BrowserPortalSearchOverlayConfiguration?
|
||||
) {
|
||||
guard var entry = entriesByWebViewId[webViewId] else { return }
|
||||
guard !Self.searchOverlayConfigurationsEquivalent(entry.searchOverlay, configuration) else { return }
|
||||
entry.searchOverlay = configuration
|
||||
entriesByWebViewId[webViewId] = entry
|
||||
entry.containerView?.setSearchOverlay(configuration)
|
||||
|
|
@ -2263,6 +2288,28 @@ final class WindowBrowserPortal: NSObject {
|
|||
return
|
||||
}
|
||||
guard anchorView.window === window else {
|
||||
let isOffWindowReparent =
|
||||
entry.visibleInUI &&
|
||||
anchorView.window == nil &&
|
||||
anchorView.superview != nil
|
||||
if isOffWindowReparent {
|
||||
let didScheduleTransientRecovery = scheduleTransientRecoveryRetryIfNeeded(
|
||||
forWebViewId: webViewId,
|
||||
entry: &entry,
|
||||
webView: webView,
|
||||
reason: "anchorWindowMismatch"
|
||||
)
|
||||
#if DEBUG
|
||||
if didScheduleTransientRecovery && !containerView.isHidden {
|
||||
dlog(
|
||||
"browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
|
||||
"reason=anchorWindowMismatch.offWindow frame=\(browserPortalDebugFrame(containerView.frame))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
containerView.setDropZoneOverlay(zone: nil)
|
||||
return
|
||||
}
|
||||
if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") {
|
||||
containerView.setPaneTopChromeHeight(0)
|
||||
containerView.setSearchOverlay(nil)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -73,7 +73,7 @@ struct BrowserSearchOverlay: View {
|
|||
Image(systemName: "chevron.up")
|
||||
}
|
||||
.buttonStyle(SearchButtonStyle())
|
||||
.help("Next match (Return)")
|
||||
.safeHelp("Next match (Return)")
|
||||
|
||||
Button(action: {
|
||||
#if DEBUG
|
||||
|
|
@ -84,7 +84,7 @@ struct BrowserSearchOverlay: View {
|
|||
Image(systemName: "chevron.down")
|
||||
}
|
||||
.buttonStyle(SearchButtonStyle())
|
||||
.help("Previous match (Shift+Return)")
|
||||
.safeHelp("Previous match (Shift+Return)")
|
||||
|
||||
Button(action: {
|
||||
#if DEBUG
|
||||
|
|
@ -95,7 +95,7 @@ struct BrowserSearchOverlay: View {
|
|||
Image(systemName: "xmark")
|
||||
}
|
||||
.buttonStyle(SearchButtonStyle())
|
||||
.help("Close (Esc)")
|
||||
.safeHelp("Close (Esc)")
|
||||
}
|
||||
.padding(8)
|
||||
.background(.background)
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ struct SurfaceSearchOverlay: View {
|
|||
Image(systemName: "chevron.up")
|
||||
}
|
||||
.buttonStyle(SearchButtonStyle())
|
||||
.help(String(localized: "search.nextMatch.help", defaultValue: "Next match (Return)"))
|
||||
.safeHelp(String(localized: "search.nextMatch.help", defaultValue: "Next match (Return)"))
|
||||
|
||||
Button(action: {
|
||||
#if DEBUG
|
||||
|
|
@ -99,7 +99,7 @@ struct SurfaceSearchOverlay: View {
|
|||
Image(systemName: "chevron.down")
|
||||
}
|
||||
.buttonStyle(SearchButtonStyle())
|
||||
.help(String(localized: "search.previousMatch.help", defaultValue: "Previous match (Shift+Return)"))
|
||||
.safeHelp(String(localized: "search.previousMatch.help", defaultValue: "Previous match (Shift+Return)"))
|
||||
|
||||
Button(action: {
|
||||
#if DEBUG
|
||||
|
|
@ -110,7 +110,7 @@ struct SurfaceSearchOverlay: View {
|
|||
Image(systemName: "xmark")
|
||||
}
|
||||
.buttonStyle(SearchButtonStyle())
|
||||
.help(String(localized: "search.close.help", defaultValue: "Close (Esc)"))
|
||||
.safeHelp(String(localized: "search.close.help", defaultValue: "Close (Esc)"))
|
||||
}
|
||||
.padding(8)
|
||||
.background(.background)
|
||||
|
|
|
|||
|
|
@ -2121,6 +2121,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
surfaceView.tabId = newTabId
|
||||
}
|
||||
|
||||
func isAttached(to view: GhosttyNSView) -> Bool {
|
||||
attachedView === view && surface != nil
|
||||
}
|
||||
|
||||
func portalBindingGeneration() -> UInt64 {
|
||||
portalLifecycleGeneration
|
||||
}
|
||||
|
|
@ -2262,6 +2266,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
// removed/re-added (or briefly have window/screen nil) without recreating the surface.
|
||||
// Ghostty's vsync-driven renderer depends on having a valid display id; if it is missing
|
||||
// or stale, the surface can appear visually frozen until a focus/visibility change.
|
||||
// SwiftUI also re-enters this path for ordinary state propagation (drag hover, active
|
||||
// markers, visibility flags), so avoid forcing a geometry refresh when the attachment
|
||||
// itself is unchanged.
|
||||
if attachedView === view && surface != nil {
|
||||
#if DEBUG
|
||||
dlog("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque())")
|
||||
|
|
@ -2272,7 +2279,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
let s = surface {
|
||||
ghostty_surface_set_display_id(s, displayID)
|
||||
}
|
||||
view.forceRefreshSurface()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2570,6 +2576,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
#endif
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateSize(
|
||||
width: CGFloat,
|
||||
height: CGFloat,
|
||||
|
|
@ -2577,15 +2584,15 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
yScale: CGFloat,
|
||||
layerScale: CGFloat,
|
||||
backingSize: CGSize? = nil
|
||||
) {
|
||||
guard let surface = surface else { return }
|
||||
) -> Bool {
|
||||
guard let surface = surface else { return false }
|
||||
_ = layerScale
|
||||
|
||||
let resolvedBackingWidth = backingSize?.width ?? (width * xScale)
|
||||
let resolvedBackingHeight = backingSize?.height ?? (height * yScale)
|
||||
let wpx = pixelDimension(from: resolvedBackingWidth)
|
||||
let hpx = pixelDimension(from: resolvedBackingHeight)
|
||||
guard wpx > 0, hpx > 0 else { return }
|
||||
guard wpx > 0, hpx > 0 else { return false }
|
||||
|
||||
let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale)
|
||||
let sizeChanged = wpx != lastPixelWidth || hpx != lastPixelHeight
|
||||
|
|
@ -2594,7 +2601,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
Self.sizeLog("updateSize-call surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) changed=\((scaleChanged || sizeChanged) ? 1 : 0)")
|
||||
#endif
|
||||
|
||||
guard scaleChanged || sizeChanged else { return }
|
||||
guard scaleChanged || sizeChanged else { return false }
|
||||
|
||||
#if DEBUG
|
||||
if sizeChanged {
|
||||
|
|
@ -2616,10 +2623,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
// Let Ghostty continue rendering on its own wakeups for steady-state frames.
|
||||
return true
|
||||
}
|
||||
|
||||
/// Force a full size recalculation and surface redraw.
|
||||
func forceRefresh() {
|
||||
func forceRefresh(reason: String = "unspecified") {
|
||||
let hasSurface = surface != nil
|
||||
let viewState: String
|
||||
if let view = attachedView {
|
||||
|
|
@ -2632,7 +2640,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
#if DEBUG
|
||||
let ts = ISO8601DateFormatter().string(from: Date())
|
||||
let line = "[\(ts)] forceRefresh: \(id) \(viewState)\n"
|
||||
let line = "[\(ts)] forceRefresh: \(id) reason=\(reason) \(viewState)\n"
|
||||
let logPath = "/tmp/cmux-refresh-debug.log"
|
||||
if let handle = FileHandle(forWritingAtPath: logPath) {
|
||||
handle.seekToEndOfFile()
|
||||
|
|
@ -2941,6 +2949,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
private var lastScrollEventTime: CFTimeInterval = 0
|
||||
private var visibleInUI: Bool = true
|
||||
private var pendingSurfaceSize: CGSize?
|
||||
private var lastDrawableSize: CGSize = .zero
|
||||
private var isFindEscapeSuppressionArmed = false
|
||||
#if DEBUG
|
||||
private var lastSizeSkipSignature: String?
|
||||
|
|
@ -3114,14 +3123,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
|
||||
func attachSurface(_ surface: TerminalSurface) {
|
||||
appliedColorScheme = nil
|
||||
let isSameSurface = terminalSurface === surface
|
||||
let isAlreadyAttached = surface.isAttached(to: self)
|
||||
if !isSameSurface {
|
||||
appliedColorScheme = nil
|
||||
}
|
||||
terminalSurface = surface
|
||||
tabId = surface.tabId
|
||||
surface.attachToView(self)
|
||||
if !isAlreadyAttached {
|
||||
surface.attachToView(self)
|
||||
}
|
||||
surface.setKeyboardCopyModeActive(keyboardCopyModeActive)
|
||||
updateSurfaceSize()
|
||||
if !isAlreadyAttached {
|
||||
updateSurfaceSize()
|
||||
}
|
||||
applySurfaceBackground()
|
||||
applySurfaceColorScheme(force: true)
|
||||
applySurfaceColorScheme(force: !isSameSurface || !isAlreadyAttached)
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
|
|
@ -3229,8 +3246,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
return currentBounds
|
||||
}
|
||||
|
||||
private func updateSurfaceSize(size: CGSize? = nil) {
|
||||
guard let terminalSurface = terminalSurface else { return }
|
||||
@discardableResult
|
||||
private func updateSurfaceSize(size: CGSize? = nil) -> Bool {
|
||||
guard let terminalSurface = terminalSurface else { return false }
|
||||
let size = resolvedSurfaceSize(preferred: size)
|
||||
guard size.width > 0 && size.height > 0 else {
|
||||
#if DEBUG
|
||||
|
|
@ -3244,7 +3262,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
lastSizeSkipSignature = signature
|
||||
}
|
||||
#endif
|
||||
return
|
||||
return false
|
||||
}
|
||||
pendingSurfaceSize = size
|
||||
guard let window else {
|
||||
|
|
@ -3258,7 +3276,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
lastSizeSkipSignature = signature
|
||||
}
|
||||
#endif
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// First principles: derive pixel size from AppKit's backing conversion for the current
|
||||
|
|
@ -3276,7 +3294,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
lastSizeSkipSignature = signature
|
||||
}
|
||||
#endif
|
||||
return
|
||||
return false
|
||||
}
|
||||
#if DEBUG
|
||||
if lastSizeSkipSignature != nil {
|
||||
|
|
@ -3295,17 +3313,29 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
width: floor(max(0, backingSize.width)),
|
||||
height: floor(max(0, backingSize.height))
|
||||
)
|
||||
var didChange = false
|
||||
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
if let layer, !nearlyEqual(layer.contentsScale, layerScale) {
|
||||
didChange = true
|
||||
}
|
||||
layer?.contentsScale = layerScale
|
||||
layer?.masksToBounds = true
|
||||
if let metalLayer = layer as? CAMetalLayer {
|
||||
metalLayer.drawableSize = drawablePixelSize
|
||||
if drawablePixelSize != lastDrawableSize || metalLayer.drawableSize != drawablePixelSize {
|
||||
if metalLayer.drawableSize != drawablePixelSize {
|
||||
didChange = true
|
||||
}
|
||||
if metalLayer.drawableSize != drawablePixelSize {
|
||||
metalLayer.drawableSize = drawablePixelSize
|
||||
}
|
||||
lastDrawableSize = drawablePixelSize
|
||||
}
|
||||
}
|
||||
CATransaction.commit()
|
||||
|
||||
terminalSurface.updateSize(
|
||||
let surfaceSizeChanged = terminalSurface.updateSize(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
xScale: xScale,
|
||||
|
|
@ -3313,15 +3343,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
layerScale: layerScale,
|
||||
backingSize: backingSize
|
||||
)
|
||||
return didChange || surfaceSizeChanged
|
||||
}
|
||||
|
||||
fileprivate func pushTargetSurfaceSize(_ size: CGSize) {
|
||||
@discardableResult
|
||||
fileprivate func pushTargetSurfaceSize(_ size: CGSize) -> Bool {
|
||||
updateSurfaceSize(size: size)
|
||||
}
|
||||
|
||||
/// Force a full size recalculation and Metal layer refresh.
|
||||
/// Resets cached metrics so updateSurfaceSize() re-runs unconditionally.
|
||||
func forceRefreshSurface() {
|
||||
/// Force a full size reconciliation for the current bounds.
|
||||
/// Keep the drawable-size cache intact so redundant refresh paths do not
|
||||
/// reallocate Metal drawables when the pixel size is unchanged.
|
||||
@discardableResult
|
||||
func forceRefreshSurface() -> Bool {
|
||||
updateSurfaceSize()
|
||||
}
|
||||
|
||||
|
|
@ -4654,6 +4688,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
if let windowObserver {
|
||||
NotificationCenter.default.removeObserver(windowObserver)
|
||||
}
|
||||
if let trackingArea {
|
||||
removeTrackingArea(trackingArea)
|
||||
}
|
||||
terminalSurface = nil
|
||||
}
|
||||
|
||||
|
|
@ -4882,6 +4919,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView
|
||||
private let keyboardCopyModeBadgeLabel: NSTextField
|
||||
private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>?
|
||||
private var lastSearchOverlayStateID: ObjectIdentifier?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private var windowObservers: [NSObjectProtocol] = []
|
||||
private var isLiveScrolling = false
|
||||
|
|
@ -4908,6 +4946,9 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
|
||||
#if DEBUG
|
||||
private var lastDropZoneOverlayLogSignature: String?
|
||||
private var dragLayoutLogSequence: UInt64 = 0
|
||||
private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
|
||||
private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder")
|
||||
private static var flashCounts: [UUID: Int] = [:]
|
||||
private static var drawCounts: [UUID: Int] = [:]
|
||||
private static var lastDrawTimes: [UUID: CFTimeInterval] = [:]
|
||||
|
|
@ -5238,36 +5279,50 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
/// Reconcile AppKit geometry with ghostty surface geometry synchronously.
|
||||
/// Used after split topology mutations (close/split) to prevent a stale one-frame
|
||||
/// IOSurface size from being presented after pane expansion.
|
||||
func reconcileGeometryNow() {
|
||||
@discardableResult
|
||||
func reconcileGeometryNow() -> Bool {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.reconcileGeometryNow()
|
||||
}
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
synchronizeGeometryAndContent()
|
||||
return synchronizeGeometryAndContent()
|
||||
}
|
||||
|
||||
/// Request an immediate terminal redraw after geometry updates so stale IOSurface
|
||||
/// contents do not remain stretched during live resize churn.
|
||||
func refreshSurfaceNow() {
|
||||
surfaceView.terminalSurface?.forceRefresh()
|
||||
func refreshSurfaceNow(reason: String = "portal.refreshSurfaceNow") {
|
||||
surfaceView.terminalSurface?.forceRefresh(reason: reason)
|
||||
}
|
||||
|
||||
private func synchronizeGeometryAndContent() {
|
||||
@discardableResult
|
||||
private func synchronizeGeometryAndContent() -> Bool {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
defer { CATransaction.commit() }
|
||||
|
||||
backgroundView.frame = bounds
|
||||
scrollView.frame = bounds
|
||||
let previousSurfaceSize = surfaceView.frame.size
|
||||
_ = setFrameIfNeeded(backgroundView, to: bounds)
|
||||
_ = setFrameIfNeeded(scrollView, to: bounds)
|
||||
let targetSize = scrollView.bounds.size
|
||||
surfaceView.frame.size = targetSize
|
||||
documentView.frame.size.width = scrollView.bounds.width
|
||||
inactiveOverlayView.frame = bounds
|
||||
#if DEBUG
|
||||
logLayoutDuringActiveDrag(targetSize: targetSize)
|
||||
#endif
|
||||
let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize)
|
||||
_ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame)
|
||||
let targetDocumentFrame = CGRect(
|
||||
origin: documentView.frame.origin,
|
||||
size: CGSize(width: scrollView.bounds.width, height: documentView.frame.height)
|
||||
)
|
||||
_ = setFrameIfNeeded(documentView, to: targetDocumentFrame)
|
||||
_ = setFrameIfNeeded(inactiveOverlayView, to: bounds)
|
||||
if let zone = activeDropZone {
|
||||
dropZoneOverlayView.frame = dropZoneOverlayFrame(for: zone, in: bounds.size)
|
||||
_ = setFrameIfNeeded(
|
||||
dropZoneOverlayView,
|
||||
to: dropZoneOverlayFrame(for: zone, in: bounds.size)
|
||||
)
|
||||
}
|
||||
if let pending = pendingDropZone,
|
||||
bounds.width > 2,
|
||||
|
|
@ -5281,15 +5336,68 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
// same initial animation as direct drop-zone activation.
|
||||
setDropZoneOverlay(zone: pending)
|
||||
}
|
||||
notificationRingOverlayView.frame = bounds
|
||||
flashOverlayView.frame = bounds
|
||||
_ = setFrameIfNeeded(notificationRingOverlayView, to: bounds)
|
||||
_ = setFrameIfNeeded(flashOverlayView, to: bounds)
|
||||
updateNotificationRingPath()
|
||||
updateFlashPath()
|
||||
synchronizeScrollView()
|
||||
synchronizeSurfaceView()
|
||||
synchronizeCoreSurface()
|
||||
let didCoreSurfaceChange = synchronizeCoreSurface()
|
||||
return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func setFrameIfNeeded(_ view: NSView, to frame: CGRect) -> Bool {
|
||||
guard !Self.rectApproximatelyEqual(view.frame, frame) else { return false }
|
||||
view.frame = frame
|
||||
return true
|
||||
}
|
||||
|
||||
private func sizeApproximatelyEqual(_ lhs: CGSize, _ rhs: CGSize, epsilon: CGFloat = 0.0001) -> Bool {
|
||||
abs(lhs.width - rhs.width) <= epsilon && abs(lhs.height - rhs.height) <= epsilon
|
||||
}
|
||||
|
||||
private func pointApproximatelyEqual(_ lhs: CGPoint, _ rhs: CGPoint, epsilon: CGFloat = 0.5) -> Bool {
|
||||
abs(lhs.x - rhs.x) <= epsilon && abs(lhs.y - rhs.y) <= epsilon
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool {
|
||||
switch eventType {
|
||||
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func logLayoutDuringActiveDrag(targetSize: CGSize) {
|
||||
let pasteboardTypes = NSPasteboard(name: .drag).types
|
||||
let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true
|
||||
let hasSidebarDrag = pasteboardTypes?.contains(Self.sidebarTabReorderPasteboardType) == true
|
||||
let eventType = NSApp.currentEvent?.type
|
||||
let hasActiveDrag =
|
||||
activeDropZone != nil ||
|
||||
pendingDropZone != nil ||
|
||||
((hasTabDrag || hasSidebarDrag) && Self.isDragMouseEvent(eventType))
|
||||
guard hasActiveDrag else { return }
|
||||
|
||||
dragLayoutLogSequence &+= 1
|
||||
let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
||||
let activeZone = activeDropZone.map { String(describing: $0) } ?? "none"
|
||||
let pendingZone = pendingDropZone.map { String(describing: $0) } ?? "none"
|
||||
let event = eventType.map { String(describing: $0) } ?? "nil"
|
||||
dlog(
|
||||
"terminal.layout.drag surface=\(surface) seq=\(dragLayoutLogSequence) " +
|
||||
"activeZone=\(activeZone) pendingZone=\(pendingZone) " +
|
||||
"hasTabDrag=\(hasTabDrag ? 1 : 0) hasSidebarDrag=\(hasSidebarDrag ? 1 : 0) " +
|
||||
"event=\(event) inWindow=\(window != nil ? 1 : 0) " +
|
||||
"bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
||||
"target=\(String(format: "%.1fx%.1f", targetSize.width, targetSize.height))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
|
|
@ -5385,10 +5493,15 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
return
|
||||
}
|
||||
|
||||
let targetHidden = !visible
|
||||
let targetOpacity: Float = visible ? 1 : 0
|
||||
guard notificationRingOverlayView.isHidden != targetHidden ||
|
||||
notificationRingLayer.opacity != targetOpacity else { return }
|
||||
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
notificationRingOverlayView.isHidden = !visible
|
||||
notificationRingLayer.opacity = visible ? 1 : 0
|
||||
notificationRingOverlayView.isHidden = targetHidden
|
||||
notificationRingLayer.opacity = targetOpacity
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
|
|
@ -5405,6 +5518,8 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
guard let terminalSurface = surfaceView.terminalSurface,
|
||||
let searchState else {
|
||||
let hadOverlay = searchOverlayHostingView != nil
|
||||
lastSearchOverlayStateID = nil
|
||||
guard hadOverlay else { return }
|
||||
#if DEBUG
|
||||
dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)")
|
||||
#endif
|
||||
|
|
@ -5414,6 +5529,16 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
return
|
||||
}
|
||||
|
||||
let searchStateID = ObjectIdentifier(searchState)
|
||||
if let overlay = searchOverlayHostingView,
|
||||
lastSearchOverlayStateID == searchStateID,
|
||||
overlay.superview === self {
|
||||
if !keyboardCopyModeBadgeView.isHidden {
|
||||
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let hadOverlay = searchOverlayHostingView != nil
|
||||
#if DEBUG
|
||||
dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")")
|
||||
|
|
@ -5457,6 +5582,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
if !keyboardCopyModeBadgeView.isHidden {
|
||||
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
|
||||
}
|
||||
lastSearchOverlayStateID = searchStateID
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -5474,6 +5600,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
|
||||
}
|
||||
searchOverlayHostingView = overlay
|
||||
lastSearchOverlayStateID = searchStateID
|
||||
}
|
||||
|
||||
func setKeyboardCopyModeIndicator(visible: Bool) {
|
||||
|
|
@ -6356,16 +6483,18 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
|
||||
private func synchronizeSurfaceView() {
|
||||
let visibleRect = scrollView.contentView.documentVisibleRect
|
||||
guard !pointApproximatelyEqual(surfaceView.frame.origin, visibleRect.origin) else { return }
|
||||
surfaceView.frame.origin = visibleRect.origin
|
||||
}
|
||||
|
||||
/// Match upstream Ghostty behavior: use content area width (excluding non-content
|
||||
/// regions such as scrollbar space) when telling libghostty the terminal size.
|
||||
private func synchronizeCoreSurface() {
|
||||
@discardableResult
|
||||
private func synchronizeCoreSurface() -> Bool {
|
||||
let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth())
|
||||
let height = surfaceView.frame.height
|
||||
guard width > 0, height > 0 else { return }
|
||||
surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height))
|
||||
guard width > 0, height > 0 else { return false }
|
||||
return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height))
|
||||
}
|
||||
|
||||
/// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller.
|
||||
|
|
@ -6425,19 +6554,30 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
|
||||
private func synchronizeScrollView() {
|
||||
documentView.frame.size.height = documentHeight()
|
||||
var didChangeGeometry = false
|
||||
let targetDocumentHeight = documentHeight()
|
||||
if abs(documentView.frame.height - targetDocumentHeight) > 0.5 {
|
||||
documentView.frame.size.height = targetDocumentHeight
|
||||
didChangeGeometry = true
|
||||
}
|
||||
|
||||
if !isLiveScrolling {
|
||||
let cellHeight = surfaceView.cellSize.height
|
||||
if cellHeight > 0, let scrollbar = surfaceView.scrollbar {
|
||||
let offsetY =
|
||||
CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight
|
||||
scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY))
|
||||
let targetOrigin = CGPoint(x: 0, y: offsetY)
|
||||
if !pointApproximatelyEqual(scrollView.contentView.bounds.origin, targetOrigin) {
|
||||
scrollView.contentView.scroll(to: targetOrigin)
|
||||
didChangeGeometry = true
|
||||
}
|
||||
lastSentRow = Int(scrollbar.offset)
|
||||
}
|
||||
}
|
||||
|
||||
scrollView.reflectScrolledClipView(scrollView.contentView)
|
||||
if didChangeGeometry {
|
||||
scrollView.reflectScrolledClipView(scrollView.contentView)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleScrollChange() {
|
||||
|
|
@ -6669,31 +6809,57 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
private final class HostContainerView: NSView {
|
||||
var onDidMoveToWindow: (() -> Void)?
|
||||
var onGeometryChanged: (() -> Void)?
|
||||
private(set) var geometryRevision: UInt64 = 0
|
||||
private var lastReportedGeometryState: GeometryState?
|
||||
|
||||
private struct GeometryState: Equatable {
|
||||
let frame: CGRect
|
||||
let bounds: CGRect
|
||||
let windowNumber: Int?
|
||||
let superviewID: ObjectIdentifier?
|
||||
}
|
||||
|
||||
private func currentGeometryState() -> GeometryState {
|
||||
GeometryState(
|
||||
frame: frame,
|
||||
bounds: bounds,
|
||||
windowNumber: window?.windowNumber,
|
||||
superviewID: superview.map(ObjectIdentifier.init)
|
||||
)
|
||||
}
|
||||
|
||||
private func notifyGeometryChangedIfNeeded() {
|
||||
let state = currentGeometryState()
|
||||
guard state != lastReportedGeometryState else { return }
|
||||
lastReportedGeometryState = state
|
||||
geometryRevision &+= 1
|
||||
onGeometryChanged?()
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
onDidMoveToWindow?()
|
||||
onGeometryChanged?()
|
||||
notifyGeometryChangedIfNeeded()
|
||||
}
|
||||
|
||||
override func viewDidMoveToSuperview() {
|
||||
super.viewDidMoveToSuperview()
|
||||
onGeometryChanged?()
|
||||
notifyGeometryChangedIfNeeded()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
onGeometryChanged?()
|
||||
notifyGeometryChangedIfNeeded()
|
||||
}
|
||||
|
||||
override func setFrameOrigin(_ newOrigin: NSPoint) {
|
||||
super.setFrameOrigin(newOrigin)
|
||||
onGeometryChanged?()
|
||||
notifyGeometryChangedIfNeeded()
|
||||
}
|
||||
|
||||
override func setFrameSize(_ newSize: NSSize) {
|
||||
super.setFrameSize(newSize)
|
||||
onGeometryChanged?()
|
||||
notifyGeometryChangedIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6706,6 +6872,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
var desiredPortalZPriority: Int = 0
|
||||
var lastBoundHostId: ObjectIdentifier?
|
||||
var lastPaneDropZone: DropZone?
|
||||
var lastSynchronizedHostGeometryRevision: UInt64 = 0
|
||||
weak var hostedView: GhosttySurfaceScrollView?
|
||||
}
|
||||
|
||||
|
|
@ -6825,6 +6992,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
expectedGeneration: portalExpectedGeneration
|
||||
)
|
||||
coordinator.lastBoundHostId = ObjectIdentifier(host)
|
||||
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
|
||||
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
|
||||
hostedView.setActive(coordinator.desiredIsActive)
|
||||
hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing)
|
||||
|
|
@ -6856,17 +7024,30 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing)
|
||||
}
|
||||
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
|
||||
}
|
||||
|
||||
if host.window != nil {
|
||||
let hostId = ObjectIdentifier(host)
|
||||
let geometryRevision = host.geometryRevision
|
||||
let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)
|
||||
let shouldBindNow =
|
||||
coordinator.lastBoundHostId != hostId ||
|
||||
hostedView.superview == nil ||
|
||||
portalEntryMissing ||
|
||||
previousDesiredIsVisibleInUI != isVisibleInUI ||
|
||||
previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing ||
|
||||
previousDesiredPortalZPriority != portalZPriority
|
||||
if shouldBindNow {
|
||||
#if DEBUG
|
||||
if portalEntryMissing {
|
||||
dlog(
|
||||
"ws.hostState.rebindOnUpdate surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " +
|
||||
"active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
TerminalWindowPortalRegistry.bind(
|
||||
hostedView: hostedView,
|
||||
to: host,
|
||||
|
|
@ -6876,8 +7057,11 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
expectedGeneration: portalExpectedGeneration
|
||||
)
|
||||
coordinator.lastBoundHostId = hostId
|
||||
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
|
||||
} else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision {
|
||||
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
|
||||
}
|
||||
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
} else {
|
||||
// Bind is deferred until host moves into a window. Update the
|
||||
// existing portal entry's visibleInUI now so that any portal sync
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import Bonsplit
|
||||
import SwiftUI
|
||||
|
||||
struct NotificationsPage: View {
|
||||
|
|
@ -113,7 +114,7 @@ struct NotificationsPage: View {
|
|||
}
|
||||
.buttonStyle(.bordered)
|
||||
.keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers)
|
||||
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
|
||||
.safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
|
||||
.disabled(!hasUnreadNotifications)
|
||||
} else {
|
||||
Button(action: {
|
||||
|
|
@ -125,7 +126,7 @@ struct NotificationsPage: View {
|
|||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
|
||||
.safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
|
||||
.disabled(!hasUnreadNotifications)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -86,15 +86,17 @@ struct MarkdownPanelView: View {
|
|||
Image(systemName: "doc.questionmark")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary)
|
||||
Text("File unavailable")
|
||||
Text(String(localized: "markdown.fileUnavailable.title", defaultValue: "File unavailable"))
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Text(panel.filePath)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("The file may have been moved or deleted.")
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 24)
|
||||
Text(String(localized: "markdown.fileUnavailable.message", defaultValue: "The file may have been moved or deleted."))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2880,7 +2880,7 @@ class TabManager: ObservableObject {
|
|||
continue
|
||||
}
|
||||
terminal.hostedView.reconcileGeometryNow()
|
||||
terminal.surface.forceRefresh()
|
||||
terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4161,7 +4161,7 @@ class TerminalController {
|
|||
var refreshedCount = 0
|
||||
for panel in ws.panels.values {
|
||||
if let terminalPanel = panel as? TerminalPanel {
|
||||
terminalPanel.surface.forceRefresh()
|
||||
terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceRefresh")
|
||||
refreshedCount += 1
|
||||
}
|
||||
}
|
||||
|
|
@ -4243,7 +4243,7 @@ class TerminalController {
|
|||
// Ensure we present a new frame after injecting input so snapshot-based tests (and
|
||||
// socket-driven agents) can observe the updated terminal without requiring a focus
|
||||
// change to trigger a draw.
|
||||
terminalPanel.surface.forceRefresh()
|
||||
terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendText")
|
||||
queued = false
|
||||
} else {
|
||||
// Avoid blocking the main actor waiting for view/surface attachment.
|
||||
|
|
@ -4301,7 +4301,7 @@ class TerminalController {
|
|||
result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key])
|
||||
return
|
||||
}
|
||||
terminalPanel.surface.forceRefresh()
|
||||
terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey")
|
||||
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))])
|
||||
}
|
||||
return result
|
||||
|
|
@ -4333,7 +4333,7 @@ class TerminalController {
|
|||
return
|
||||
}
|
||||
|
||||
terminalPanel.surface.forceRefresh()
|
||||
terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceClearHistory")
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
result = .ok([
|
||||
"workspace_id": ws.id.uuidString,
|
||||
|
|
@ -9704,81 +9704,91 @@ class TerminalController {
|
|||
return "OK"
|
||||
}
|
||||
|
||||
private func simulateShortcut(_ args: String) -> String {
|
||||
let combo = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !combo.isEmpty else {
|
||||
return "ERROR: Usage: simulate_shortcut <combo>"
|
||||
}
|
||||
guard let parsed = parseShortcutCombo(combo) else {
|
||||
return "ERROR: Invalid combo. Example: cmd+ctrl+h"
|
||||
}
|
||||
private func prepareWindowForSyntheticInput(_ window: NSWindow?) {
|
||||
guard let window else { return }
|
||||
|
||||
// Stamp at socket-handler arrival so event.timestamp includes any wait
|
||||
// before the main-thread event dispatch.
|
||||
let requestTimestamp = ProcessInfo.processInfo.systemUptime
|
||||
|
||||
var result = "ERROR: Failed to create event"
|
||||
DispatchQueue.main.sync {
|
||||
// Prefer the current active-tab-manager window so shortcut simulation stays
|
||||
// scoped to the intended window even when NSApp.keyWindow is stale.
|
||||
let targetWindow: NSWindow? = {
|
||||
if let activeTabManager = self.tabManager,
|
||||
let windowId = AppDelegate.shared?.windowId(for: activeTabManager),
|
||||
let window = AppDelegate.shared?.mainWindow(for: windowId) {
|
||||
return window
|
||||
}
|
||||
return NSApp.keyWindow
|
||||
?? NSApp.mainWindow
|
||||
?? NSApp.windows.first(where: { $0.isVisible })
|
||||
?? NSApp.windows.first
|
||||
}()
|
||||
if let targetWindow {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
targetWindow.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
let windowNumber = targetWindow?.windowNumber ?? 0
|
||||
guard let keyDownEvent = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: parsed.modifierFlags,
|
||||
timestamp: requestTimestamp,
|
||||
windowNumber: windowNumber,
|
||||
context: nil,
|
||||
characters: parsed.characters,
|
||||
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
|
||||
isARepeat: false,
|
||||
keyCode: parsed.keyCode
|
||||
) else {
|
||||
result = "ERROR: NSEvent.keyEvent returned nil"
|
||||
return
|
||||
}
|
||||
let keyUpEvent = NSEvent.keyEvent(
|
||||
with: .keyUp,
|
||||
location: .zero,
|
||||
modifierFlags: parsed.modifierFlags,
|
||||
timestamp: requestTimestamp + 0.0001,
|
||||
windowNumber: windowNumber,
|
||||
context: nil,
|
||||
characters: parsed.characters,
|
||||
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
|
||||
isARepeat: false,
|
||||
keyCode: parsed.keyCode
|
||||
)
|
||||
// Socket-driven shortcut simulation should reuse the exact same matching logic as the
|
||||
// app-level shortcut monitor (so tests are hermetic), while still falling back to the
|
||||
// normal responder chain for plain typing.
|
||||
if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) {
|
||||
result = "OK"
|
||||
return
|
||||
}
|
||||
NSApp.sendEvent(keyDownEvent)
|
||||
if let keyUpEvent {
|
||||
NSApp.sendEvent(keyUpEvent)
|
||||
}
|
||||
result = "OK"
|
||||
}
|
||||
return result
|
||||
}
|
||||
// Keep socket-driven input simulation focused on the intended window without
|
||||
// paying repeated activation/order-front costs for every synthetic key event.
|
||||
if !NSApp.isActive {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
if !window.isKeyWindow || !window.isVisible {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func simulateShortcut(_ args: String) -> String {
|
||||
let combo = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !combo.isEmpty else {
|
||||
return "ERROR: Usage: simulate_shortcut <combo>"
|
||||
}
|
||||
guard let parsed = parseShortcutCombo(combo) else {
|
||||
return "ERROR: Invalid combo. Example: cmd+ctrl+h"
|
||||
}
|
||||
|
||||
// Stamp at socket-handler arrival so event.timestamp includes any wait
|
||||
// before the main-thread event dispatch.
|
||||
let requestTimestamp = ProcessInfo.processInfo.systemUptime
|
||||
|
||||
var result = "ERROR: Failed to create event"
|
||||
DispatchQueue.main.sync {
|
||||
// Prefer the current active-tab-manager window so shortcut simulation stays
|
||||
// scoped to the intended window even when NSApp.keyWindow is stale.
|
||||
let targetWindow: NSWindow? = {
|
||||
if let activeTabManager = self.tabManager,
|
||||
let windowId = AppDelegate.shared?.windowId(for: activeTabManager),
|
||||
let window = AppDelegate.shared?.mainWindow(for: windowId) {
|
||||
return window
|
||||
}
|
||||
return NSApp.keyWindow
|
||||
?? NSApp.mainWindow
|
||||
?? NSApp.windows.first(where: { $0.isVisible })
|
||||
?? NSApp.windows.first
|
||||
}()
|
||||
prepareWindowForSyntheticInput(targetWindow)
|
||||
let windowNumber = targetWindow?.windowNumber ?? 0
|
||||
guard let keyDownEvent = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: parsed.modifierFlags,
|
||||
timestamp: requestTimestamp,
|
||||
windowNumber: windowNumber,
|
||||
context: nil,
|
||||
characters: parsed.characters,
|
||||
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
|
||||
isARepeat: false,
|
||||
keyCode: parsed.keyCode
|
||||
) else {
|
||||
result = "ERROR: NSEvent.keyEvent returned nil"
|
||||
return
|
||||
}
|
||||
let keyUpEvent = NSEvent.keyEvent(
|
||||
with: .keyUp,
|
||||
location: .zero,
|
||||
modifierFlags: parsed.modifierFlags,
|
||||
timestamp: requestTimestamp + 0.0001,
|
||||
windowNumber: windowNumber,
|
||||
context: nil,
|
||||
characters: parsed.characters,
|
||||
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
|
||||
isARepeat: false,
|
||||
keyCode: parsed.keyCode
|
||||
)
|
||||
// Socket-driven shortcut simulation should reuse the exact same matching logic as the
|
||||
// app-level shortcut monitor (so tests are hermetic), while still falling back to the
|
||||
// normal responder chain for plain typing.
|
||||
if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) {
|
||||
result = "OK"
|
||||
return
|
||||
}
|
||||
NSApp.sendEvent(keyDownEvent)
|
||||
if let keyUpEvent {
|
||||
NSApp.sendEvent(keyUpEvent)
|
||||
}
|
||||
result = "OK"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func activateApp() -> String {
|
||||
DispatchQueue.main.sync {
|
||||
|
|
@ -9823,8 +9833,7 @@ class TerminalController {
|
|||
?? NSApp.mainWindow
|
||||
?? NSApp.windows.first(where: { $0.isVisible })
|
||||
?? NSApp.windows.first else { return }
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
prepareWindowForSyntheticInput(window)
|
||||
guard let fr = window.firstResponder else {
|
||||
result = "ERROR: No first responder"
|
||||
return
|
||||
|
|
@ -11146,7 +11155,7 @@ class TerminalController {
|
|||
var cgImage = view.debugCopyIOSurfaceCGImage()
|
||||
if cgImage == nil {
|
||||
// If the surface is mid-attach we may not have contents yet. Nudge a draw and retry once.
|
||||
terminalPanel.surface.forceRefresh()
|
||||
terminalPanel.surface.forceRefresh(reason: "terminalController.debugCopyIOSurfaceRetry")
|
||||
cgImage = view.debugCopyIOSurfaceCGImage()
|
||||
}
|
||||
guard let cgImage else {
|
||||
|
|
@ -13712,7 +13721,7 @@ class TerminalController {
|
|||
// (resets cached metrics so the Metal layer drawable resizes correctly)
|
||||
for panel in tab.panels.values {
|
||||
if let terminalPanel = panel as? TerminalPanel {
|
||||
terminalPanel.surface.forceRefresh()
|
||||
terminalPanel.surface.forceRefresh(reason: "terminalController.refreshAllTerminalPanels")
|
||||
refreshedCount += 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,13 @@ final class WindowTerminalHostView: NSView {
|
|||
private var lastDragRouteSignature: String?
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
if let trackingArea {
|
||||
removeTrackingArea(trackingArea)
|
||||
}
|
||||
clearActiveDividerCursor(restoreArrow: false)
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
if window == nil {
|
||||
|
|
@ -698,12 +705,13 @@ final class WindowTerminalPortal: NSObject {
|
|||
synchronizeAllHostedViews(excluding: nil)
|
||||
|
||||
// During live resize, AppKit can deliver frame churn where host/container geometry
|
||||
// settles a tick before the terminal's own scroll/surface hierarchy. Force a final
|
||||
// in-place geometry + surface refresh for all visible entries in this window.
|
||||
// settles a tick before the terminal's own scroll/surface hierarchy. Only force an
|
||||
// in-place surface refresh when reconciliation actually changed terminal geometry.
|
||||
for entry in entriesByHostedId.values {
|
||||
guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue }
|
||||
hostedView.reconcileGeometryNow()
|
||||
hostedView.refreshSurfaceNow()
|
||||
if hostedView.reconcileGeometryNow() {
|
||||
hostedView.refreshSurfaceNow(reason: "portal.externalGeometrySync")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1392,7 +1400,7 @@ final class WindowTerminalPortal: NSObject {
|
|||
hostedView.frame = targetFrame
|
||||
CATransaction.commit()
|
||||
hostedView.reconcileGeometryNow()
|
||||
hostedView.refreshSurfaceNow()
|
||||
hostedView.refreshSurfaceNow(reason: "portal.frameChange")
|
||||
}
|
||||
|
||||
if hasFiniteFrame {
|
||||
|
|
@ -1431,7 +1439,7 @@ final class WindowTerminalPortal: NSObject {
|
|||
// normal frame-change refresh path won't run. Nudge geometry + redraw so newly
|
||||
// revealed terminals don't sit on a stale/blank IOSurface until later focus churn.
|
||||
hostedView.reconcileGeometryNow()
|
||||
hostedView.refreshSurfaceNow()
|
||||
hostedView.refreshSurfaceNow(reason: "portal.reveal")
|
||||
}
|
||||
|
||||
if transientRecoveryReason == nil {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ struct UpdatePill: View {
|
|||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(model.text)
|
||||
.safeHelp(model.text)
|
||||
.accessibilityLabel(model.text)
|
||||
.accessibilityIdentifier("UpdatePill")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ struct TitlebarControlsView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
// Force the `.help(...)` tooltips to re-evaluate when shortcuts are changed in settings.
|
||||
// Force the `.safeHelp(...)` tooltips to re-evaluate when shortcuts are changed in settings.
|
||||
// (The titlebar controls don't otherwise re-render on UserDefaults changes.)
|
||||
let _ = shortcutRefreshTick
|
||||
let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic
|
||||
|
|
@ -321,7 +321,7 @@ struct TitlebarControlsView: View {
|
|||
}
|
||||
.accessibilityIdentifier("titlebarControl.toggleSidebar")
|
||||
.accessibilityLabel(String(localized: "titlebar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar"))
|
||||
.help(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar")))
|
||||
.safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar")))
|
||||
|
||||
TitlebarControlButton(config: config, action: {
|
||||
#if DEBUG
|
||||
|
|
@ -348,7 +348,7 @@ struct TitlebarControlsView: View {
|
|||
.accessibilityIdentifier("titlebarControl.showNotifications")
|
||||
.background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 })
|
||||
.accessibilityLabel(String(localized: "titlebar.notifications.accessibilityLabel", defaultValue: "Notifications"))
|
||||
.help(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications")))
|
||||
.safeHelp(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications")))
|
||||
|
||||
TitlebarControlButton(config: config, action: {
|
||||
#if DEBUG
|
||||
|
|
@ -360,7 +360,7 @@ struct TitlebarControlsView: View {
|
|||
}
|
||||
.accessibilityIdentifier("titlebarControl.newTab")
|
||||
.accessibilityLabel(String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace"))
|
||||
.help(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace")))
|
||||
.safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace")))
|
||||
}
|
||||
|
||||
let paddedContent = content.padding(config.groupPadding)
|
||||
|
|
@ -729,6 +729,11 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
|
|||
|
||||
view = containerView
|
||||
containerView.translatesAutoresizingMaskIntoConstraints = true
|
||||
// Prevent the titlebar accessory from clipping button backgrounds
|
||||
// at the bottom edge (the system constrains accessory height to the
|
||||
// titlebar, which can be slightly shorter than the button frames).
|
||||
containerView.wantsLayer = true
|
||||
containerView.layer?.masksToBounds = false
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = true
|
||||
hostingView.autoresizingMask = [.width, .height]
|
||||
containerView.addSubview(hostingView)
|
||||
|
|
|
|||
|
|
@ -3432,11 +3432,11 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
needsFollowUpPass = true
|
||||
}
|
||||
|
||||
hostedView.reconcileGeometryNow()
|
||||
let geometryChanged = hostedView.reconcileGeometryNow()
|
||||
// Re-check surface after reconcileGeometryNow() which can trigger AppKit
|
||||
// layout and view lifecycle changes that free surfaces (#432).
|
||||
if terminalPanel.surface.surface != nil {
|
||||
terminalPanel.surface.forceRefresh()
|
||||
if geometryChanged, terminalPanel.surface.surface != nil {
|
||||
terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile")
|
||||
}
|
||||
if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds {
|
||||
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
|
|
@ -3492,9 +3492,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
guard let self, let panel = self.terminalPanel(for: panelId) else { return }
|
||||
panel.hostedView.reconcileGeometryNow()
|
||||
if panel.surface.surface != nil {
|
||||
panel.surface.forceRefresh()
|
||||
let geometryChanged = panel.hostedView.reconcileGeometryNow()
|
||||
if geometryChanged, panel.surface.surface != nil {
|
||||
panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh")
|
||||
}
|
||||
if panel.surface.surface == nil {
|
||||
panel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue