macos: use value-style AppleScript surface configuration records

Add a `surface configuration` record type to the scripting dictionary,
implement `new surface configuration` (with optional copy-from), and allow
`new window` to accept `with configuration`.
This commit is contained in:
Mitchell Hashimoto
2026-03-06 11:17:08 -08:00
parent 959c2f51ac
commit a3adeb0166
4 changed files with 232 additions and 1 deletions

View File

@@ -16,6 +16,9 @@
<responds-to command="new window">
<cocoa method="handleNewWindowScriptCommand:"/>
</responds-to>
<responds-to command="new surface configuration">
<cocoa method="handleNewSurfaceConfigurationScriptCommand:"/>
</responds-to>
<responds-to command="quit">
<cocoa method="handleQuitScriptCommand:"/>
</responds-to>
@@ -29,6 +32,28 @@
</element>
</class>
<record-type name="surface configuration" code="GScf" description="Reusable settings applied when creating a terminal surface.">
<property name="font size" code="GScF" type="real" description="Font size in points.">
<cocoa key="fontSize"/>
</property>
<property name="working directory" code="GScD" type="text" description="Working directory for the terminal process.">
<cocoa key="workingDirectory"/>
</property>
<property name="command" code="GScC" type="text" description="Command to execute instead of the configured shell.">
<cocoa key="command"/>
</property>
<property name="initial input" code="GScI" type="text" description="Input sent to the terminal after launch.">
<cocoa key="initialInput"/>
</property>
<property name="wait after command" code="GScW" type="boolean" description="Keep the terminal open after command exit.">
<cocoa key="waitAfterCommand"/>
</property>
<property name="environment variables" code="GScE" description="Environment variables in KEY=VALUE format.">
<type type="text" list="yes"/>
<cocoa key="environmentVariables"/>
</property>
</record-type>
<class name="window" code="Gwnd" plural="windows" description="A Ghostty window containing one or more tabs.">
<cocoa class="GhosttyScriptWindow"/>
<property name="id" code="ID " type="text" access="r" description="Stable ID for this window."/>
@@ -74,7 +99,17 @@
<result type="boolean" description="True when the action was performed."/>
</command>
<command name="new surface configuration" code="GhstNSCf" description="Create a reusable surface configuration object.">
<parameter name="from" code="GScS" type="surface configuration" optional="yes" description="Surface configuration to copy.">
<cocoa key="configuration"/>
</parameter>
<result type="surface configuration" description="The newly created surface configuration."/>
</command>
<command name="new window" code="GhstNWin" description="Create a new Ghostty window.">
<parameter name="with configuration" code="GNwS" type="surface configuration" optional="yes" description="Base surface configuration for the initial terminal.">
<cocoa key="configuration"/>
</parameter>
<result type="window" description="The newly created window."/>
</command>

View File

@@ -126,11 +126,28 @@ extension NSApplication {
return NSNumber(value: terminal.perform(action: action))
}
/// Handler for creating a reusable AppleScript surface configuration object.
@objc(handleNewSurfaceConfigurationScriptCommand:)
func handleNewSurfaceConfigurationScriptCommand(_ command: NSScriptCommand) -> Any? {
do {
let configuration = try Ghostty.SurfaceConfiguration(
scriptRecord: command.evaluatedArguments?["configuration"] as? NSDictionary
)
return configuration.dictionaryRepresentation
} catch {
command.scriptErrorNumber = errAECoercionFail
command.scriptErrorString = error.localizedDescription
return nil
}
}
/// Handler for the `new window` AppleScript command.
///
/// Required selector name from the command in `sdef`:
/// `handleNewWindowScriptCommand:`.
///
/// Accepts an optional reusable surface configuration object.
///
/// Returns the newly created scripting window object.
@objc(handleNewWindowScriptCommand:)
func handleNewWindowScriptCommand(_ command: NSScriptCommand) -> Any? {
@@ -140,7 +157,21 @@ extension NSApplication {
return nil
}
let controller = TerminalController.newWindow(appDelegate.ghostty)
let baseConfig: Ghostty.SurfaceConfiguration
do {
baseConfig = try Ghostty.SurfaceConfiguration(
scriptRecord: command.evaluatedArguments?["configuration"] as? NSDictionary
)
} catch {
command.scriptErrorNumber = errAECoercionFail
command.scriptErrorString = error.localizedDescription
return nil
}
let controller = TerminalController.newWindow(
appDelegate.ghostty,
withBaseConfig: baseConfig
)
let createdWindowID = ScriptWindow.stableID(primaryController: controller)
if let scriptWindow = scriptWindows.first(where: { $0.stableID == createdWindowID }) {

View File

@@ -0,0 +1,29 @@
import Cocoa
/// Protocol to more easily implement AppleScript records in Swift.
protocol ScriptRecord {
/// Initialize a default record.
init()
/// Initialize a record from the raw value from AppleScript.
init(scriptRecord: NSDictionary?) throws
/// Encode into the dictionary form for AppleScript.
var dictionaryRepresentation: NSDictionary { get }
}
/// An error that can be thrown by `ScriptRecord.init(scriptRecord:)`. Any localized error
/// can be thrown but this is a common one.
enum RecordParseError: LocalizedError {
case invalidType(parameter: String, expected: String)
case invalidValue(parameter: String, message: String)
var errorDescription: String? {
switch self {
case .invalidType(let parameter, let expected):
return "\(parameter) must be \(expected)."
case .invalidValue(let parameter, let message):
return "\(parameter) \(message)."
}
}
}

View File

@@ -0,0 +1,136 @@
import Foundation
/// AppleScript record support for `Ghostty.SurfaceConfiguration`.
///
/// This keeps scripting conversion at the data-structure boundary so AppleScript
/// can pass records by value (`new surface configuration`, assign, copy, mutate)
/// without introducing an additional wrapper type.
extension Ghostty.SurfaceConfiguration: ScriptRecord {
init(scriptRecord source: NSDictionary?) throws {
self.init()
guard let raw = source as? [String: Any] else {
throw RecordParseError.invalidType(parameter: "configuration", expected: "a surface configuration record")
}
if let rawFontSize = raw["fontSize"] {
guard let number = rawFontSize as? NSNumber else {
throw RecordParseError.invalidType(parameter: "font size", expected: "a number")
}
let value = number.doubleValue
guard value.isFinite else {
throw RecordParseError.invalidValue(parameter: "font size", message: "must be a finite number")
}
if value < 0 {
throw RecordParseError.invalidValue(parameter: "font size", message: "must be a positive number")
}
if value > 0 {
fontSize = Float32(value)
}
}
if let rawWorkingDirectory = raw["workingDirectory"] {
guard let workingDirectory = rawWorkingDirectory as? String else {
throw RecordParseError.invalidType(parameter: "working directory", expected: "text")
}
if !workingDirectory.isEmpty {
self.workingDirectory = workingDirectory
}
}
if let rawCommand = raw["command"] {
guard let command = rawCommand as? String else {
throw RecordParseError.invalidType(parameter: "command", expected: "text")
}
if !command.isEmpty {
self.command = command
}
}
if let rawInitialInput = raw["initialInput"] {
guard let initialInput = rawInitialInput as? String else {
throw RecordParseError.invalidType(parameter: "initial input", expected: "text")
}
if !initialInput.isEmpty {
self.initialInput = initialInput
}
}
if let rawWaitAfterCommand = raw["waitAfterCommand"] {
if let boolValue = rawWaitAfterCommand as? Bool {
waitAfterCommand = boolValue
} else if let numericValue = rawWaitAfterCommand as? NSNumber {
waitAfterCommand = numericValue.boolValue
} else {
throw RecordParseError.invalidType(parameter: "wait after command", expected: "boolean")
}
}
if let assignments = raw["environmentVariables"] as? [String], !assignments.isEmpty {
environmentVariables = try Self.parseScriptEnvironmentAssignments(assignments)
}
}
var dictionaryRepresentation: NSDictionary {
var record: [String: Any] = [
"fontSize": 0,
"workingDirectory": "",
"command": "",
"initialInput": "",
"waitAfterCommand": false,
"environmentVariables": [String](),
]
if let fontSize {
record["fontSize"] = NSNumber(value: fontSize)
}
if let workingDirectory {
record["workingDirectory"] = workingDirectory
}
if let command {
record["command"] = command
}
if let initialInput {
record["initialInput"] = initialInput
}
if waitAfterCommand {
record["waitAfterCommand"] = true
}
if !environmentVariables.isEmpty {
record["environmentVariables"] = environmentVariables.map { "\($0.key)=\($0.value)" }
}
return record as NSDictionary
}
private static func parseScriptEnvironmentAssignments(_ assignments: [String]) throws -> [String: String] {
var result: [String: String] = [:]
for assignment in assignments {
guard let separator = assignment.firstIndex(of: "=") else {
throw RecordParseError.invalidValue(
parameter: "environment variables",
message: "expected KEY=VALUE, got \"\(assignment)\""
)
}
let key = String(assignment[..<separator])
let valueStart = assignment.index(after: separator)
let value = String(assignment[valueStart...])
result[key] = value
}
return result
}
}