macos: add AppleScript commands for text input, key, and mouse events

Add five new AppleScript commands to Ghostty.sdef mirroring the existing
App Intents for terminal input:

- `input text`: send text to a terminal as if pasted
- `send key`: simulate a keyboard event with optional action and modifiers
- `send mouse button`: send a mouse button press/release event
- `send mouse position`: send a mouse cursor position event
- `send mouse scroll`: send a scroll event with precision and momentum

A shared `input action` enumeration (press/release) is used by both key
and mouse button commands. Modifier keys are passed as a comma-separated
string parameter (shift, control, option, command).
This commit is contained in:
Mitchell Hashimoto
2026-03-05 21:03:45 -08:00
parent 1742aeda50
commit fd5ad1f574
7 changed files with 448 additions and 0 deletions

View File

@@ -58,6 +58,98 @@
<cocoa key="terminal"/>
</parameter>
</command>
<command name="input text" code="GhstInTx" description="Input text to a terminal as if it was pasted.">
<cocoa class="GhosttyScriptInputTextCommand"/>
<direct-parameter type="text" description="The text to input."/>
<parameter name="to" code="GItT" type="terminal" description="The terminal to input text to.">
<cocoa key="terminal"/>
</parameter>
</command>
<enumeration name="input action" code="GIAc" description="Whether an input is pressed or released.">
<enumerator name="press" code="GIpr" description="Press."/>
<enumerator name="release" code="GIrl" description="Release."/>
</enumeration>
<command name="send key" code="GhstSKey" description="Send a keyboard event to a terminal.">
<cocoa class="GhosttyScriptKeyEventCommand"/>
<direct-parameter type="text" description="The key name (e.g. &quot;enter&quot;, &quot;a&quot;, &quot;space&quot;)."/>
<parameter name="action" code="GKeA" type="input action" optional="yes" description="Press or release (default: press).">
<cocoa key="action"/>
</parameter>
<parameter name="modifiers" code="GKeM" type="text" optional="yes" description="Comma-separated modifier keys: shift, control, option, command.">
<cocoa key="modifiers"/>
</parameter>
<parameter name="to" code="GKeT" type="terminal" description="The terminal to send the key event to.">
<cocoa key="terminal"/>
</parameter>
</command>
<enumeration name="mouse button" code="GMBt" description="A mouse button.">
<enumerator name="left button" code="GMlf" description="Left mouse button."/>
<enumerator name="right button" code="GMrt" description="Right mouse button."/>
<enumerator name="middle button" code="GMmd" description="Middle mouse button."/>
</enumeration>
<command name="send mouse button" code="GhstSMBt" description="Send a mouse button event to a terminal.">
<cocoa class="GhosttyScriptMouseButtonCommand"/>
<direct-parameter type="mouse button" description="The mouse button."/>
<parameter name="action" code="GMbA" type="input action" optional="yes" description="Press or release (default: press).">
<cocoa key="action"/>
</parameter>
<parameter name="modifiers" code="GMbM" type="text" optional="yes" description="Comma-separated modifier keys: shift, control, option, command.">
<cocoa key="modifiers"/>
</parameter>
<parameter name="to" code="GMbT" type="terminal" description="The terminal to send the event to.">
<cocoa key="terminal"/>
</parameter>
</command>
<command name="send mouse position" code="GhstSMPs" description="Send a mouse position event to a terminal.">
<cocoa class="GhosttyScriptMousePosCommand"/>
<parameter name="x" code="GMpX" type="real" description="Horizontal position in pixels.">
<cocoa key="x"/>
</parameter>
<parameter name="y" code="GMpY" type="real" description="Vertical position in pixels.">
<cocoa key="y"/>
</parameter>
<parameter name="modifiers" code="GMpM" type="text" optional="yes" description="Comma-separated modifier keys: shift, control, option, command.">
<cocoa key="modifiers"/>
</parameter>
<parameter name="to" code="GMpT" type="terminal" description="The terminal to send the event to.">
<cocoa key="terminal"/>
</parameter>
</command>
<enumeration name="scroll momentum" code="GSMo" description="Momentum phase for inertial scrolling.">
<enumerator name="none" code="SMno" description="No momentum."/>
<enumerator name="began" code="SMbg" description="Momentum began."/>
<enumerator name="changed" code="SMch" description="Momentum changed."/>
<enumerator name="ended" code="SMen" description="Momentum ended."/>
<enumerator name="cancelled" code="SMcn" description="Momentum cancelled."/>
<enumerator name="may begin" code="SMmb" description="Momentum may begin."/>
<enumerator name="stationary" code="SMst" description="Stationary."/>
</enumeration>
<command name="send mouse scroll" code="GhstSMSc" description="Send a mouse scroll event to a terminal.">
<cocoa class="GhosttyScriptMouseScrollCommand"/>
<parameter name="x" code="GMsX" type="real" description="Horizontal scroll delta.">
<cocoa key="x"/>
</parameter>
<parameter name="y" code="GMsY" type="real" description="Vertical scroll delta.">
<cocoa key="y"/>
</parameter>
<parameter name="precision" code="GMsP" type="boolean" optional="yes" description="High-precision scroll (e.g. trackpad). Default: false.">
<cocoa key="precision"/>
</parameter>
<parameter name="momentum" code="GMsM" type="scroll momentum" optional="yes" description="Momentum phase for inertial scrolling. Default: none.">
<cocoa key="momentum"/>
</parameter>
<parameter name="to" code="GMsT" type="terminal" description="The terminal to send the event to.">
<cocoa key="terminal"/>
</parameter>
</command>
</suite>
<!--

View File

@@ -0,0 +1,18 @@
extension Ghostty.Input.Mods {
/// Parses a comma-separated modifier string into `Ghostty.Input.Mods`.
///
/// Recognized names: `shift`, `control`, `option`, `command`.
/// Returns `nil` if any unrecognized modifier name is encountered.
init?(scriptModifiers string: String) {
self = []
for part in string.split(separator: ",") {
switch part.trimmingCharacters(in: .whitespaces).lowercased() {
case "shift": insert(.shift)
case "control": insert(.ctrl)
case "option": insert(.alt)
case "command": insert(.super)
default: return nil
}
}
}
}

View File

@@ -0,0 +1,39 @@
import AppKit
/// Handler for the `input text` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptInputTextCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@objc(GhosttyScriptInputTextCommand)
final class ScriptInputTextCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard let text = directParameter as? String else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing text to input."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
return nil
}
guard let surfaceView = terminal.surfaceView else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let surface = surfaceView.surfaceModel else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface model is not available."
return nil
}
surface.sendText(text)
return nil
}
}

View File

@@ -0,0 +1,74 @@
import AppKit
/// Handler for the `send key` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptKeyEventCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@objc(GhosttyScriptKeyEventCommand)
final class ScriptKeyEventCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard let keyName = directParameter as? String else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing key name."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
return nil
}
guard let surfaceView = terminal.surfaceView else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let surface = surfaceView.surfaceModel else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface model is not available."
return nil
}
guard let key = Ghostty.Input.Key(rawValue: keyName) else {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown key name: \(keyName)"
return nil
}
let action: Ghostty.Input.Action
if let actionCode = evaluatedArguments?["action"] as? UInt32 {
switch actionCode {
case "GIpr".fourCharCode: action = .press
case "GIrl".fourCharCode: action = .release
default: action = .press
}
} else {
action = .press
}
let mods: Ghostty.Input.Mods
if let modsString = evaluatedArguments?["modifiers"] as? String {
guard let parsed = Ghostty.Input.Mods(scriptModifiers: modsString) else {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown modifier in: \(modsString)"
return nil
}
mods = parsed
} else {
mods = []
}
let keyEvent = Ghostty.Input.KeyEvent(
key: key,
action: action,
mods: mods
)
surface.sendKeyEvent(keyEvent)
return nil
}
}

View File

@@ -0,0 +1,93 @@
import AppKit
/// Handler for the `send mouse button` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptMouseButtonCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@objc(GhosttyScriptMouseButtonCommand)
final class ScriptMouseButtonCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard let buttonCode = directParameter as? UInt32,
let button = ScriptMouseButtonValue(code: buttonCode) else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing or unknown mouse button."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
return nil
}
guard let surfaceView = terminal.surfaceView else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let surface = surfaceView.surfaceModel else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface model is not available."
return nil
}
let action: Ghostty.Input.MouseState
if let actionCode = evaluatedArguments?["action"] as? UInt32 {
switch actionCode {
case "GIpr".fourCharCode: action = .press
case "GIrl".fourCharCode: action = .release
default: action = .press
}
} else {
action = .press
}
let mods: Ghostty.Input.Mods
if let modsString = evaluatedArguments?["modifiers"] as? String {
guard let parsed = Ghostty.Input.Mods(scriptModifiers: modsString) else {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown modifier in: \(modsString)"
return nil
}
mods = parsed
} else {
mods = []
}
let mouseEvent = Ghostty.Input.MouseButtonEvent(
action: action,
button: button.ghosttyButton,
mods: mods
)
surface.sendMouseButton(mouseEvent)
return nil
}
}
/// Four-character codes matching the `mouse button` enumeration in `Ghostty.sdef`.
private enum ScriptMouseButtonValue {
case left
case right
case middle
init?(code: UInt32) {
switch code {
case "GMlf".fourCharCode: self = .left
case "GMrt".fourCharCode: self = .right
case "GMmd".fourCharCode: self = .middle
default: return nil
}
}
var ghosttyButton: Ghostty.Input.MouseButton {
switch self {
case .left: .left
case .right: .right
case .middle: .middle
}
}
}

View File

@@ -0,0 +1,63 @@
import AppKit
/// Handler for the `send mouse position` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptMousePosCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@objc(GhosttyScriptMousePosCommand)
final class ScriptMousePosCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard let x = evaluatedArguments?["x"] as? Double else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing x position."
return nil
}
guard let y = evaluatedArguments?["y"] as? Double else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing y position."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
return nil
}
guard let surfaceView = terminal.surfaceView else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let surface = surfaceView.surfaceModel else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface model is not available."
return nil
}
let mods: Ghostty.Input.Mods
if let modsString = evaluatedArguments?["modifiers"] as? String {
guard let parsed = Ghostty.Input.Mods(scriptModifiers: modsString) else {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown modifier in: \(modsString)"
return nil
}
mods = parsed
} else {
mods = []
}
let mousePosEvent = Ghostty.Input.MousePosEvent(
x: x,
y: y,
mods: mods
)
surface.sendMousePos(mousePosEvent)
return nil
}
}

View File

@@ -0,0 +1,69 @@
import AppKit
/// Handler for the `send mouse scroll` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptMouseScrollCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@objc(GhosttyScriptMouseScrollCommand)
final class ScriptMouseScrollCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard let x = evaluatedArguments?["x"] as? Double else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing x scroll delta."
return nil
}
guard let y = evaluatedArguments?["y"] as? Double else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing y scroll delta."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
return nil
}
guard let surfaceView = terminal.surfaceView else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let surface = surfaceView.surfaceModel else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface model is not available."
return nil
}
let precision = evaluatedArguments?["precision"] as? Bool ?? false
let momentum: Ghostty.Input.Momentum
if let momentumCode = evaluatedArguments?["momentum"] as? UInt32 {
switch momentumCode {
case "SMno".fourCharCode: momentum = .none
case "SMbg".fourCharCode: momentum = .began
case "SMch".fourCharCode: momentum = .changed
case "SMen".fourCharCode: momentum = .ended
case "SMcn".fourCharCode: momentum = .cancelled
case "SMmb".fourCharCode: momentum = .mayBegin
case "SMst".fourCharCode: momentum = .stationary
default: momentum = .none
}
} else {
momentum = .none
}
let scrollEvent = Ghostty.Input.MouseScrollEvent(
x: x,
y: y,
mods: .init(precision: precision, momentum: momentum)
)
surface.sendMouseScroll(scrollEvent)
return nil
}
}