From 569ff3307c793a10380e1de4770ba21bc4ff58c5 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 18 Aug 2026 11:27:06 -0400 Subject: [PATCH] macos: translate physical menu shortcuts Translate printable physical keybindings through the current macOS keyboard layout before assigning menu key equivalents. Previously these bindings could not be represented because SwiftUI shortcuts are character-based, so actions such as super+backquote had no native menu shortcut. Keep native keycodes as dispatch identity so translated display characters do not change physical semantics or precedence over Unicode bindings. Refresh shortcuts when the input source changes, and prevent AppKit from transforming equivalents that are already localized. --- macos/Sources/App/AppDelegate.swift | 11 +++ macos/Sources/Ghostty/Ghostty.Config.swift | 9 ++- macos/Sources/Ghostty/Ghostty.Input.swift | 39 +++++++--- .../Ghostty/Ghostty.MenuShortcutManager.swift | 78 +++++++++++++++---- macos/Sources/Helpers/KeyboardLayout.swift | 43 ++++++++++ .../Ghostty/MenuShortcutManagerTests.swift | 15 ++++ .../NormalizedMenuShortcutKeyTests.swift | 9 +++ macos/Tests/Helpers/KeyboardLayoutTests.swift | 37 +++++++++ 8 files changed, 213 insertions(+), 28 deletions(-) create mode 100644 macos/Tests/Helpers/KeyboardLayoutTests.swift diff --git a/macos/Sources/App/AppDelegate.swift b/macos/Sources/App/AppDelegate.swift index 127f3a014..91d6f15c1 100644 --- a/macos/Sources/App/AppDelegate.swift +++ b/macos/Sources/App/AppDelegate.swift @@ -263,6 +263,12 @@ class AppDelegate: NSObject, name: .ghosttyConfigDidChange, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardSelectionDidChange(_:)), + name: NSTextInputContext.keyboardSelectionDidChangeNotification, + object: nil + ) NotificationCenter.default.addObserver( self, selector: #selector(ghosttyBellDidRing(_:)), @@ -649,6 +655,11 @@ class AppDelegate: NSObject, ghosttyConfigDidChange(config: config) } + @MainActor @objc private func keyboardSelectionDidChange(_ notification: Notification) { + syncMenuShortcuts(ghostty.config) + TerminalController.all.forEach { $0.relabelTabs() } + } + @objc private func ghosttyBellDidRing(_ notification: Notification) { if ghostty.config.bellFeatures.contains(.system) { NSSound.beep() diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index bfe42b114..10be99c76 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -112,12 +112,15 @@ extension Ghostty { /// /// Returns nil if there is no key equivalent for the given action. func keyboardShortcut(for action: String) -> KeyboardShortcut? { - guard let cfg = self.config else { return nil } - - let trigger = ghostty_config_trigger(cfg, action, UInt(action.lengthOfBytes(using: .utf8))) + guard let trigger = keybindTrigger(for: action) else { return nil } return Ghostty.keyboardShortcut(for: trigger) } + func keybindTrigger(for action: String) -> ghostty_input_trigger_s? { + guard let config else { return nil } + return ghostty_config_trigger(config, action, UInt(action.lengthOfBytes(using: .utf8))) + } + // MARK: - Configuration Values /// For all of the configuration values below, see the associated Ghostty documentation for diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 308312bce..3791e52a7 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -17,15 +17,25 @@ extension Ghostty { /// handling for Ghostty is handled at a lower level (usually). This function should generally only /// be used for things like NSMenu that only support keyboard shortcuts anyways. static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? { + let modifierFlags = Self.eventModifierFlags(mods: trigger.mods) let key: KeyEquivalent switch trigger.tag { case GHOSTTY_TRIGGER_PHYSICAL: - // Only functional keys can be converted to a KeyboardShortcut. Other physical - // mappings cannot because KeyboardShortcut in Swift is inherently layout-dependent. - if let equiv = Self.keyToEquivalent[trigger.key.physical] { - key = equiv + let physical = trigger.key.physical + if let equivalent = Self.keyToEquivalent[physical] { + key = equivalent } else { - return nil + guard + Self.writingSystemKeyRange.contains(physical.rawValue), + let inputKey = Input.Key(cKey: physical), + let keyCode = inputKey.keyCode, + let character = KeyboardLayout.character( + for: keyCode, + modifiers: modifierFlags) + else { return nil } + + // Printable physical keys must be translated through the current layout. + key = KeyEquivalent(character) } case GHOSTTY_TRIGGER_UNICODE: @@ -45,7 +55,7 @@ extension Ghostty { return KeyboardShortcut( key, - modifiers: EventModifiers(nsFlags: Ghostty.eventModifierFlags(mods: trigger.mods))) + modifiers: EventModifiers(nsFlags: modifierFlags)) } // MARK: Mods @@ -101,6 +111,10 @@ extension Ghostty { GHOSTTY_KEY_BACKSPACE: .delete, GHOSTTY_KEY_SPACE: .space, ] + + /// The contiguous W3C "Writing System Keys" ยง 3.1.1 key range. + private static let writingSystemKeyRange = + GHOSTTY_KEY_BACKQUOTE.rawValue...GHOSTTY_KEY_SLASH.rawValue } // MARK: Ghostty.Input.BindingFlags @@ -771,14 +785,15 @@ extension Ghostty.Input { case cut case paste + init?(cKey: ghostty_input_key_e) { + guard let key = Key.allCases.first(where: { $0.cKey == cKey }) else { return nil } + self = key + } + /// Get a key from a keycode init?(keyCode: UInt16) { - if let key = Key.allCases.first(where: { $0.keyCode == keyCode }) { - self = key - return - } - - return nil + guard let key = Key.allCases.first(where: { $0.keyCode == keyCode }) else { return nil } + self = key } var cKey: ghostty_input_key_e { diff --git a/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift b/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift index d7145745f..8c00ab04b 100644 --- a/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift +++ b/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import GhosttyKit extension Ghostty { /// The manager that's responsible for updating shortcuts of Ghostty's app menu @@ -26,6 +27,8 @@ extension Ghostty { if !updateMenuShortcut(config, action: action, menuItem: menu) { menu.keyEquivalent = "" menu.keyEquivalentModifierMask = [] + menu.allowsAutomaticKeyEquivalentLocalization = true + menu.allowsAutomaticKeyEquivalentMirroring = true } } @@ -34,16 +37,23 @@ extension Ghostty { /// bindings through the menu so they flash but also lets our surface override macOS built-ins /// like Cmd+H. func performGhosttyBindingMenuKeyEquivalent(with event: NSEvent) -> Bool { - // Convert this event into the same normalized lookup key we use when - // syncing menu shortcuts from configuration. - guard let key = MenuShortcutKey(event: event) else { - return false + // Physical bindings take precedence over Unicode bindings in the core. + let physicalKey = MenuShortcutKey( + physicalKeyCode: event.keyCode, + modifiers: event.modifierFlags) + if let result = performMenuItem(for: physicalKey) { + return result } + guard let key = MenuShortcutKey(event: event) else { return false } + return performMenuItem(for: key) ?? false + } + + private func performMenuItem(for key: MenuShortcutKey) -> Bool? { // If we don't have an entry for this key combo, no Ghostty-owned // menu shortcut exists for this event. guard let weakItem = menuItemsByShortcut[key] else { - return false + return nil } // Weak references can be nil if a menu item was deallocated after sync. @@ -81,16 +91,23 @@ private extension Ghostty.MenuShortcutManager { func updateMenuShortcut(_ config: Ghostty.Config, action: String?, menuItem menu: NSMenuItem) -> Bool { guard let action, - let shortcut = config.keyboardShortcut(for: action), - // Build a direct lookup for key-equivalent dispatch so we don't need to - // linearly walk the full menu hierarchy at event time. - let key = MenuShortcutKey(shortcut) - else { + let trigger = config.keybindTrigger(for: action), + let shortcut = Ghostty.keyboardShortcut(for: trigger) + else { return false } + + let isPhysical = trigger.tag == GHOSTTY_TRIGGER_PHYSICAL + let physicalKeyCode = isPhysical ? Ghostty.Input.Key(cKey: trigger.key.physical)?.keyCode : nil + // Build a direct lookup for key-equivalent dispatch so we don't need to + // linearly walk the full menu hierarchy at event time. + guard let key = MenuShortcutKey(shortcut, physicalKeyCode: physicalKeyCode) else { return false } menu.keyEquivalent = key.keyEquivalent menu.keyEquivalentModifierMask = key.modifierFlags + // The key equivalent was already localized from the physical keycode. + menu.allowsAutomaticKeyEquivalentLocalization = !isPhysical + menu.allowsAutomaticKeyEquivalentMirroring = !isPhysical // Later registrations intentionally override earlier ones for the same key. menuItemsByShortcut[key] = .init(menu) @@ -104,7 +121,7 @@ extension Ghostty.MenuShortcutManager { private static let shortcutModifiers: NSEvent.ModifierFlags = [.shift, .control, .option, .command] let keyEquivalent: String - // Make it Hashable + private let physicalKeyCode: UInt16? private let modifiersRawValue: UInt var modifierFlags: NSEvent.ModifierFlags { @@ -123,9 +140,20 @@ extension Ghostty.MenuShortcutManager { mods.insert(.shift) } self.keyEquivalent = normalized + self.physicalKeyCode = nil self.modifiersRawValue = mods.rawValue } + init( + keyEquivalent: String = "", + physicalKeyCode: UInt16, + modifiers: NSEvent.ModifierFlags + ) { + self.keyEquivalent = keyEquivalent + self.physicalKeyCode = physicalKeyCode + self.modifiersRawValue = modifiers.intersection(Self.shortcutModifiers).rawValue + } + init?(event: NSEvent) { guard let keyEquivalent = event.charactersIgnoringModifiers else { return nil } self.init(keyEquivalent: keyEquivalent, modifiers: event.modifierFlags) @@ -142,12 +170,36 @@ extension Ghostty.MenuShortcutManager { } /// Create from a swiftUI `KeyboardShortcut` - init?(_ shortcut: KeyboardShortcut) { + init?(_ shortcut: KeyboardShortcut, physicalKeyCode: UInt16? = nil) { // Ghostty configured shortcuts are already normalized // in `Ghostty.keyboardShortcut(for:)`, see also gh-#12039 let keyEquivalent = shortcut.key.character.description let modifierMask = NSEvent.ModifierFlags(swiftUIFlags: shortcut.modifiers) - self.init(keyEquivalent: keyEquivalent, modifiers: modifierMask) + if let physicalKeyCode { + self.init( + keyEquivalent: keyEquivalent, + physicalKeyCode: physicalKeyCode, + modifiers: modifierMask) + } else { + self.init(keyEquivalent: keyEquivalent, modifiers: modifierMask) + } + } + + static func == (lhs: Self, rhs: Self) -> Bool { + guard lhs.modifiersRawValue == rhs.modifiersRawValue else { return false } + return switch (lhs.physicalKeyCode, rhs.physicalKeyCode) { + case let (.some(lhs), .some(rhs)): lhs == rhs + case (nil, nil): lhs.keyEquivalent == rhs.keyEquivalent + default: false + } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(modifiersRawValue) + hasher.combine(physicalKeyCode) + if physicalKeyCode == nil { + hasher.combine(keyEquivalent) + } } var swiftUIShortcut: KeyboardShortcut? { diff --git a/macos/Sources/Helpers/KeyboardLayout.swift b/macos/Sources/Helpers/KeyboardLayout.swift index 8e573f495..e6c53d2a1 100644 --- a/macos/Sources/Helpers/KeyboardLayout.swift +++ b/macos/Sources/Helpers/KeyboardLayout.swift @@ -1,3 +1,4 @@ +import AppKit import Carbon class KeyboardLayout { @@ -11,4 +12,46 @@ class KeyboardLayout { return nil } + + /// Translate a physical keycode for use as a menu key equivalent. + /// + /// Must be called on the main thread because Text Input Sources APIs are not thread-safe. + static func character( + for keyCode: UInt16, + modifiers: NSEvent.ModifierFlags + ) -> Character? { + guard + let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), + let dataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) + else { return nil } + + let data = unsafeBitCast(dataPointer, to: CFData.self) + guard let bytes = CFDataGetBytePtr(data) else { return nil } + + // Command can select a distinct layout table. Other modifiers remain + // separate in the menu's modifier mask and must not affect this character. + let carbonModifiers = modifiers.contains(.command) ? UInt32(cmdKey) >> 8 : 0 + + var deadKeyState: UInt32 = 0 + var characters = [UniChar](repeating: 0, count: 4) + var length = 0 + let status = bytes.withMemoryRebound(to: UCKeyboardLayout.self, capacity: 1) { layout in + UCKeyTranslate( + layout, + keyCode, + UInt16(kUCKeyActionDisplay), + carbonModifiers, + UInt32(LMGetKbdType()), + UInt32(kUCKeyTranslateNoDeadKeysMask), + &deadKeyState, + characters.count, + &length, + &characters) + } + guard status == noErr else { return nil } + + let result = String(utf16CodeUnits: characters, count: length) + guard result.count == 1 else { return nil } + return result.first + } } diff --git a/macos/Tests/Ghostty/MenuShortcutManagerTests.swift b/macos/Tests/Ghostty/MenuShortcutManagerTests.swift index ab8806b9b..d5a4cef00 100644 --- a/macos/Tests/Ghostty/MenuShortcutManagerTests.swift +++ b/macos/Tests/Ghostty/MenuShortcutManagerTests.swift @@ -26,6 +26,21 @@ struct MenuShortcutManagerTests { #expect(item.keyEquivalentModifierMask == .command) } + @Test func physicalBackquoteUsesCurrentKeyboardLayout() async throws { + let config = try TemporaryConfig("keybind=super+backquote=toggle_quick_terminal") + let expected = try #require(KeyboardLayout.character(for: 0x32, modifiers: .command)) + let item = NSMenuItem(title: "Quick Terminal", action: nil, keyEquivalent: "") + let manager = await Ghostty.MenuShortcutManager() + + await manager.reset() + await manager.syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: item) + + #expect(item.keyEquivalent == String(expected)) + #expect(item.keyEquivalentModifierMask == .command) + #expect(!item.allowsAutomaticKeyEquivalentLocalization) + #expect(!item.allowsAutomaticKeyEquivalentMirroring) + } + @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11396", id: 11396)) func overrideDefault() async throws { let config = try TemporaryConfig("keybind=super+h=goto_split:left") diff --git a/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift b/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift index 73bf9704e..d92078e7a 100644 --- a/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift +++ b/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift @@ -74,6 +74,15 @@ struct NormalizedMenuShortcutKeyTests { #expect(a != b) } + @Test func physicalKeysUseKeyCodeIdentity() { + let configured = Key(keyEquivalent: "`", physicalKeyCode: 0x32, modifiers: .command) + let event = Key(physicalKeyCode: 0x32, modifiers: .command) + let unicode = Key(keyEquivalent: "`", modifiers: .command) + + #expect(configured == event) + #expect(configured != unicode) + } + @Test func differentModifiersAreNotEqual() { let a = Key(keyEquivalent: "c", modifiers: .command) let b = Key(keyEquivalent: "c", modifiers: .option) diff --git a/macos/Tests/Helpers/KeyboardLayoutTests.swift b/macos/Tests/Helpers/KeyboardLayoutTests.swift new file mode 100644 index 000000000..15c58928b --- /dev/null +++ b/macos/Tests/Helpers/KeyboardLayoutTests.swift @@ -0,0 +1,37 @@ +import AppKit +import Testing +@testable import Ghostty + +@MainActor +struct KeyboardLayoutTests { + @Test(arguments: [ + 0x00, // W3C KeyA + 0x12, // W3C Digit1 + 0x32, // W3C Backquote + ]) + func characterHandlesKeyCode(keyCode: UInt16) { + #expect(KeyboardLayout.character(for: keyCode, modifiers: []) != nil) + } + + @Test func characterRejectsInvalidKeyCode() { + #expect(KeyboardLayout.character(for: UInt16.max, modifiers: []) == nil) + } + + @Test(arguments: [ + ([.shift, .control, .option], []), + ([.command, .shift, .control, .option], .command), + ] as [(NSEvent.ModifierFlags, NSEvent.ModifierFlags)]) + func characterUsesOnlyCommandModifier( + modifiers: NSEvent.ModifierFlags, + effectiveModifiers: NSEvent.ModifierFlags + ) throws { + let keyCode: UInt16 = 0x00 // W3C KeyA + let expected = try #require(KeyboardLayout.character( + for: keyCode, + modifiers: effectiveModifiers)) + let actual = try #require(KeyboardLayout.character( + for: keyCode, + modifiers: modifiers)) + #expect(actual == expected) + } +}