Route terminal Cmd zoom shortcuts to Ghostty
This commit is contained in:
parent
d69bd66588
commit
04073f1fd8
2 changed files with 208 additions and 8 deletions
|
|
@ -138,6 +138,61 @@ func browserZoomShortcutAction(
|
|||
return nil
|
||||
}
|
||||
|
||||
func shouldRouteTerminalFontZoomShortcutToGhostty(
|
||||
firstResponderIsGhostty: Bool,
|
||||
flags: NSEvent.ModifierFlags,
|
||||
chars: String,
|
||||
keyCode: UInt16
|
||||
) -> Bool {
|
||||
guard firstResponderIsGhostty else { return false }
|
||||
return browserZoomShortcutAction(flags: flags, chars: chars, keyCode: keyCode) != nil
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func browserZoomShortcutTraceCandidate(
|
||||
flags: NSEvent.ModifierFlags,
|
||||
chars: String,
|
||||
keyCode: UInt16
|
||||
) -> Bool {
|
||||
let normalizedFlags = flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function])
|
||||
guard normalizedFlags.contains(.command) else { return false }
|
||||
|
||||
let key = chars.lowercased()
|
||||
if key == "=" || key == "+" || key == "-" || key == "_" || key == "0" {
|
||||
return true
|
||||
}
|
||||
switch keyCode {
|
||||
case 24, 27, 29, 69, 78, 82: // ANSI and keypad zoom keys
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func browserZoomShortcutTraceFlagsString(_ flags: NSEvent.ModifierFlags) -> String {
|
||||
let normalizedFlags = flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function])
|
||||
var parts: [String] = []
|
||||
if normalizedFlags.contains(.command) { parts.append("Cmd") }
|
||||
if normalizedFlags.contains(.shift) { parts.append("Shift") }
|
||||
if normalizedFlags.contains(.option) { parts.append("Opt") }
|
||||
if normalizedFlags.contains(.control) { parts.append("Ctrl") }
|
||||
return parts.isEmpty ? "none" : parts.joined(separator: "+")
|
||||
}
|
||||
|
||||
func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) -> String {
|
||||
guard let action else { return "none" }
|
||||
switch action {
|
||||
case .zoomIn: return "zoomIn"
|
||||
case .zoomOut: return "zoomOut"
|
||||
case .reset: return "reset"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation {
|
||||
static var shared: AppDelegate?
|
||||
|
|
@ -2044,21 +2099,82 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
}
|
||||
|
||||
if let action = browserZoomShortcutAction(flags: flags, chars: chars, keyCode: event.keyCode),
|
||||
let manager = tabManager {
|
||||
#if DEBUG
|
||||
logBrowserZoomShortcutTrace(stage: "probe", event: event, flags: flags, chars: chars)
|
||||
#endif
|
||||
let zoomAction = browserZoomShortcutAction(flags: flags, chars: chars, keyCode: event.keyCode)
|
||||
#if DEBUG
|
||||
logBrowserZoomShortcutTrace(stage: "match", event: event, flags: flags, chars: chars, action: zoomAction)
|
||||
#endif
|
||||
if let action = zoomAction, let manager = tabManager {
|
||||
let handled: Bool
|
||||
switch action {
|
||||
case .zoomIn:
|
||||
return manager.zoomInFocusedBrowser()
|
||||
handled = manager.zoomInFocusedBrowser()
|
||||
case .zoomOut:
|
||||
return manager.zoomOutFocusedBrowser()
|
||||
handled = manager.zoomOutFocusedBrowser()
|
||||
case .reset:
|
||||
return manager.resetZoomFocusedBrowser()
|
||||
handled = manager.resetZoomFocusedBrowser()
|
||||
}
|
||||
#if DEBUG
|
||||
logBrowserZoomShortcutTrace(
|
||||
stage: "dispatch",
|
||||
event: event,
|
||||
flags: flags,
|
||||
chars: chars,
|
||||
action: action,
|
||||
handled: handled
|
||||
)
|
||||
#endif
|
||||
return handled
|
||||
}
|
||||
#if DEBUG
|
||||
if zoomAction != nil, tabManager == nil {
|
||||
logBrowserZoomShortcutTrace(
|
||||
stage: "dispatch.noManager",
|
||||
event: event,
|
||||
flags: flags,
|
||||
chars: chars,
|
||||
action: zoomAction,
|
||||
handled: false
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func logBrowserZoomShortcutTrace(
|
||||
stage: String,
|
||||
event: NSEvent,
|
||||
flags: NSEvent.ModifierFlags,
|
||||
chars: String,
|
||||
action: BrowserZoomShortcutAction? = nil,
|
||||
handled: Bool? = nil
|
||||
) {
|
||||
guard browserZoomShortcutTraceCandidate(flags: flags, chars: chars, keyCode: event.keyCode) else {
|
||||
return
|
||||
}
|
||||
|
||||
let keyWindow = NSApp.keyWindow
|
||||
let firstResponderType = keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
let panel = tabManager?.focusedBrowserPanel
|
||||
let panelToken = panel.map { String($0.id.uuidString.prefix(8)) } ?? "nil"
|
||||
let panelZoom = panel?.webView.pageZoom ?? -1
|
||||
var line =
|
||||
"zoom.shortcut stage=\(stage) event=\(NSWindow.keyDescription(event)) " +
|
||||
"chars='\(chars)' flags=\(browserZoomShortcutTraceFlagsString(flags)) " +
|
||||
"action=\(browserZoomShortcutTraceActionString(action)) keyWin=\(keyWindow?.windowNumber ?? -1) " +
|
||||
"fr=\(firstResponderType) panel=\(panelToken) zoom=\(String(format: "%.3f", panelZoom)) " +
|
||||
"addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")"
|
||||
if let handled {
|
||||
line += " handled=\(handled ? 1 : 0)"
|
||||
}
|
||||
dlog(line)
|
||||
}
|
||||
#endif
|
||||
|
||||
@discardableResult
|
||||
private func focusBrowserAddressBar(panelId: UUID) -> Bool {
|
||||
guard let tabManager,
|
||||
|
|
@ -3672,6 +3788,22 @@ private extension NSWindow {
|
|||
#endif
|
||||
return result
|
||||
}
|
||||
|
||||
// Preserve Ghostty's terminal font-size shortcuts (Cmd +/−/0) when
|
||||
// the terminal is focused. Otherwise our browser menu shortcuts can
|
||||
// consume the event even when no browser panel is focused.
|
||||
if shouldRouteTerminalFontZoomShortcutToGhostty(
|
||||
firstResponderIsGhostty: true,
|
||||
flags: event.modifierFlags,
|
||||
chars: event.charactersIgnoringModifiers ?? "",
|
||||
keyCode: event.keyCode
|
||||
) {
|
||||
ghosttyView.keyDown(with: event)
|
||||
#if DEBUG
|
||||
dlog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1")
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
|
||||
|
|
@ -3686,11 +3818,28 @@ private extension NSWindow {
|
|||
// events directly to the main menu. This avoids the broken SwiftUI focus path.
|
||||
if self.firstResponder is GhosttyNSView,
|
||||
event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
|
||||
let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) {
|
||||
let mainMenu = NSApp.mainMenu {
|
||||
let consumedByMenu = mainMenu.performKeyEquivalent(with: event)
|
||||
#if DEBUG
|
||||
dlog(" → consumed by mainMenu (bypassed SwiftUI)")
|
||||
if browserZoomShortcutTraceCandidate(
|
||||
flags: event.modifierFlags,
|
||||
chars: event.charactersIgnoringModifiers ?? "",
|
||||
keyCode: event.keyCode
|
||||
) {
|
||||
dlog(
|
||||
"zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " +
|
||||
"consumed=\(consumedByMenu ? 1 : 0) fr=GhosttyNSView"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
return true
|
||||
if !consumedByMenu {
|
||||
// Fall through to the original performKeyEquivalent path below.
|
||||
} else {
|
||||
#if DEBUG
|
||||
dlog(" → consumed by mainMenu (bypassed SwiftUI)")
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let result = cmux_performKeyEquivalent(with: event)
|
||||
|
|
|
|||
|
|
@ -721,6 +721,57 @@ final class BrowserZoomShortcutActionTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase {
|
||||
func testRoutesWhenGhosttyIsFirstResponderAndShortcutIsZoom() {
|
||||
XCTAssertTrue(
|
||||
shouldRouteTerminalFontZoomShortcutToGhostty(
|
||||
firstResponderIsGhostty: true,
|
||||
flags: [.command],
|
||||
chars: "=",
|
||||
keyCode: 24
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
shouldRouteTerminalFontZoomShortcutToGhostty(
|
||||
firstResponderIsGhostty: true,
|
||||
flags: [.command],
|
||||
chars: "-",
|
||||
keyCode: 27
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
shouldRouteTerminalFontZoomShortcutToGhostty(
|
||||
firstResponderIsGhostty: true,
|
||||
flags: [.command],
|
||||
chars: "0",
|
||||
keyCode: 29
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testDoesNotRouteWhenFirstResponderIsNotGhostty() {
|
||||
XCTAssertFalse(
|
||||
shouldRouteTerminalFontZoomShortcutToGhostty(
|
||||
firstResponderIsGhostty: false,
|
||||
flags: [.command],
|
||||
chars: "=",
|
||||
keyCode: 24
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testDoesNotRouteForNonZoomShortcuts() {
|
||||
XCTAssertFalse(
|
||||
shouldRouteTerminalFontZoomShortcutToGhostty(
|
||||
firstResponderIsGhostty: true,
|
||||
flags: [.command],
|
||||
chars: "n",
|
||||
keyCode: 45
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarCommandHintPolicyTests: XCTestCase {
|
||||
func testCommandHintRequiresCommandOnlyModifier() {
|
||||
XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command]))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue