From 569ff3307c793a10380e1de4770ba21bc4ff58c5 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 18 Aug 2026 11:27:06 -0400 Subject: [PATCH 1/4] 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) + } +} From 761696c349c61b38e3b474a48a76f2dfc6f1af28 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 18 Aug 2026 11:49:46 -0400 Subject: [PATCH 2/4] macos: simplify menu shortcut identity Separate menu shortcut presentation from lookup identity. Store either a normalized key equivalent or a physical keycode in a private hashable enum, allowing Swift to synthesize equality and hashing instead of maintaining parallel optional-key logic. Assign display characters directly from KeyboardShortcut and remove unused NSMenuItem and SwiftUI conversion helpers. --- .../Ghostty/Ghostty.MenuShortcutManager.swift | 73 ++++--------------- .../NormalizedMenuShortcutKeyTests.swift | 15 ++-- 2 files changed, 22 insertions(+), 66 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift b/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift index 8c00ab04b..cece791b7 100644 --- a/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift +++ b/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift @@ -103,7 +103,7 @@ private extension Ghostty.MenuShortcutManager { return false } - menu.keyEquivalent = key.keyEquivalent + menu.keyEquivalent = shortcut.key.character.description menu.keyEquivalentModifierMask = key.modifierFlags // The key equivalent was already localized from the physical keycode. menu.allowsAutomaticKeyEquivalentLocalization = !isPhysical @@ -118,10 +118,14 @@ private extension Ghostty.MenuShortcutManager { extension Ghostty.MenuShortcutManager { /// Hashable key for a menu shortcut match, normalized for quick lookup. struct MenuShortcutKey: Hashable { + private enum Identity: Hashable { + case keyEquivalent(String) + case physicalKeyCode(UInt16) + } + private static let shortcutModifiers: NSEvent.ModifierFlags = [.shift, .control, .option, .command] - let keyEquivalent: String - private let physicalKeyCode: UInt16? + private let identity: Identity private let modifiersRawValue: UInt var modifierFlags: NSEvent.ModifierFlags { @@ -139,18 +143,12 @@ extension Ghostty.MenuShortcutManager { // it's originally uppercased, then we need to add `shift` to the modifiers mods.insert(.shift) } - self.keyEquivalent = normalized - self.physicalKeyCode = nil + self.identity = .keyEquivalent(normalized) self.modifiersRawValue = mods.rawValue } - init( - keyEquivalent: String = "", - physicalKeyCode: UInt16, - modifiers: NSEvent.ModifierFlags - ) { - self.keyEquivalent = keyEquivalent - self.physicalKeyCode = physicalKeyCode + init(physicalKeyCode: UInt16, modifiers: NSEvent.ModifierFlags) { + self.identity = .physicalKeyCode(physicalKeyCode) self.modifiersRawValue = modifiers.intersection(Self.shortcutModifiers).rawValue } @@ -159,55 +157,16 @@ extension Ghostty.MenuShortcutManager { self.init(keyEquivalent: keyEquivalent, modifiers: event.modifierFlags) } - /// Create from a `NSMenuItem` - /// - /// - Important: This will check whether the `keyEquivalent` is uppercased by `.shift` modifier. - init?(_ menuItem: NSMenuItem) { - self.init( - keyEquivalent: menuItem.keyEquivalent, - modifiers: menuItem.keyEquivalentModifierMask, - ) - } - - /// Create from a swiftUI `KeyboardShortcut` + /// Create from a SwiftUI `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) + let modifiers = NSEvent.ModifierFlags(swiftUIFlags: shortcut.modifiers) if let physicalKeyCode { - self.init( - keyEquivalent: keyEquivalent, - physicalKeyCode: physicalKeyCode, - modifiers: modifierMask) + self.init(physicalKeyCode: physicalKeyCode, modifiers: modifiers) } else { - self.init(keyEquivalent: keyEquivalent, modifiers: modifierMask) + self.init( + keyEquivalent: shortcut.key.character.description, + modifiers: modifiers) } } - - 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? { - guard let character = keyEquivalent.first else { return nil } - return KeyboardShortcut( - KeyEquivalent(character), - modifiers: .init(nsFlags: modifierFlags) - ) - } } } diff --git a/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift b/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift index d92078e7a..f83a0e7ad 100644 --- a/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift +++ b/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift @@ -13,11 +13,6 @@ struct NormalizedMenuShortcutKeyTests { #expect(key == nil) } - @Test func lowercasesKeyEquivalent() { - let key = Key(keyEquivalent: "A", modifiers: .command) - #expect(key?.keyEquivalent == "a") - } - @Test func stripsNonShortcutModifiers() { // .capsLock and .function should be stripped let key = Key(keyEquivalent: "c", modifiers: [.command, .capsLock, .function]) @@ -75,12 +70,14 @@ struct NormalizedMenuShortcutKeyTests { } @Test func physicalKeysUseKeyCodeIdentity() { - let configured = Key(keyEquivalent: "`", physicalKeyCode: 0x32, modifiers: .command) - let event = Key(physicalKeyCode: 0x32, modifiers: .command) + let physical = Key(physicalKeyCode: 0x32, modifiers: .command) + let same = Key(physicalKeyCode: 0x32, modifiers: .command) + let different = Key(physicalKeyCode: 0x31, modifiers: .command) let unicode = Key(keyEquivalent: "`", modifiers: .command) - #expect(configured == event) - #expect(configured != unicode) + #expect(physical == same) + #expect(physical != different) + #expect(physical != unicode) } @Test func differentModifiersAreNotEqual() { From 9886f4817cbc83319d69391f505fd6b611d0a621 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 18 Aug 2026 12:34:59 -0400 Subject: [PATCH 3/4] macos: enforce keyboard layout actor isolation Text Input Sources APIs are not thread-safe, but shortcut translation could be called outside a declared main-actor context. Mark keyboard layout and shortcut conversion as main-actor isolated, update their tests, and dispatch key-sequence UI notifications to the main queue before translating their shortcuts. --- macos/Sources/Ghostty/Ghostty.App.swift | 28 ++++++++++--------- macos/Sources/Ghostty/Ghostty.Config.swift | 2 +- macos/Sources/Ghostty/Ghostty.Input.swift | 2 +- macos/Sources/Helpers/KeyboardLayout.swift | 2 +- macos/Tests/Ghostty/ConfigTests.swift | 8 +++--- .../Ghostty/MenuShortcutManagerTests.swift | 8 +++--- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 495dd8232..943120413 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1985,19 +1985,21 @@ extension Ghostty { case GHOSTTY_TARGET_SURFACE: guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } - if v.active { - NotificationCenter.default.post( - name: Notification.didContinueKeySequence, - object: surfaceView, - userInfo: [ - Notification.KeySequenceKey: keyboardShortcut(for: v.trigger) as Any - ] - ) - } else { - NotificationCenter.default.post( - name: Notification.didEndKeySequence, - object: surfaceView - ) + DispatchQueue.main.async { + if v.active { + NotificationCenter.default.post( + name: Notification.didContinueKeySequence, + object: surfaceView, + userInfo: [ + Notification.KeySequenceKey: keyboardShortcut(for: v.trigger) as Any + ] + ) + } else { + NotificationCenter.default.post( + name: Notification.didEndKeySequence, + object: surfaceView + ) + } } default: diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 10be99c76..8881e09ca 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -111,7 +111,7 @@ extension Ghostty { /// configuration would be "quit" action. /// /// Returns nil if there is no key equivalent for the given action. - func keyboardShortcut(for action: String) -> KeyboardShortcut? { + @MainActor func keyboardShortcut(for action: String) -> KeyboardShortcut? { guard let trigger = keybindTrigger(for: action) else { return nil } return Ghostty.keyboardShortcut(for: trigger) } diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 3791e52a7..570043832 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -16,7 +16,7 @@ extension Ghostty { /// (F1, F2, ...) with a KeyboardShortcut. This doesn't represent a practical issue because input /// 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? { + @MainActor static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? { let modifierFlags = Self.eventModifierFlags(mods: trigger.mods) let key: KeyEquivalent switch trigger.tag { diff --git a/macos/Sources/Helpers/KeyboardLayout.swift b/macos/Sources/Helpers/KeyboardLayout.swift index e6c53d2a1..30bcfab0e 100644 --- a/macos/Sources/Helpers/KeyboardLayout.swift +++ b/macos/Sources/Helpers/KeyboardLayout.swift @@ -16,7 +16,7 @@ class KeyboardLayout { /// 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( + @MainActor static func character( for keyCode: UInt16, modifiers: NSEvent.ModifierFlags ) -> Character? { diff --git a/macos/Tests/Ghostty/ConfigTests.swift b/macos/Tests/Ghostty/ConfigTests.swift index a4b8472ac..a8fc52be4 100644 --- a/macos/Tests/Ghostty/ConfigTests.swift +++ b/macos/Tests/Ghostty/ConfigTests.swift @@ -223,8 +223,8 @@ struct ConfigTests { // MARK: - Keybind - @Test - func uppercasedLetterShouldBeNormalized() async throws { + @MainActor @Test + func uppercasedLetterShouldBeNormalized() throws { let config = try TemporaryConfig(""" keybind=cmd+L=goto_split:left """) @@ -238,8 +238,8 @@ struct ConfigTests { #expect(shortcut2 == .init("ä", modifiers: [.command])) } - @Test - func emptyConfigShouldBeHaveDefaultShortcut() async throws { + @MainActor @Test + func emptyConfigShouldBeHaveDefaultShortcut() throws { let config = try TemporaryConfig("") let newWindow = try #require(config.keyboardShortcut(for: "new_window")) #expect(newWindow == .init("n", modifiers: [.command])) diff --git a/macos/Tests/Ghostty/MenuShortcutManagerTests.swift b/macos/Tests/Ghostty/MenuShortcutManagerTests.swift index d5a4cef00..5961498ff 100644 --- a/macos/Tests/Ghostty/MenuShortcutManagerTests.swift +++ b/macos/Tests/Ghostty/MenuShortcutManagerTests.swift @@ -26,14 +26,14 @@ struct MenuShortcutManagerTests { #expect(item.keyEquivalentModifierMask == .command) } - @Test func physicalBackquoteUsesCurrentKeyboardLayout() async throws { + @MainActor @Test func physicalBackquoteUsesCurrentKeyboardLayout() 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() + let manager = Ghostty.MenuShortcutManager() - await manager.reset() - await manager.syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: item) + manager.reset() + manager.syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: item) #expect(item.keyEquivalent == String(expected)) #expect(item.keyEquivalentModifierMask == .command) From f5ad3a0a4a1e56b71e7710660f247899a6f19c5d Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 19 Aug 2026 14:14:12 -0400 Subject: [PATCH 4/4] macos: use AppKit for shortcut translation Translate synthetic physical-key events with characters(byApplyingModifiers:). This preserves current-layout and Command-table behavior while avoiding a duplicate direct UCKeyTranslate implementation in Swift. --- macos/Sources/Helpers/KeyboardLayout.swift | 44 +++++++--------------- 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/macos/Sources/Helpers/KeyboardLayout.swift b/macos/Sources/Helpers/KeyboardLayout.swift index 30bcfab0e..f8dd5b30f 100644 --- a/macos/Sources/Helpers/KeyboardLayout.swift +++ b/macos/Sources/Helpers/KeyboardLayout.swift @@ -15,43 +15,27 @@ class KeyboardLayout { /// 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. + /// AppKit retranslates against the current input source without changing its dead key state. @MainActor static func character( for keyCode: UInt16, modifiers: NSEvent.ModifierFlags ) -> Character? { guard - let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), - let dataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) + let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: keyCode), + let result = event.characters(byApplyingModifiers: modifiers.intersection(.command)), + result.count == 1 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 } }