Fix Cmd+plus zoom handling on non-US layouts (#680)

This commit is contained in:
Lawrence Chen 2026-02-28 00:16:03 -08:00 committed by GitHub
parent f73887154d
commit c3b55e2a9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 124 additions and 14 deletions

View file

@ -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<String> {
var keys: Set<String> = [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)) " +

View file

@ -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()
}
}

View file

@ -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 {