mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-05 19:08:17 +00:00
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:
@@ -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 */,
|
||||
|
@@ -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? {
|
||||
|
@@ -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 = ""
|
||||
|
@@ -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 {
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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] = [
|
||||
|
@@ -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)
|
||||
}
|
||||
|
27
macos/Sources/Helpers/EventModifiers+Extension.swift
Normal file
27
macos/Sources/Helpers/EventModifiers+Extension.swift
Normal 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
|
||||
}
|
||||
}
|
45
macos/Sources/Helpers/KeyboardShortcut+Extension.swift
Normal file
45
macos/Sources/Helpers/KeyboardShortcut+Extension.swift
Normal 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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user