Merge branch 'ghostty-org:main' into main

This commit is contained in:
Mohammad AlShami
2026-08-24 13:25:36 +03:00
committed by Mohammad H. AlShami
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)
}
}