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.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 bfe42b114..8881e09ca 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -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 diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 308312bce..570043832 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -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 { diff --git a/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift b/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift index d7145745f..cece791b7 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.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) + } } } } diff --git a/macos/Sources/Helpers/KeyboardLayout.swift b/macos/Sources/Helpers/KeyboardLayout.swift index 8e573f495..f8dd5b30f 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,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 + } } 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 ab8806b9b..5961498ff 100644 --- a/macos/Tests/Ghostty/MenuShortcutManagerTests.swift +++ b/macos/Tests/Ghostty/MenuShortcutManagerTests.swift @@ -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") diff --git a/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift b/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift index 73bf9704e..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]) @@ -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) 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) + } +}