mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-19 05:50:27 +00:00
macos: AppleScript windows/tabs
Add ScriptWindow and ScriptTab classes to expose window/tab hierarchy to AppleScript, along with the corresponding sdef definitions.
This commit is contained in:
@@ -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 `<element>` in `sdef` maps to an ObjC collection accessor.
|
||||
/// - Unique-ID element lookup maps to `valueIn...WithUniqueID:`.
|
||||
/// - Some `<command>` 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<ObjectIdentifier> = []
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
85
macos/Sources/Features/AppleScript/ScriptTab.swift
Normal file
85
macos/Sources/Features/AppleScript/ScriptTab.swift
Normal file
@@ -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)"
|
||||
}
|
||||
}
|
||||
174
macos/Sources/Features/AppleScript/ScriptWindow.swift
Normal file
174
macos/Sources/Features/AppleScript/ScriptWindow.swift
Normal file
@@ -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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user