diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 33b7ade6..cb8ed24a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -727,32 +727,55 @@ struct CommandPaletteDebugSnapshot { func browserZoomShortcutAction( flags: NSEvent.ModifierFlags, chars: String, - keyCode: UInt16 + keyCode: UInt16, + literalChars: String? = nil ) -> BrowserZoomShortcutAction? { let normalizedFlags = flags .intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function]) - let key = chars.lowercased() let hasCommand = normalizedFlags.contains(.command) let hasOnlyCommandAndOptionalShift = hasCommand && normalizedFlags.isDisjoint(with: [.control, .option]) guard hasOnlyCommandAndOptionalShift else { return nil } + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) - if key == "=" || key == "+" || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus + if keys.contains("=") || keys.contains("+") || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus return .zoomIn } - if key == "-" || key == "_" || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus + if keys.contains("-") || keys.contains("_") || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus return .zoomOut } - if key == "0" || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 + if keys.contains("0") || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 return .reset } return nil } +func browserZoomShortcutKeyCandidates( + chars: String, + literalChars: String?, + keyCode: UInt16 +) -> Set { + var keys: Set = [chars.lowercased()] + + if let literalChars, !literalChars.isEmpty { + keys.insert(literalChars.lowercased()) + } + + if let layoutChar = KeyboardLayout.character(forKeyCode: keyCode), !layoutChar.isEmpty { + keys.insert(layoutChar) + } + + return keys +} + func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( firstResponderIsWindow: Bool, hostedSize: CGSize, @@ -768,10 +791,16 @@ func shouldRouteTerminalFontZoomShortcutToGhostty( firstResponderIsGhostty: Bool, flags: NSEvent.ModifierFlags, chars: String, - keyCode: UInt16 + keyCode: UInt16, + literalChars: String? = nil ) -> Bool { guard firstResponderIsGhostty else { return false } - return browserZoomShortcutAction(flags: flags, chars: chars, keyCode: keyCode) != nil + return browserZoomShortcutAction( + flags: flags, + chars: chars, + keyCode: keyCode, + literalChars: literalChars + ) != nil } func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { @@ -826,15 +855,20 @@ private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { func browserZoomShortcutTraceCandidate( flags: NSEvent.ModifierFlags, chars: String, - keyCode: UInt16 + keyCode: UInt16, + literalChars: String? = nil ) -> 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" { + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) + if keys.contains("=") || keys.contains("+") || keys.contains("-") || keys.contains("_") || keys.contains("0") { return true } switch keyCode { @@ -5458,7 +5492,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG logBrowserZoomShortcutTrace(stage: "probe", event: event, flags: flags, chars: chars) #endif - let zoomAction = browserZoomShortcutAction(flags: flags, chars: chars, keyCode: event.keyCode) + let zoomAction = browserZoomShortcutAction( + flags: flags, + chars: chars, + keyCode: event.keyCode, + literalChars: event.characters + ) #if DEBUG logBrowserZoomShortcutTrace(stage: "match", event: event, flags: flags, chars: chars, action: zoomAction) #endif @@ -5552,7 +5591,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent action: BrowserZoomShortcutAction? = nil, handled: Bool? = nil ) { - guard browserZoomShortcutTraceCandidate(flags: flags, chars: chars, keyCode: event.keyCode) else { + guard browserZoomShortcutTraceCandidate( + flags: flags, + chars: chars, + keyCode: event.keyCode, + literalChars: event.characters + ) else { return } @@ -7485,7 +7529,8 @@ private extension NSWindow { firstResponderIsGhostty: true, flags: event.modifierFlags, chars: event.charactersIgnoringModifiers ?? "", - keyCode: event.keyCode + keyCode: event.keyCode, + literalChars: event.characters ) { ghosttyView.keyDown(with: event) #if DEBUG @@ -7538,7 +7583,8 @@ private extension NSWindow { if browserZoomShortcutTraceCandidate( flags: event.modifierFlags, chars: event.charactersIgnoringModifiers ?? "", - keyCode: event.keyCode + keyCode: event.keyCode, + literalChars: event.characters ) { dlog( "zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " + diff --git a/Sources/KeyboardLayout.swift b/Sources/KeyboardLayout.swift index 4407461f..392d0723 100644 --- a/Sources/KeyboardLayout.swift +++ b/Sources/KeyboardLayout.swift @@ -11,4 +11,36 @@ class KeyboardLayout { return nil } + + /// Translate a physical keyCode to the unmodified character under the current keyboard layout. + static func character(forKeyCode keyCode: UInt16) -> String? { + guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), + let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { + return nil + } + + let layoutData = unsafeBitCast(layoutDataPointer, to: CFData.self) + guard let bytes = CFDataGetBytePtr(layoutData) else { return nil } + let keyboardLayout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self) + + var deadKeyState: UInt32 = 0 + var chars = [UniChar](repeating: 0, count: 4) + var length = 0 + + let status = UCKeyTranslate( + keyboardLayout, + keyCode, + UInt16(kUCKeyActionDisplay), + 0, + UInt32(LMGetKbdType()), + UInt32(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + chars.count, + &length, + &chars + ) + + guard status == noErr, length > 0 else { return nil } + return String(utf16CodeUnits: chars, count: length).lowercased() + } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 85d0cd87..3217ab1c 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2045,6 +2045,26 @@ final class BrowserZoomShortcutActionTests: XCTestCase { ) } + func testZoomInSupportsShiftedLiteralFromDifferentPhysicalKey() { + XCTAssertEqual( + browserZoomShortcutAction( + flags: [.command, .shift], + chars: ";", + keyCode: 41, + literalChars: "+" + ), + .zoomIn + ) + + XCTAssertNil( + browserZoomShortcutAction( + flags: [.command, .shift], + chars: ";", + keyCode: 41 + ) + ) + } + func testZoomRequiresCommandWithoutOptionOrControl() { XCTAssertNil(browserZoomShortcutAction(flags: [], chars: "=", keyCode: 24)) XCTAssertNil(browserZoomShortcutAction(flags: [.command, .option], chars: "=", keyCode: 24)) @@ -2108,6 +2128,18 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { ) ) } + + func testRoutesForShiftedLiteralZoomShortcut() { + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command, .shift], + chars: ";", + keyCode: 41, + literalChars: "+" + ) + ) + } } final class GhosttyResponderResolutionTests: XCTestCase {