mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-16 10:51:59 +00:00
Merge branch 'ghostty-org:main' into main
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