mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-26 09:01:44 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
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