mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 11:35:48 +00:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
29
macos/Sources/Features/AppleScript/ScriptRecord.swift
Normal file
29
macos/Sources/Features/AppleScript/ScriptRecord.swift
Normal 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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user