macos: input keyboard event can send modifiers and actions now

pull/7634/head
Mitchell Hashimoto 2025-06-19 12:06:27 -07:00
parent 93619ad420
commit 71b6e223af
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
4 changed files with 382 additions and 203 deletions

View File

@ -44,10 +44,25 @@ struct KeyEventIntent: AppIntent {
static var description = IntentDescription("Simulate a keyboard event. This will not handle text encoding; use the 'Input Text' action for that.") static var description = IntentDescription("Simulate a keyboard event. This will not handle text encoding; use the 'Input Text' action for that.")
@Parameter( @Parameter(
title: "Text", title: "Key",
description: "The key to send to the terminal." description: "The key to send to the terminal.",
default: .enter
) )
var key: Ghostty.Key var key: Ghostty.Input.Key
@Parameter(
title: "Modifier(s)",
description: "The modifiers to send with the key event.",
default: []
)
var mods: [KeyEventMods]
@Parameter(
title: "Event Type",
description: "A key press or release.",
default: .press
)
var action: Ghostty.Input.Action
@Parameter( @Parameter(
title: "Terminal", title: "Terminal",
@ -64,6 +79,45 @@ struct KeyEventIntent: AppIntent {
throw GhosttyIntentError.surfaceNotFound throw GhosttyIntentError.surfaceNotFound
} }
// Convert KeyEventMods array to Ghostty.Input.Mods
let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
result.union(mod.ghosttyMod)
}
let keyEvent = Ghostty.Input.KeyEvent(
key: key,
action: action,
mods: ghosttyMods
)
surface.sendKeyEvent(keyEvent)
return .result() return .result()
} }
} }
// MARK: Mods
enum KeyEventMods: String, AppEnum, CaseIterable {
case shift
case control
case option
case command
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Modifier Key")
static var caseDisplayRepresentations: [KeyEventMods : DisplayRepresentation] = [
.shift: "Shift",
.control: "Control",
.option: "Option",
.command: "Command"
]
var ghosttyMod: Ghostty.Input.Mods {
switch self {
case .shift: .shift
case .control: .ctrl
case .option: .alt
case .command: .super
}
}
}

View File

@ -4,6 +4,8 @@ import SwiftUI
import GhosttyKit import GhosttyKit
extension Ghostty { extension Ghostty {
struct Input {}
// MARK: Keyboard Shortcuts // MARK: Keyboard Shortcuts
/// Return the key equivalent for the given trigger. /// Return the key equivalent for the given trigger.
@ -92,7 +94,175 @@ extension Ghostty {
GHOSTTY_KEY_BACKSPACE: .delete, GHOSTTY_KEY_BACKSPACE: .delete,
GHOSTTY_KEY_SPACE: .space, GHOSTTY_KEY_SPACE: .space,
] ]
}
// MARK: Ghostty.Input.KeyEvent
extension Ghostty.Input {
/// `ghostty_input_key_s`
struct KeyEvent {
let action: Action
let key: Key
let text: String?
let composing: Bool
let mods: Mods
let consumedMods: Mods
let unshiftedCodepoint: UInt32
init(
key: Key,
action: Action = .press,
text: String? = nil,
composing: Bool = false,
mods: Mods = [],
consumedMods: Mods = [],
unshiftedCodepoint: UInt32 = 0
) {
self.key = key
self.action = action
self.text = text
self.composing = composing
self.mods = mods
self.consumedMods = consumedMods
self.unshiftedCodepoint = unshiftedCodepoint
}
init?(cValue: ghostty_input_key_s) {
// Convert action
switch cValue.action {
case GHOSTTY_ACTION_PRESS: self.action = .press
case GHOSTTY_ACTION_RELEASE: self.action = .release
case GHOSTTY_ACTION_REPEAT: self.action = .repeat
default: self.action = .press
}
// Convert key from keycode
guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil }
self.key = key
// Convert text
if let textPtr = cValue.text {
self.text = String(cString: textPtr)
} else {
self.text = nil
}
// Set composing state
self.composing = cValue.composing
// Convert modifiers
self.mods = Mods(cMods: cValue.mods)
self.consumedMods = Mods(cMods: cValue.consumed_mods)
// Set unshifted codepoint
self.unshiftedCodepoint = cValue.unshifted_codepoint
}
/// Executes a closure with a temporary C representation of this KeyEvent.
///
/// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct
/// and passes it to the provided closure. The C struct is only valid within the closure's
/// execution scope. The text field's C string pointer is managed automatically and will
/// be invalid after the closure returns.
///
/// - Parameter execute: A closure that receives the C struct and returns a value
/// - Returns: The value returned by the closure
@discardableResult
func withCValue<T>(execute: (ghostty_input_key_s) -> T) -> T {
var keyEvent = ghostty_input_key_s()
keyEvent.action = action.cAction
keyEvent.keycode = UInt32(key.keyCode ?? 0)
keyEvent.composing = composing
keyEvent.mods = mods.cMods
keyEvent.consumed_mods = consumedMods.cMods
keyEvent.unshifted_codepoint = unshiftedCodepoint
// Handle text with proper memory management
if let text = text {
return text.withCString { textPtr in
keyEvent.text = textPtr
return execute(keyEvent)
}
} else {
keyEvent.text = nil
return execute(keyEvent)
}
}
}
}
// MARK: Ghostty.Input.Action
extension Ghostty.Input {
/// `ghostty_input_action_e`
enum Action: String, CaseIterable {
case release
case press
case `repeat`
var cAction: ghostty_input_action_e {
switch self {
case .release: GHOSTTY_ACTION_RELEASE
case .press: GHOSTTY_ACTION_PRESS
case .repeat: GHOSTTY_ACTION_REPEAT
}
}
}
}
extension Ghostty.Input.Action: AppEnum {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Key Action")
static var caseDisplayRepresentations: [Ghostty.Input.Action : DisplayRepresentation] = [
.release: "Release",
.press: "Press",
.repeat: "Repeat"
]
}
// MARK: Ghostty.Input.Mods
extension Ghostty.Input {
/// `ghostty_input_mods_e`
struct Mods: OptionSet {
let rawValue: UInt32
static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue)
static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue)
static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue)
static let alt = Mods(rawValue: GHOSTTY_MODS_ALT.rawValue)
static let `super` = Mods(rawValue: GHOSTTY_MODS_SUPER.rawValue)
static let caps = Mods(rawValue: GHOSTTY_MODS_CAPS.rawValue)
static let shiftRight = Mods(rawValue: GHOSTTY_MODS_SHIFT_RIGHT.rawValue)
static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue)
static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue)
static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue)
var cMods: ghostty_input_mods_e {
ghostty_input_mods_e(rawValue)
}
init(rawValue: UInt32) {
self.rawValue = rawValue
}
init(cMods: ghostty_input_mods_e) {
self.rawValue = cMods.rawValue
}
init(nsFlags: NSEvent.ModifierFlags) {
self.init(cMods: Ghostty.ghosttyMods(nsFlags))
}
var nsFlags: NSEvent.ModifierFlags {
Ghostty.eventModifierFlags(mods: cMods)
}
}
}
// MARK: Ghostty.Input.Key
extension Ghostty.Input {
/// `ghostty_input_key_e` /// `ghostty_input_key_e`
enum Key: String { enum Key: String {
// Writing System Keys // Writing System Keys
@ -689,201 +859,142 @@ extension Ghostty {
} }
} }
// MARK: Ghostty.Key AppEnum extension Ghostty.Input.Key: AppEnum {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Key")
extension Ghostty.Key: AppEnum { // Only include keys that have Mac keycodes for App Intents
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Key" static var allCases: [Ghostty.Input.Key] {
return [
// Letters (A-Z)
.a, .b, .c, .d, .e, .f, .g, .h, .i, .j, .k, .l, .m, .n, .o, .p, .q, .r, .s, .t, .u, .v, .w, .x, .y, .z,
static var caseDisplayRepresentations: [Ghostty.Key : DisplayRepresentation] = [ // Numbers (0-9)
// Writing System Keys .digit0, .digit1, .digit2, .digit3, .digit4, .digit5, .digit6, .digit7, .digit8, .digit9,
.backquote: "Backtick (`)",
.backslash: "Backslash (\\)",
.bracketLeft: "Left Bracket ([)",
.bracketRight: "Right Bracket (])",
.comma: "Comma (,)",
.digit0: "0",
.digit1: "1",
.digit2: "2",
.digit3: "3",
.digit4: "4",
.digit5: "5",
.digit6: "6",
.digit7: "7",
.digit8: "8",
.digit9: "9",
.equal: "Equal (=)",
.intlBackslash: "International Backslash",
.intlRo: "International Ro",
.intlYen: "International Yen",
.a: "A",
.b: "B",
.c: "C",
.d: "D",
.e: "E",
.f: "F",
.g: "G",
.h: "H",
.i: "I",
.j: "J",
.k: "K",
.l: "L",
.m: "M",
.n: "N",
.o: "O",
.p: "P",
.q: "Q",
.r: "R",
.s: "S",
.t: "T",
.u: "U",
.v: "V",
.w: "W",
.x: "X",
.y: "Y",
.z: "Z",
.minus: "Minus (-)",
.period: "Period (.)",
.quote: "Quote (')",
.semicolon: "Semicolon (;)",
.slash: "Slash (/)",
// Functional Keys // Common Control Keys
.altLeft: "Left Alt", .space, .enter, .tab, .backspace, .escape, .delete,
.altRight: "Right Alt",
.backspace: "Backspace", // Arrow Keys
.capsLock: "Caps Lock", .arrowUp, .arrowDown, .arrowLeft, .arrowRight,
.contextMenu: "Context Menu",
.controlLeft: "Left Control", // Navigation Keys
.controlRight: "Right Control", .home, .end, .pageUp, .pageDown, .insert,
.enter: "Enter",
.metaLeft: "Left Command", // Function Keys (F1-F20)
.metaRight: "Right Command", .f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, .f11, .f12,
.shiftLeft: "Left Shift", .f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20,
.shiftRight: "Right Shift",
// Modifier Keys
.shiftLeft, .shiftRight, .controlLeft, .controlRight, .altLeft, .altRight,
.metaLeft, .metaRight, .capsLock,
// Punctuation & Symbols
.minus, .equal, .backquote, .bracketLeft, .bracketRight, .backslash,
.semicolon, .quote, .comma, .period, .slash,
// Numpad
.numLock, .numpad0, .numpad1, .numpad2, .numpad3, .numpad4, .numpad5,
.numpad6, .numpad7, .numpad8, .numpad9, .numpadAdd, .numpadSubtract,
.numpadMultiply, .numpadDivide, .numpadDecimal, .numpadEqual,
.numpadEnter, .numpadComma,
// Media Keys
.audioVolumeUp, .audioVolumeDown, .audioVolumeMute,
// International Keys
.intlBackslash, .intlRo, .intlYen,
// Other
.contextMenu
]
}
static var caseDisplayRepresentations: [Ghostty.Input.Key : DisplayRepresentation] = [
// Letters (A-Z)
.a: "A", .b: "B", .c: "C", .d: "D", .e: "E", .f: "F", .g: "G", .h: "H", .i: "I", .j: "J",
.k: "K", .l: "L", .m: "M", .n: "N", .o: "O", .p: "P", .q: "Q", .r: "R", .s: "S", .t: "T",
.u: "U", .v: "V", .w: "W", .x: "X", .y: "Y", .z: "Z",
// Numbers (0-9)
.digit0: "0", .digit1: "1", .digit2: "2", .digit3: "3", .digit4: "4",
.digit5: "5", .digit6: "6", .digit7: "7", .digit8: "8", .digit9: "9",
// Common Control Keys
.space: "Space", .space: "Space",
.enter: "Enter",
.tab: "Tab", .tab: "Tab",
.convert: "Convert", .backspace: "Backspace",
.kanaMode: "Kana Mode", .escape: "Escape",
.nonConvert: "Non Convert",
// Control Pad Section
.delete: "Delete", .delete: "Delete",
.end: "End",
.help: "Help",
.home: "Home",
.insert: "Insert",
.pageDown: "Page Down",
.pageUp: "Page Up",
// Arrow Pad Section // Arrow Keys
.arrowUp: "Up Arrow",
.arrowDown: "Down Arrow", .arrowDown: "Down Arrow",
.arrowLeft: "Left Arrow", .arrowLeft: "Left Arrow",
.arrowRight: "Right Arrow", .arrowRight: "Right Arrow",
.arrowUp: "Up Arrow",
// Numpad Section // Navigation Keys
.home: "Home",
.end: "End",
.pageUp: "Page Up",
.pageDown: "Page Down",
.insert: "Insert",
// Function Keys (F1-F20)
.f1: "F1", .f2: "F2", .f3: "F3", .f4: "F4", .f5: "F5", .f6: "F6",
.f7: "F7", .f8: "F8", .f9: "F9", .f10: "F10", .f11: "F11", .f12: "F12",
.f13: "F13", .f14: "F14", .f15: "F15", .f16: "F16", .f17: "F17",
.f18: "F18", .f19: "F19", .f20: "F20",
// Modifier Keys
.shiftLeft: "Left Shift",
.shiftRight: "Right Shift",
.controlLeft: "Left Control",
.controlRight: "Right Control",
.altLeft: "Left Alt",
.altRight: "Right Alt",
.metaLeft: "Left Command",
.metaRight: "Right Command",
.capsLock: "Caps Lock",
// Punctuation & Symbols
.minus: "Minus (-)",
.equal: "Equal (=)",
.backquote: "Backtick (`)",
.bracketLeft: "Left Bracket ([)",
.bracketRight: "Right Bracket (])",
.backslash: "Backslash (\\)",
.semicolon: "Semicolon (;)",
.quote: "Quote (')",
.comma: "Comma (,)",
.period: "Period (.)",
.slash: "Slash (/)",
// Numpad
.numLock: "Num Lock", .numLock: "Num Lock",
.numpad0: "Numpad 0", .numpad0: "Numpad 0", .numpad1: "Numpad 1", .numpad2: "Numpad 2",
.numpad1: "Numpad 1", .numpad3: "Numpad 3", .numpad4: "Numpad 4", .numpad5: "Numpad 5",
.numpad2: "Numpad 2", .numpad6: "Numpad 6", .numpad7: "Numpad 7", .numpad8: "Numpad 8", .numpad9: "Numpad 9",
.numpad3: "Numpad 3",
.numpad4: "Numpad 4",
.numpad5: "Numpad 5",
.numpad6: "Numpad 6",
.numpad7: "Numpad 7",
.numpad8: "Numpad 8",
.numpad9: "Numpad 9",
.numpadAdd: "Numpad Add (+)", .numpadAdd: "Numpad Add (+)",
.numpadBackspace: "Numpad Backspace",
.numpadClear: "Numpad Clear",
.numpadClearEntry: "Numpad Clear Entry",
.numpadComma: "Numpad Comma",
.numpadDecimal: "Numpad Decimal",
.numpadDivide: "Numpad Divide (÷)",
.numpadEnter: "Numpad Enter",
.numpadEqual: "Numpad Equal",
.numpadMemoryAdd: "Numpad Memory Add",
.numpadMemoryClear: "Numpad Memory Clear",
.numpadMemoryRecall: "Numpad Memory Recall",
.numpadMemoryStore: "Numpad Memory Store",
.numpadMemorySubtract: "Numpad Memory Subtract",
.numpadMultiply: "Numpad Multiply (×)",
.numpadParenLeft: "Numpad Left Parenthesis",
.numpadParenRight: "Numpad Right Parenthesis",
.numpadSubtract: "Numpad Subtract (-)", .numpadSubtract: "Numpad Subtract (-)",
.numpadSeparator: "Numpad Separator", .numpadMultiply: "Numpad Multiply (×)",
.numpadUp: "Numpad Up", .numpadDivide: "Numpad Divide (÷)",
.numpadDown: "Numpad Down", .numpadDecimal: "Numpad Decimal",
.numpadRight: "Numpad Right", .numpadEqual: "Numpad Equal",
.numpadLeft: "Numpad Left", .numpadEnter: "Numpad Enter",
.numpadBegin: "Numpad Begin", .numpadComma: "Numpad Comma",
.numpadHome: "Numpad Home",
.numpadEnd: "Numpad End",
.numpadInsert: "Numpad Insert",
.numpadDelete: "Numpad Delete",
.numpadPageUp: "Numpad Page Up",
.numpadPageDown: "Numpad Page Down",
// Function Section
.escape: "Escape",
.f1: "F1",
.f2: "F2",
.f3: "F3",
.f4: "F4",
.f5: "F5",
.f6: "F6",
.f7: "F7",
.f8: "F8",
.f9: "F9",
.f10: "F10",
.f11: "F11",
.f12: "F12",
.f13: "F13",
.f14: "F14",
.f15: "F15",
.f16: "F16",
.f17: "F17",
.f18: "F18",
.f19: "F19",
.f20: "F20",
.f21: "F21",
.f22: "F22",
.f23: "F23",
.f24: "F24",
.f25: "F25",
.fn: "Fn",
.fnLock: "Fn Lock",
.printScreen: "Print Screen",
.scrollLock: "Scroll Lock",
.pause: "Pause",
// Media Keys // Media Keys
.browserBack: "Browser Back", .audioVolumeUp: "Volume Up",
.browserFavorites: "Browser Favorites",
.browserForward: "Browser Forward",
.browserHome: "Browser Home",
.browserRefresh: "Browser Refresh",
.browserSearch: "Browser Search",
.browserStop: "Browser Stop",
.eject: "Eject",
.launchApp1: "Launch App 1",
.launchApp2: "Launch App 2",
.launchMail: "Launch Mail",
.mediaPlayPause: "Media Play/Pause",
.mediaSelect: "Media Select",
.mediaStop: "Media Stop",
.mediaTrackNext: "Media Next Track",
.mediaTrackPrevious: "Media Previous Track",
.power: "Power",
.sleep: "Sleep",
.audioVolumeDown: "Volume Down", .audioVolumeDown: "Volume Down",
.audioVolumeMute: "Volume Mute", .audioVolumeMute: "Volume Mute",
.audioVolumeUp: "Volume Up",
.wakeUp: "Wake Up",
// Legacy, Non-standard, and Special Keys // International Keys
.copy: "Copy", .intlBackslash: "International Backslash",
.cut: "Cut", .intlRo: "International Ro",
.paste: "Paste" .intlYen: "International Yen",
// Other
.contextMenu: "Context Menu"
] ]
} }

View File

@ -48,6 +48,20 @@ extension Ghostty {
} }
} }
/// Send a key event to the terminal.
///
/// This sends the full key event including modifiers, action type, and text to the terminal.
/// Unlike `sendText`, this method processes keyboard shortcuts, key bindings, and terminal
/// encoding based on the complete key event information.
///
/// - Parameter event: The key event to send to the terminal
@MainActor
func sendKeyEvent(_ event: Input.KeyEvent) {
event.withCValue { cEvent in
ghostty_surface_key(surface, cEvent)
}
}
/// Perform a keybinding action. /// Perform a keybinding action.
/// ///
/// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4` /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4`

View File

@ -337,7 +337,7 @@ extension Ghostty {
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) {
guard let inspector = self.inspector else { return } guard let inspector = self.inspector else { return }
guard let key = Ghostty.Key(keyCode: event.keyCode) else { return } guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags) let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_key(inspector, action, key.cKey, mods) ghostty_inspector_key(inspector, action, key.cKey, mods)
} }