mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
37
macos/Tests/Helpers/KeyboardLayoutTests.swift
Normal file
37
macos/Tests/Helpers/KeyboardLayoutTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user