From 341d8bdf754b85fe0dfaa57ceabe598e7eea0c59 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Mar 2026 07:11:04 -0800 Subject: [PATCH] macos: AppleScript windows/tabs Add ScriptWindow and ScriptTab classes to expose window/tab hierarchy to AppleScript, along with the corresponding sdef definitions. --- macos/Ghostty.sdef | 22 +++ macos/Ghostty.xcodeproj/project.pbxproj | 2 + .../AppleScript/AppDelegate+AppleScript.swift | 112 ++++++++++- .../Features/AppleScript/ScriptTab.swift | 85 +++++++++ .../Features/AppleScript/ScriptWindow.swift | 174 ++++++++++++++++++ .../ObjectIdentifier+Extension.swift | 7 + 6 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 macos/Sources/Features/AppleScript/ScriptTab.swift create mode 100644 macos/Sources/Features/AppleScript/ScriptWindow.swift create mode 100644 macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef index dcb973974..e2410a4bd 100644 --- a/macos/Ghostty.sdef +++ b/macos/Ghostty.sdef @@ -14,11 +14,33 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 6b89e28c9..f667bb76e 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -146,7 +146,9 @@ Features/AppleScript/ScriptMousePosCommand.swift, Features/AppleScript/ScriptMouseScrollCommand.swift, Features/AppleScript/ScriptSplitCommand.swift, + Features/AppleScript/ScriptTab.swift, Features/AppleScript/ScriptTerminal.swift, + Features/AppleScript/ScriptWindow.swift, Features/ClipboardConfirmation/ClipboardConfirmation.xib, Features/ClipboardConfirmation/ClipboardConfirmationController.swift, Features/ClipboardConfirmation/ClipboardConfirmationView.swift, diff --git a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift index 267863712..519d2805f 100644 --- a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift +++ b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift @@ -2,10 +2,74 @@ import AppKit /// Application-level Cocoa scripting hooks for the Ghostty AppleScript dictionary. /// -/// Cocoa scripting looks for specifically named Objective-C selectors derived -/// from the `sdef` file. This extension implements those required entry points -/// on `NSApplication`, which is the object behind the `application` class in +/// Cocoa scripting is mostly convention-based: we do not register handlers in +/// code, we expose Objective-C selectors with names Cocoa derives from /// `Ghostty.sdef`. +/// +/// In practical terms: +/// - An `` in `sdef` maps to an ObjC collection accessor. +/// - Unique-ID element lookup maps to `valueIn...WithUniqueID:`. +/// - Some `` declarations map to `handle...ScriptCommand:`. +/// +/// This file implements the selectors Cocoa expects on `NSApplication`, which is +/// the runtime object behind the `application` class in `Ghostty.sdef`. + +// MARK: - Windows + +@MainActor +extension NSApplication { + /// Backing collection for `application.windows`. + /// + /// We expose one scripting window per native tab group so scripts see the + /// expected window/tab hierarchy instead of one AppKit window per tab. + /// + /// Required selector name from the `sdef` element key: `scriptWindows`. + /// + /// Cocoa scripting calls this whenever AppleScript evaluates a window list, + /// such as `windows`, `window 1`, or `every window whose ...`. + @objc(scriptWindows) + var scriptWindows: [ScriptWindow] { + // AppKit exposes one NSWindow per tab. AppleScript users expect one + // top-level window object containing multiple tabs, so we dedupe tab + // siblings into a single ScriptWindow. + var seen: Set = [] + var result: [ScriptWindow] = [] + + for controller in orderedTerminalControllers { + // Collapse each controller to one canonical representative for the + // whole tab group. Standalone windows map to themselves. + guard let primary = primaryTerminalController(for: controller) else { + continue + } + + let primaryControllerID = ObjectIdentifier(primary) + guard seen.insert(primaryControllerID).inserted else { + // Another tab from this group already created the scripting + // window object. + continue + } + + result.append(ScriptWindow(primaryController: primary)) + } + + return result + } + + /// Enables AppleScript unique-ID lookup for window references. + /// + /// Required selector name pattern for element key `scriptWindows`: + /// `valueInScriptWindowsWithUniqueID:`. + /// + /// Cocoa calls this when a script resolves `window id "..."`. + /// Returning `nil` makes the object specifier fail naturally. + @objc(valueInScriptWindowsWithUniqueID:) + func valueInScriptWindows(uniqueID: String) -> ScriptWindow? { + scriptWindows.first(where: { $0.stableID == uniqueID }) + } +} + +// MARK: - Terminals + @MainActor extension NSApplication { /// Backing collection for `application.terminals`. @@ -29,7 +93,12 @@ extension NSApplication { .first(where: { $0.id.uuidString == uniqueID }) .map(ScriptTerminal.init) } +} +// MARK: - Commands + +@MainActor +extension NSApplication { /// Handler for the `perform action` AppleScript command. /// /// Required selector name from the command in `sdef`: @@ -56,12 +125,43 @@ extension NSApplication { return NSNumber(value: terminal.perform(action: action)) } +} +// MARK: - Private Helpers + +@MainActor +extension NSApplication { /// Discovers all currently alive terminal surfaces across normal and quick /// terminal windows. This powers both terminal enumeration and ID lookup. - private var allSurfaceViews: [Ghostty.SurfaceView] { - NSApp.windows - .compactMap { $0.windowController as? BaseTerminalController } + fileprivate var allSurfaceViews: [Ghostty.SurfaceView] { + allTerminalControllers .flatMap { $0.surfaceTree.root?.leaves() ?? [] } } + + /// All terminal controllers in undefined order. + fileprivate var allTerminalControllers: [BaseTerminalController] { + NSApp.windows.compactMap { $0.windowController as? BaseTerminalController } + } + + /// All terminal controllers in front-to-back order. + fileprivate var orderedTerminalControllers: [BaseTerminalController] { + NSApp.orderedWindows.compactMap { $0.windowController as? BaseTerminalController } + } + + /// Identifies the primary tab controller for a window's tab group. + /// + /// This gives us one stable representative for all tabs in the same native + /// AppKit tab group. + /// + /// For standalone windows this returns the window's controller directly. + /// For tabbed windows, "primary" is currently the first controller in the + /// tab group's ordered windows list. + fileprivate func primaryTerminalController(for controller: BaseTerminalController) -> BaseTerminalController? { + guard let window = controller.window else { return nil } + guard let tabGroup = window.tabGroup else { return controller } + + return tabGroup.windows + .compactMap { $0.windowController as? BaseTerminalController } + .first + } } diff --git a/macos/Sources/Features/AppleScript/ScriptTab.swift b/macos/Sources/Features/AppleScript/ScriptTab.swift new file mode 100644 index 000000000..433da8160 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptTab.swift @@ -0,0 +1,85 @@ +import AppKit + +/// AppleScript-facing wrapper around a single tab in a scripting window. +/// +/// `ScriptWindow.tabs` vends these objects so AppleScript can traverse +/// `window -> tab` without knowing anything about AppKit controllers. +@MainActor +@objc(GhosttyScriptTab) +final class ScriptTab: NSObject { + /// Stable identifier used by AppleScript `tab id "..."` references. + private let stableID: String + + /// Weak back-reference to the scripting window that owns this tab wrapper. + /// + /// We only need this for dynamic properties (`index`, `selected`) and for + /// building an object specifier path. + private weak var window: ScriptWindow? + + /// Live terminal controller for this tab. + /// + /// This can become `nil` if the tab closes while a script is running. + private weak var controller: BaseTerminalController? + + /// Called by `ScriptWindow.tabs` / `ScriptWindow.selectedTab`. + /// + /// The ID is computed once so object specifiers built from this instance keep + /// a consistent tab identity. + init(window: ScriptWindow, controller: BaseTerminalController) { + self.stableID = Self.stableID(controller: controller) + self.window = window + self.controller = controller + } + + /// Exposed as the AppleScript `id` property. + @objc(id) + var idValue: String { + stableID + } + + /// Exposed as the AppleScript `index` property. + /// + /// Cocoa scripting expects this to be 1-based for user-facing collections. + @objc(index) + var index: Int { + guard let controller else { return 0 } + return window?.tabIndex(for: controller) ?? 0 + } + + /// Exposed as the AppleScript `selected` property. + /// + /// Powers script conditions such as `if selected of tab 1 then ...`. + @objc(selected) + var selected: Bool { + guard let controller else { return false } + return window?.tabIsSelected(controller) ?? false + } + + /// Provides Cocoa scripting with a canonical "path" back to this object. + override var objectSpecifier: NSScriptObjectSpecifier? { + guard let window else { return nil } + guard let windowClassDescription = window.classDescription as? NSScriptClassDescription else { + return nil + } + guard let windowSpecifier = window.objectSpecifier else { return nil } + + // This tells Cocoa how to re-find this tab later: + // application -> scriptWindows[id] -> tabs[id]. + return NSUniqueIDSpecifier( + containerClassDescription: windowClassDescription, + containerSpecifier: windowSpecifier, + key: "tabs", + uniqueID: stableID + ) + } +} + +extension ScriptTab { + /// Stable ID for one tab controller. + /// + /// Tab identity belongs to `ScriptTab`, so both tab creation and tab ID + /// lookups in `ScriptWindow` call this helper. + static func stableID(controller: BaseTerminalController) -> String { + "tab-\(ObjectIdentifier(controller).hexString)" + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptWindow.swift b/macos/Sources/Features/AppleScript/ScriptWindow.swift new file mode 100644 index 000000000..eb00d720b --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptWindow.swift @@ -0,0 +1,174 @@ +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 `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) + } + + /// 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 + } + + /// 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)" + } +} diff --git a/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift b/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift new file mode 100644 index 000000000..d2440f1d4 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift @@ -0,0 +1,7 @@ +import Foundation + +extension ObjectIdentifier { + var hexString: String { + String(UInt(bitPattern: self), radix: 16) + } +}