macos: translate physical menu shortcuts (#13888)

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.

**AI Usage:** The approach was suggested by GPT 5.6 Sol, but I wrote
most of the code and understand it all.
This commit is contained in:
Lukas
2026-08-24 09:55:02 +02:00
committed by GitHub
10 changed files with 205 additions and 78 deletions

View File

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

View File

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

View File

@@ -111,13 +111,16 @@ 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? {
guard let cfg = self.config else { return nil }
let trigger = ghostty_config_trigger(cfg, action, UInt(action.lengthOfBytes(using: .utf8)))
@MainActor func keyboardShortcut(for action: String) -> KeyboardShortcut? {
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

View File

@@ -16,16 +16,26 @@ 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 {
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 {

View File

@@ -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.keyEquivalent = shortcut.key.character.description
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)
@@ -101,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
// Make it Hashable
private let identity: Identity
private let modifiersRawValue: UInt
var modifierFlags: NSEvent.ModifierFlags {
@@ -122,40 +143,30 @@ extension Ghostty.MenuShortcutManager {
// it's originally uppercased, then we need to add `shift` to the modifiers
mods.insert(.shift)
}
self.keyEquivalent = normalized
self.identity = .keyEquivalent(normalized)
self.modifiersRawValue = mods.rawValue
}
init(physicalKeyCode: UInt16, modifiers: NSEvent.ModifierFlags) {
self.identity = .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)
}
/// 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`
init?(_ shortcut: KeyboardShortcut) {
// 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)
}
var swiftUIShortcut: KeyboardShortcut? {
guard let character = keyEquivalent.first else { return nil }
return KeyboardShortcut(
KeyEquivalent(character),
modifiers: .init(nsFlags: modifierFlags)
)
/// Create from a SwiftUI `KeyboardShortcut`.
init?(_ shortcut: KeyboardShortcut, physicalKeyCode: UInt16? = nil) {
let modifiers = NSEvent.ModifierFlags(swiftUIFlags: shortcut.modifiers)
if let physicalKeyCode {
self.init(physicalKeyCode: physicalKeyCode, modifiers: modifiers)
} else {
self.init(
keyEquivalent: shortcut.key.character.description,
modifiers: modifiers)
}
}
}
}

View File

@@ -1,3 +1,4 @@
import AppKit
import Carbon
class KeyboardLayout {
@@ -11,4 +12,30 @@ class KeyboardLayout {
return nil
}
/// Translate a physical keycode for use as a menu key equivalent.
///
/// 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 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 }
return result.first
}
}

View File

@@ -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]))

View File

@@ -26,6 +26,21 @@ struct MenuShortcutManagerTests {
#expect(item.keyEquivalentModifierMask == .command)
}
@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 = Ghostty.MenuShortcutManager()
manager.reset()
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")

View File

@@ -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])
@@ -74,6 +69,17 @@ struct NormalizedMenuShortcutKeyTests {
#expect(a != b)
}
@Test func physicalKeysUseKeyCodeIdentity() {
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(physical == same)
#expect(physical != different)
#expect(physical != unicode)
}
@Test func differentModifiersAreNotEqual() {
let a = Key(keyEquivalent: "c", modifiers: .command)
let b = Key(keyEquivalent: "c", modifiers: .option)

View File

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