From a3adeb0166b2dc896045b71f8656b2605648e9c2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Mar 2026 11:17:08 -0800 Subject: [PATCH] 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`. --- macos/Ghostty.sdef | 35 +++++ .../AppleScript/AppDelegate+AppleScript.swift | 33 ++++- .../Features/AppleScript/ScriptRecord.swift | 29 ++++ .../ScriptSurfaceConfiguration.swift | 136 ++++++++++++++++++ 4 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Features/AppleScript/ScriptRecord.swift create mode 100644 macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef index c39a3c8a1..5b77e29b2 100644 --- a/macos/Ghostty.sdef +++ b/macos/Ghostty.sdef @@ -16,6 +16,9 @@ + + + @@ -29,6 +32,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -74,7 +99,17 @@ + + + + + + + + + + diff --git a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift index cbeda86eb..acfc943db 100644 --- a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift +++ b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift @@ -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 }) { diff --git a/macos/Sources/Features/AppleScript/ScriptRecord.swift b/macos/Sources/Features/AppleScript/ScriptRecord.swift new file mode 100644 index 000000000..7c81b8e29 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptRecord.swift @@ -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)." + } + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift b/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift new file mode 100644 index 000000000..89ec85a3b --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift @@ -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[..