diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b69541504..5d02ba12b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; + A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EventModifiers+Extension.swift"; sourceTree = ""; }; + A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcut+Extension.swift"; sourceTree = ""; }; A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = ""; }; A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = ""; }; @@ -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 */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 9d866d734..94626f808 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -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? { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f54eb6539..f384b97ed 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -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 = "" diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index dfd066870..d7fd0c777 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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 { diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index d146477dc..d7be4eb5b 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -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 diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 0a279ea1f..cb4fdc451 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -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] = [ diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 72a324525..5985d64a0 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -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) } diff --git a/macos/Sources/Helpers/EventModifiers+Extension.swift b/macos/Sources/Helpers/EventModifiers+Extension.swift new file mode 100644 index 000000000..8d379bd99 --- /dev/null +++ b/macos/Sources/Helpers/EventModifiers+Extension.swift @@ -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 + } +} diff --git a/macos/Sources/Helpers/KeyboardShortcut+Extension.swift b/macos/Sources/Helpers/KeyboardShortcut+Extension.swift new file mode 100644 index 000000000..b953f5755 --- /dev/null +++ b/macos/Sources/Helpers/KeyboardShortcut+Extension.swift @@ -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 + } +}