macOS: use KeyboardShortcut rather than homegrown KeyEquivalent

This replaces the use of our custom `Ghostty.KeyEquivalent` with
the SwiftUI `KeyboardShortcut` type. This is a more standard way to
represent keyboard shortcuts and lets us more tightly integrate with
SwiftUI/AppKit when necessary over our custom type.

Note that not all Ghostty triggers can be represented as
KeyboardShortcut values because macOS itself does not support
binding keys such as function keys (e.g. F1-F12) to KeyboardShortcuts.

This isn't an issue since all input also passes through a lower level
libghostty API which can handle all key events, we just can't show these
keyboard shortcuts on things like the menu bar. This was already true
before this commit.
This commit is contained in:
Mitchell Hashimoto
2025-04-19 14:16:14 -07:00
parent 4e10f972df
commit b05826ac9d
9 changed files with 126 additions and 87 deletions

View File

@@ -34,6 +34,8 @@
A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */; };
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */; };
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; };
A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */; };
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */; };
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */; };
A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C932B53B43700305CE6 /* iOSApp.swift */; };
@@ -138,6 +140,8 @@
A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_AppKit.swift; sourceTree = "<group>"; };
A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EventModifiers+Extension.swift"; sourceTree = "<group>"; };
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcut+Extension.swift"; sourceTree = "<group>"; };
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; };
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
@@ -288,8 +292,10 @@
A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
@@ -667,6 +673,7 @@
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
@@ -691,6 +698,7 @@
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */,
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */,
A5FEB3002ABB69450068369E /* main.swift in Sources */,
A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */,
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */,
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,

View File

@@ -419,15 +419,15 @@ class AppDelegate: NSObject,
/// action string used for the Ghostty configuration.
private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) {
guard let menu = menuItem else { return }
guard let equiv = config.keyEquivalent(for: action) else {
guard let shortcut = config.keyboardShortcut(for: action) else {
// No shortcut, clear the menu item
menu.keyEquivalent = ""
menu.keyEquivalentModifierMask = []
return
}
menu.keyEquivalent = equiv.key
menu.keyEquivalentModifierMask = equiv.modifiers
menu.keyEquivalent = shortcut.key.character.description
menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers)
}
private func focusedSurface() -> ghostty_surface_t? {

View File

@@ -192,7 +192,7 @@ class TerminalController: BaseTerminalController {
}
let action = "goto_tab:\(tab)"
if let equiv = ghostty.config.keyEquivalent(for: action) {
if let equiv = ghostty.config.keyboardShortcut(for: action) {
window.keyEquivalent = "\(equiv)"
} else {
window.keyEquivalent = ""

View File

@@ -1306,7 +1306,7 @@ extension Ghostty {
name: Notification.didContinueKeySequence,
object: surfaceView,
userInfo: [
Notification.KeySequenceKey: keyEquivalent(for: v.trigger) as Any
Notification.KeySequenceKey: keyboardShortcut(for: v.trigger) as Any
]
)
} else {

View File

@@ -102,11 +102,11 @@ extension Ghostty {
/// configuration would be "quit" action.
///
/// Returns nil if there is no key equivalent for the given action.
func keyEquivalent(for action: String) -> KeyEquivalent? {
func keyboardShortcut(for action: String) -> KeyboardShortcut? {
guard let cfg = self.config else { return nil }
let trigger = ghostty_config_trigger(cfg, action, UInt(action.count))
return Ghostty.keyEquivalent(for: trigger)
return Ghostty.keyboardShortcut(for: trigger)
}
#endif

View File

@@ -1,66 +1,52 @@
import Cocoa
import SwiftUI
import GhosttyKit
extension Ghostty {
// MARK: Key Equivalents
// MARK: Keyboard Shortcuts
/// Returns the "keyEquivalent" string for a given input key. This doesn't always have a corresponding key.
static func keyEquivalent(key: ghostty_input_key_e) -> String? {
/// Returns the SwiftUI KeyEquivalent for a given key. Note that not all keys known by
/// Ghostty have a macOS equivalent since macOS doesn't allow all keys as equivalents.
static func keyEquivalent(key: ghostty_input_key_e) -> KeyEquivalent? {
return Self.keyToEquivalent[key]
}
/// A convenience struct that has the key + modifiers for some keybinding.
struct KeyEquivalent: CustomStringConvertible {
let key: String
let modifiers: NSEvent.ModifierFlags
var description: String {
var key = self.key
// Note: the order below matters; it matches the ordering modifiers
// shown for macOS menu shortcut labels.
if modifiers.contains(.command) { key = "\(key)" }
if modifiers.contains(.shift) { key = "\(key)" }
if modifiers.contains(.option) { key = "\(key)" }
if modifiers.contains(.control) { key = "\(key)" }
return key
}
}
/// Return the key equivalent for the given trigger.
/// Return the keyboard shortcut for a trigger.
///
/// Returns nil if the trigger can't be processed. This should only happen for unknown trigger types
/// or keys.
static func keyEquivalent(for trigger: ghostty_input_trigger_s) -> KeyEquivalent? {
let equiv: String
/// Returns nil if the trigger doesn't have an equivalent KeyboardShortcut. This is possible
/// because Ghostty input triggers are a superset of what can be represented by a macOS
/// KeyboardShortcut. For example, macOS doesn't have any way to represent function keys
/// (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? {
let key: KeyEquivalent
switch (trigger.tag) {
case GHOSTTY_TRIGGER_TRANSLATED:
if let v = Ghostty.keyEquivalent(key: trigger.key.translated) {
equiv = v
key = v
} else {
return nil
}
case GHOSTTY_TRIGGER_PHYSICAL:
if let v = Ghostty.keyEquivalent(key: trigger.key.physical) {
equiv = v
key = v
} else {
return nil
}
case GHOSTTY_TRIGGER_UNICODE:
guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil }
equiv = String(scalar)
key = KeyEquivalent(Character(scalar))
default:
return nil
}
return KeyEquivalent(
key: equiv,
modifiers: Ghostty.eventModifierFlags(mods: trigger.mods)
)
return KeyboardShortcut(
key,
modifiers: EventModifiers(nsFlags: Ghostty.eventModifierFlags(mods: trigger.mods)))
}
// MARK: Mods
@@ -96,8 +82,10 @@ extension Ghostty {
return ghostty_input_mods_e(mods)
}
/// A map from the Ghostty key enum to the keyEquivalent string for shortcuts.
static let keyToEquivalent: [ghostty_input_key_e : String] = [
/// A map from the Ghostty key enum to the keyEquivalent string for shortcuts. Note that
/// not all ghostty key enum values are represented here because not all of them can be
/// mapped to a KeyEquivalent.
static let keyToEquivalent: [ghostty_input_key_e : KeyEquivalent] = [
// 0-9
GHOSTTY_KEY_ZERO: "0",
GHOSTTY_KEY_ONE: "1",
@@ -152,48 +140,19 @@ extension Ghostty {
GHOSTTY_KEY_SLASH: "/",
// Function keys
GHOSTTY_KEY_UP: "\u{F700}",
GHOSTTY_KEY_DOWN: "\u{F701}",
GHOSTTY_KEY_LEFT: "\u{F702}",
GHOSTTY_KEY_RIGHT: "\u{F703}",
GHOSTTY_KEY_HOME: "\u{F729}",
GHOSTTY_KEY_END: "\u{F72B}",
GHOSTTY_KEY_INSERT: "\u{F727}",
GHOSTTY_KEY_DELETE: "\u{F728}",
GHOSTTY_KEY_PAGE_UP: "\u{F72C}",
GHOSTTY_KEY_PAGE_DOWN: "\u{F72D}",
GHOSTTY_KEY_ESCAPE: "\u{1B}",
GHOSTTY_KEY_ENTER: "\r",
GHOSTTY_KEY_TAB: "\t",
GHOSTTY_KEY_BACKSPACE: "\u{7F}",
GHOSTTY_KEY_PRINT_SCREEN: "\u{F72E}",
GHOSTTY_KEY_PAUSE: "\u{F72F}",
GHOSTTY_KEY_F1: "\u{F704}",
GHOSTTY_KEY_F2: "\u{F705}",
GHOSTTY_KEY_F3: "\u{F706}",
GHOSTTY_KEY_F4: "\u{F707}",
GHOSTTY_KEY_F5: "\u{F708}",
GHOSTTY_KEY_F6: "\u{F709}",
GHOSTTY_KEY_F7: "\u{F70A}",
GHOSTTY_KEY_F8: "\u{F70B}",
GHOSTTY_KEY_F9: "\u{F70C}",
GHOSTTY_KEY_F10: "\u{F70D}",
GHOSTTY_KEY_F11: "\u{F70E}",
GHOSTTY_KEY_F12: "\u{F70F}",
GHOSTTY_KEY_F13: "\u{F710}",
GHOSTTY_KEY_F14: "\u{F711}",
GHOSTTY_KEY_F15: "\u{F712}",
GHOSTTY_KEY_F16: "\u{F713}",
GHOSTTY_KEY_F17: "\u{F714}",
GHOSTTY_KEY_F18: "\u{F715}",
GHOSTTY_KEY_F19: "\u{F716}",
GHOSTTY_KEY_F20: "\u{F717}",
GHOSTTY_KEY_F21: "\u{F718}",
GHOSTTY_KEY_F22: "\u{F719}",
GHOSTTY_KEY_F23: "\u{F71A}",
GHOSTTY_KEY_F24: "\u{F71B}",
GHOSTTY_KEY_F25: "\u{F71C}",
GHOSTTY_KEY_UP: .upArrow,
GHOSTTY_KEY_DOWN: .downArrow,
GHOSTTY_KEY_LEFT: .leftArrow,
GHOSTTY_KEY_RIGHT: .rightArrow,
GHOSTTY_KEY_HOME: .home,
GHOSTTY_KEY_END: .end,
GHOSTTY_KEY_DELETE: .delete,
GHOSTTY_KEY_PAGE_UP: .pageUp,
GHOSTTY_KEY_PAGE_DOWN: .pageDown,
GHOSTTY_KEY_ESCAPE: .escape,
GHOSTTY_KEY_ENTER: .return,
GHOSTTY_KEY_TAB: .tab,
GHOSTTY_KEY_BACKSPACE: .delete,
]
static let asciiToKey: [UInt8 : ghostty_input_key_e] = [

View File

@@ -43,7 +43,7 @@ extension Ghostty {
@Published var hoverUrl: String? = nil
// The currently active key sequence. The sequence is not active if this is empty.
@Published var keySequence: [Ghostty.KeyEquivalent] = []
@Published var keySequence: [KeyboardShortcut] = []
// The time this surface last became focused. This is a ContinuousClock.Instant
// on supported platforms.
@@ -526,7 +526,7 @@ extension Ghostty {
@objc private func ghosttyDidContinueKeySequence(notification: SwiftUI.Notification) {
guard let keyAny = notification.userInfo?[Ghostty.Notification.KeySequenceKey] else { return }
guard let key = keyAny as? Ghostty.KeyEquivalent else { return }
guard let key = keyAny as? KeyboardShortcut else { return }
DispatchQueue.main.async { [weak self] in
self?.keySequence.append(key)
}

View File

@@ -0,0 +1,27 @@
import SwiftUI
// MARK: EventModifiers to NSEvent and Back
extension EventModifiers {
init(nsFlags: NSEvent.ModifierFlags) {
var result: SwiftUI.EventModifiers = []
if nsFlags.contains(.shift) { result.insert(.shift) }
if nsFlags.contains(.control) { result.insert(.control) }
if nsFlags.contains(.option) { result.insert(.option) }
if nsFlags.contains(.command) { result.insert(.command) }
if nsFlags.contains(.capsLock) { result.insert(.capsLock) }
self = result
}
}
extension NSEvent.ModifierFlags {
init(swiftUIFlags: SwiftUI.EventModifiers) {
var result: NSEvent.ModifierFlags = []
if swiftUIFlags.contains(.shift) { result.insert(.shift) }
if swiftUIFlags.contains(.control) { result.insert(.control) }
if swiftUIFlags.contains(.option) { result.insert(.option) }
if swiftUIFlags.contains(.command) { result.insert(.command) }
if swiftUIFlags.contains(.capsLock) { result.insert(.capsLock) }
self = result
}
}

View File

@@ -0,0 +1,45 @@
import SwiftUI
extension KeyboardShortcut: @retroactive CustomStringConvertible {
public var description: String {
var result = ""
if modifiers.contains(.command) {
result.append("")
}
if modifiers.contains(.control) {
result.append("")
}
if modifiers.contains(.option) {
result.append("")
}
if modifiers.contains(.shift) {
result.append("")
}
let keyString: String
switch key {
case .return: keyString = ""
case .escape: keyString = ""
case .delete: keyString = ""
case .space: keyString = ""
case .tab: keyString = ""
case .upArrow: keyString = ""
case .downArrow: keyString = ""
case .leftArrow: keyString = ""
case .rightArrow: keyString = ""
default:
keyString = String(key.character)
}
result.append(keyString)
return result
}
}
// This is available in macOS 14 so this only applies to early macOS versions.
extension KeyEquivalent: @retroactive Equatable {
public static func == (lhs: KeyEquivalent, rhs: KeyEquivalent) -> Bool {
lhs.character == rhs.character
}
}