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.
This commit is contained in:
Jon Parise
2026-08-18 11:27:06 -04:00
parent 4816afc742
commit 569ff3307c
8 changed files with 213 additions and 28 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

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

View File

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

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.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? {

View File

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

View File

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

View File

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

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