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) + } +}