Files
ghostty/macos/Sources/Features/AppleScript/ScriptWindow.swift
Mitchell Hashimoto 210b01ad60 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.
2026-03-07 07:23:59 -08:00

261 lines
10 KiB
Swift

import AppKit
/// AppleScript-facing wrapper around a logical Ghostty window.
///
/// In AppKit, each tab is often its own `NSWindow`. AppleScript users, however,
/// expect a single window object containing a list of tabs.
///
/// `ScriptWindow` is that compatibility layer:
/// - It presents one object per tab group.
/// - It translates tab-group state into `tabs` and `selected tab`.
/// - It exposes stable IDs that Cocoa scripting can resolve later.
@MainActor
@objc(GhosttyScriptWindow)
final class ScriptWindow: NSObject {
/// Stable identifier used by AppleScript `window id "..."` references.
///
/// We precompute this once so the object keeps a consistent ID for its whole
/// lifetime, even if AppKit window bookkeeping changes after creation.
let stableID: String
/// Canonical representative for this scripting window's tab group.
///
/// We intentionally keep only one controller reference; full tab membership
/// is derived lazily from current AppKit state whenever needed.
private weak var primaryController: BaseTerminalController?
/// `scriptWindows` in `AppDelegate+AppleScript` constructs these objects.
///
/// `stableID` must match the same identity scheme used by
/// `valueInScriptWindowsWithUniqueID:` so Cocoa can re-resolve object
/// specifiers produced earlier in a script.
init(primaryController: BaseTerminalController) {
self.stableID = Self.stableID(primaryController: primaryController)
self.primaryController = primaryController
}
/// Exposed as the AppleScript `id` property.
///
/// This is what scripts read with `id of window ...`.
@objc(id)
var idValue: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return stableID
}
/// Exposed as the AppleScript `title` property.
///
/// Returns the title of the window (from the selected/primary controller's NSWindow).
@objc(title)
var title: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return selectedController?.window?.title ?? ""
}
/// Exposed as the AppleScript `tabs` element.
///
/// Cocoa asks for this collection when a script evaluates `tabs of window ...`
/// or any tab-filter expression. We build wrappers from live controller state
/// so tab additions/removals are reflected immediately.
@objc(tabs)
var tabs: [ScriptTab] {
guard NSApp.isAppleScriptEnabled else { return [] }
return controllers.map { ScriptTab(window: self, controller: $0) }
}
/// Exposed as the AppleScript `selected tab` property.
///
/// This powers expressions like `selected tab of window 1`.
@objc(selectedTab)
var selectedTab: ScriptTab? {
guard NSApp.isAppleScriptEnabled else { return nil }
guard let selectedController else { return nil }
return ScriptTab(window: self, controller: selectedController)
}
/// Enables unique-ID lookup for `tabs` references.
///
/// Required selector pattern for the `tabs` element key:
/// `valueInTabsWithUniqueID:`.
///
/// Cocoa uses this when a script resolves `tab id "..." of window ...`.
@objc(valueInTabsWithUniqueID:)
func valueInTabs(uniqueID: String) -> ScriptTab? {
guard NSApp.isAppleScriptEnabled else { return nil }
guard let controller = controller(tabID: uniqueID) else { return nil }
return ScriptTab(window: self, controller: controller)
}
/// Exposed as the AppleScript `terminals` element on a window.
///
/// Returns all terminal surfaces across every tab in this window.
@objc(terminals)
var terminals: [ScriptTerminal] {
guard NSApp.isAppleScriptEnabled else { return [] }
return controllers
.flatMap { $0.surfaceTree.root?.leaves() ?? [] }
.map(ScriptTerminal.init)
}
/// Enables unique-ID lookup for `terminals` references on a window.
@objc(valueInTerminalsWithUniqueID:)
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
guard NSApp.isAppleScriptEnabled else { return nil }
return controllers
.flatMap { $0.surfaceTree.root?.leaves() ?? [] }
.first(where: { $0.id.uuidString == uniqueID })
.map(ScriptTerminal.init)
}
/// AppleScript tab indexes are 1-based, so we add one to Swift's 0-based
/// array index.
func tabIndex(for controller: BaseTerminalController) -> Int? {
guard NSApp.isAppleScriptEnabled else { return nil }
return controllers.firstIndex(where: { $0 === controller }).map { $0 + 1 }
}
/// Reports whether a given controller maps to this window's selected tab.
func tabIsSelected(_ controller: BaseTerminalController) -> Bool {
guard NSApp.isAppleScriptEnabled else { return false }
return selectedController === controller
}
/// Best-effort native window to use as a tab parent for AppleScript commands.
var preferredParentWindow: NSWindow? {
guard NSApp.isAppleScriptEnabled else { return nil }
return selectedController?.window ?? controllers.first?.window
}
/// Best-effort controller to use for window-scoped AppleScript commands.
var preferredController: BaseTerminalController? {
guard NSApp.isAppleScriptEnabled else { return nil }
return selectedController ?? controllers.first
}
/// Resolves a previously generated tab ID back to a live controller.
private func controller(tabID: String) -> BaseTerminalController? {
controllers.first(where: { ScriptTab.stableID(controller: $0) == tabID })
}
/// Live controller list for this scripting window.
///
/// We recalculate on every access so AppleScript immediately sees tab-group
/// changes (new tabs, closed tabs, tab moves) without rebuilding all objects.
private var controllers: [BaseTerminalController] {
guard NSApp.isAppleScriptEnabled else { return [] }
guard let primaryController else { return [] }
guard let window = primaryController.window else { return [primaryController] }
if let tabGroup = window.tabGroup {
let groupControllers = tabGroup.windows.compactMap {
$0.windowController as? BaseTerminalController
}
if !groupControllers.isEmpty {
return groupControllers
}
}
return [primaryController]
}
/// Live selected controller for this scripting window.
///
/// AppKit tracks selected tab on `NSWindowTabGroup.selectedWindow`; for
/// non-tabbed windows we fall back to the primary controller.
private var selectedController: BaseTerminalController? {
guard let primaryController else { return nil }
guard let window = primaryController.window else { return primaryController }
if let tabGroup = window.tabGroup,
let selectedController = tabGroup.selectedWindow?.windowController as? BaseTerminalController {
return selectedController
}
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
/// references for later script statements. This specifier encodes:
/// `application -> scriptWindows[id]`.
override var objectSpecifier: NSScriptObjectSpecifier? {
guard NSApp.isAppleScriptEnabled else { return nil }
guard let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
return nil
}
return NSUniqueIDSpecifier(
containerClassDescription: appClassDescription,
containerSpecifier: nil,
key: "scriptWindows",
uniqueID: stableID
)
}
}
extension ScriptWindow {
/// Produces the window-level stable ID from the primary controller.
///
/// - Tabbed windows are keyed by tab-group identity.
/// - Standalone windows are keyed by window identity.
/// - Detached controllers fall back to controller identity.
static func stableID(primaryController: BaseTerminalController) -> String {
guard let window = primaryController.window else {
return "controller-\(ObjectIdentifier(primaryController).hexString)"
}
if let tabGroup = window.tabGroup {
return stableID(tabGroup: tabGroup)
}
return stableID(window: window)
}
/// Stable ID for a standalone native window.
static func stableID(window: NSWindow) -> String {
"window-\(ObjectIdentifier(window).hexString)"
}
/// Stable ID for a native AppKit tab group.
static func stableID(tabGroup: NSWindowTabGroup) -> String {
"tab-group-\(ObjectIdentifier(tabGroup).hexString)"
}
}