Files
ghostty-org-ghostty/macos/Sources/Features/AppleScript/ScriptWindow.swift
Mitchell Hashimoto 28b4e2495d macos: Add AppleScript commands for window and tab control
Add scripting dictionary commands for activating windows, selecting tabs,
closing tabs, and closing windows.

Implement the corresponding Cocoa AppleScript command handlers and expose
minimal ScriptWindow/ScriptTab helpers needed to resolve live targets.

Verified by building Ghostty and running osascript commands against the
absolute Debug app path to exercise all four new commands.
2026-03-06 14:35:31 -08:00

212 lines
7.9 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 {
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 {
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] {
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 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 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] {
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? {
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? {
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 {
selectedController === controller
}
/// Best-effort native window to use as a tab parent for AppleScript commands.
var preferredParentWindow: NSWindow? {
selectedController?.window ?? controllers.first?.window
}
/// Best-effort controller to use for window-scoped AppleScript commands.
var preferredController: BaseTerminalController? {
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 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
}
/// 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 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)"
}
}