macos: use direct parameters for object-targeting commands

Change split, focus, close, activate window, select tab, close tab, and
close window commands to accept their target object as a direct parameter
instead of a named parameter. This produces natural AppleScript syntax:

  activate window (window 1)
  close tab (tab 1 of window 1)
  split (terminal 1) direction right

instead of the awkward redundant form:

  activate window window (window 1)
  close tab tab (tab 1 of window 1)
  split terminal (terminal 1) direction right

The implementation moves command logic from NSScriptCommand subclasses
into responds-to handler methods on ScriptTerminal, ScriptWindow, and
ScriptTab, which is the standard Cocoa Scripting pattern for commands
whose direct parameter is an application class.
This commit is contained in:
Mitchell Hashimoto
2026-03-07 07:23:47 -08:00
parent 038ebef16c
commit 210b01ad60
8 changed files with 231 additions and 311 deletions

View File

@@ -1,101 +0,0 @@
import AppKit
/// Handler for the `close` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptCloseCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@objc(GhosttyScriptCloseCommand)
final class ScriptCloseCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { 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 controller = surfaceView.window?.windowController as? BaseTerminalController else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal is not in a window."
return nil
}
controller.closeSurface(surfaceView, withConfirmation: false)
return nil
}
}
/// Handler for the container-level `close tab` AppleScript command defined in
/// `Ghostty.sdef`.
@MainActor
@objc(GhosttyScriptCloseTabCommand)
final class ScriptCloseTabCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let tab = evaluatedArguments?["tab"] as? ScriptTab else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing tab target."
return nil
}
guard let tabController = tab.parentController else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Tab is no longer available."
return nil
}
if let managedTerminalController = tabController as? TerminalController {
managedTerminalController.closeTabImmediately(registerRedo: false)
return nil
}
guard let tabContainerWindow = tab.parentWindow else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Tab container window is no longer available."
return nil
}
tabContainerWindow.close()
return nil
}
}
/// Handler for the container-level `close window` AppleScript command defined in
/// `Ghostty.sdef`.
@MainActor
@objc(GhosttyScriptCloseWindowCommand)
final class ScriptCloseWindowCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let window = evaluatedArguments?["window"] as? ScriptWindow else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing window target."
return nil
}
if let managedTerminalController = window.preferredController as? TerminalController {
managedTerminalController.closeWindowImmediately()
return nil
}
guard let windowContainer = window.preferredParentWindow else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Window is no longer available."
return nil
}
windowContainer.close()
return nil
}
}

View File

@@ -1,87 +0,0 @@
import AppKit
/// Handler for the `focus` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptFocusCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@objc(GhosttyScriptFocusCommand)
final class ScriptFocusCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { 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 controller = surfaceView.window?.windowController as? BaseTerminalController else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal is not in a window."
return nil
}
controller.focusSurface(surfaceView)
return nil
}
}
/// Handler for the container-level `activate window` AppleScript command
/// defined in `Ghostty.sdef`.
@MainActor
@objc(GhosttyScriptActivateWindowCommand)
final class ScriptActivateWindowCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let window = evaluatedArguments?["window"] as? ScriptWindow else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing window target."
return nil
}
guard let windowContainer = window.preferredParentWindow else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Window is no longer available."
return nil
}
windowContainer.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return nil
}
}
/// Handler for the container-level `select tab` AppleScript command defined in
/// `Ghostty.sdef`.
@MainActor
@objc(GhosttyScriptSelectTabCommand)
final class ScriptSelectTabCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let tab = evaluatedArguments?["tab"] as? ScriptTab else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing tab target."
return nil
}
guard let tabContainerWindow = tab.parentWindow else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Tab is no longer available."
return nil
}
tabContainerWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return nil
}
}

View File

@@ -1,92 +0,0 @@
import AppKit
/// Handler for the `split` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptSplitCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@objc(GhosttyScriptSplitCommand)
final class ScriptSplitCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { 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 directionCode = evaluatedArguments?["direction"] as? UInt32,
let direction = ScriptSplitDirection(code: directionCode) else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing or unknown split direction."
return nil
}
let baseConfig: Ghostty.SurfaceConfiguration?
if let scriptRecord = evaluatedArguments?["configuration"] as? NSDictionary {
do {
baseConfig = try Ghostty.SurfaceConfiguration(scriptRecord: scriptRecord)
} catch {
scriptErrorNumber = errAECoercionFail
scriptErrorString = error.localizedDescription
return nil
}
} else {
baseConfig = nil
}
// Find the window controller that owns this surface.
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal is not in a splittable window."
return nil
}
guard let newView = controller.newSplit(
at: surfaceView,
direction: direction.splitDirection,
baseConfig: baseConfig
) else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Failed to create split."
return nil
}
return ScriptTerminal(surfaceView: newView)
}
}
/// Four-character codes matching the `split direction` enumeration in `Ghostty.sdef`.
private enum ScriptSplitDirection {
case right
case left
case down
case up
init?(code: UInt32) {
switch code {
case "GSrt".fourCharCode: self = .right
case "GSlf".fourCharCode: self = .left
case "GSdn".fourCharCode: self = .down
case "GSup".fourCharCode: self = .up
default: return nil
}
}
var splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection {
switch self {
case .right: .right
case .left: .left
case .down: .down
case .up: .up
}
}
}

View File

@@ -100,6 +100,48 @@ final class ScriptTab: NSObject {
.map(ScriptTerminal.init)
}
/// Handler for `select tab <tab>`.
@objc(handleSelectTabCommand:)
func handleSelectTab(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let tabContainerWindow = parentWindow else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Tab is no longer available."
return nil
}
tabContainerWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return nil
}
/// Handler for `close tab <tab>`.
@objc(handleCloseTabCommand:)
func handleCloseTab(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let tabController = parentController else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Tab is no longer available."
return nil
}
if let managedTerminalController = tabController as? TerminalController {
managedTerminalController.closeTabImmediately(registerRedo: false)
return nil
}
guard let tabContainerWindow = parentWindow else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Tab container window is no longer available."
return nil
}
tabContainerWindow.close()
return nil
}
/// Provides Cocoa scripting with a canonical "path" back to this object.
override var objectSpecifier: NSScriptObjectSpecifier? {
guard NSApp.isAppleScriptEnabled else { return nil }

View File

@@ -60,6 +60,103 @@ final class ScriptTerminal: NSObject {
return surfaceModel.perform(action: action)
}
/// Handler for `split <terminal> direction <dir>`.
@objc(handleSplitCommand:)
func handleSplit(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let surfaceView else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let directionCode = command.evaluatedArguments?["direction"] as? UInt32 else {
command.scriptErrorNumber = errAEParamMissed
command.scriptErrorString = "Missing or unknown split direction."
return nil
}
guard let direction = ScriptSplitDirection(code: directionCode)?.splitDirection else {
command.scriptErrorNumber = errAEParamMissed
command.scriptErrorString = "Missing or unknown split direction."
return nil
}
let baseConfig: Ghostty.SurfaceConfiguration?
if let scriptRecord = command.evaluatedArguments?["configuration"] as? NSDictionary {
do {
baseConfig = try Ghostty.SurfaceConfiguration(scriptRecord: scriptRecord)
} catch {
command.scriptErrorNumber = errAECoercionFail
command.scriptErrorString = error.localizedDescription
return nil
}
} else {
baseConfig = nil
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal is not in a splittable window."
return nil
}
guard let newView = controller.newSplit(
at: surfaceView,
direction: direction,
baseConfig: baseConfig
) else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Failed to create split."
return nil
}
return ScriptTerminal(surfaceView: newView)
}
/// Handler for `focus <terminal>`.
@objc(handleFocusCommand:)
func handleFocus(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let surfaceView else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal is not in a window."
return nil
}
controller.focusSurface(surfaceView)
return nil
}
/// Handler for `close <terminal>`.
@objc(handleCloseCommand:)
func handleClose(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let surfaceView else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal is not in a window."
return nil
}
controller.closeSurface(surfaceView, withConfirmation: false)
return nil
}
/// Provides Cocoa scripting with a canonical "path" back to this object.
///
/// Without an object specifier, returned terminal objects can't be reliably
@@ -79,3 +176,31 @@ final class ScriptTerminal: NSObject {
)
}
}
/// Converts four-character codes from the `split direction` enumeration in `Ghostty.sdef`
/// to `SplitTree.NewDirection` values.
enum ScriptSplitDirection {
case right
case left
case down
case up
init?(code: UInt32) {
switch code {
case "GSrt".fourCharCode: self = .right
case "GSlf".fourCharCode: self = .left
case "GSdn".fourCharCode: self = .down
case "GSup".fourCharCode: self = .up
default: return nil
}
}
var splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection {
switch self {
case .right: .right
case .left: .left
case .down: .down
case .up: .up
}
}
}

View File

@@ -174,6 +174,42 @@ final class ScriptWindow: NSObject {
return controllers.first
}
/// Handler for `activate window <window>`.
@objc(handleActivateWindowCommand:)
func handleActivateWindow(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let windowContainer = preferredParentWindow else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Window is no longer available."
return nil
}
windowContainer.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return nil
}
/// Handler for `close window <window>`.
@objc(handleCloseWindowCommand:)
func handleCloseWindow(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
if let managedTerminalController = preferredController as? TerminalController {
managedTerminalController.closeWindowImmediately()
return nil
}
guard let windowContainer = preferredParentWindow else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Window is no longer available."
return nil
}
windowContainer.close()
return nil
}
/// Provides Cocoa scripting with a canonical "path" back to this object.
///
/// Without this, Cocoa can return data but cannot reliably build object