diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index c3b9afa28..a66d4abe7 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -1,7 +1,7 @@ import AppKit /// SplitTree represents a tree of views that can be divided. -struct SplitTree { +struct SplitTree: Codable { /// The root of the tree. This can be nil to indicate the tree is empty. let root: Node? @@ -11,11 +11,11 @@ struct SplitTree { /// A single node in the tree is either a leaf node (a view) or a split (has a /// left/right or top/bottom). - indirect enum Node { - case leaf(view: NSView) + indirect enum Node: Codable { + case leaf(view: ViewType) case split(Split) - struct Split: Equatable { + struct Split: Equatable, Codable { let direction: Direction let ratio: Double let left: Node @@ -23,7 +23,7 @@ struct SplitTree { } } - enum Direction { + enum Direction: Codable { case horizontal // Splits are laid out left and right case vertical // Splits are laid out top and bottom } @@ -63,12 +63,12 @@ extension SplitTree { self.init(root: nil, zoomed: nil) } - init(view: NSView) { + init(view: ViewType) { self.init(root: .leaf(view: view), zoomed: nil) } /// Insert a new view at the given view point by creating a split in the given direction. - func insert(view: NSView, at: NSView, direction: NewDirection) throws -> Self { + func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { guard let root else { throw SplitError.viewNotFound } return .init( root: try root.insert(view: view, at: at, direction: direction), @@ -122,7 +122,7 @@ extension SplitTree.Node { typealias Path = SplitTree.Path /// Returns the node in the tree that contains the given view. - func node(view: NSView) -> Node? { + func node(view: ViewType) -> Node? { switch (self) { case .leaf(view): return self @@ -188,7 +188,7 @@ extension SplitTree.Node { /// /// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should /// maybe throw instead but at the moment we just do nothing. - func insert(view: NSView, at: NSView, direction: NewDirection) throws -> Self { + func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { // Get the path to our insertion point. If it doesn't exist we do // nothing. guard let path = path(to: .leaf(view: at)) else { @@ -351,11 +351,51 @@ extension SplitTree.Node: Equatable { } } +// MARK: SplitTree Codable + +extension SplitTree.Node { + enum CodingKeys: String, CodingKey { + case view + case split + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if container.contains(.view) { + let view = try container.decode(ViewType.self, forKey: .view) + self = .leaf(view: view) + } else if container.contains(.split) { + let split = try container.decode(Split.self, forKey: .split) + self = .split(split) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "No valid node type found" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .leaf(let view): + try container.encode(view, forKey: .view) + + case .split(let split): + try container.encode(split, forKey: .split) + } + } +} + // MARK: SplitTree Sequences extension SplitTree.Node { /// Returns all leaf views in this subtree - func leaves() -> [NSView] { + func leaves() -> [ViewType] { switch self { case .leaf(let view): return [view] @@ -367,13 +407,13 @@ extension SplitTree.Node { } extension SplitTree: Sequence { - func makeIterator() -> [NSView].Iterator { + func makeIterator() -> [ViewType].Iterator { return root?.leaves().makeIterator() ?? [].makeIterator() } } extension SplitTree.Node: Sequence { - func makeIterator() -> [NSView].Iterator { + func makeIterator() -> [ViewType].Iterator { return leaves().makeIterator() } } diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 8f78dcbf8..3969b2e74 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -1,8 +1,8 @@ import SwiftUI struct TerminalSplitTreeView: View { - let tree: SplitTree - let onResize: (SplitTree.Node, Double) -> Void + let tree: SplitTree + let onResize: (SplitTree.Node, Double) -> Void var body: some View { if let node = tree.root { @@ -14,16 +14,15 @@ struct TerminalSplitTreeView: View { struct TerminalSplitSubtreeView: View { @EnvironmentObject var ghostty: Ghostty.App - let node: SplitTree.Node + let node: SplitTree.Node var isRoot: Bool = false - let onResize: (SplitTree.Node, Double) -> Void + let onResize: (SplitTree.Node, Double) -> Void var body: some View { switch (node) { case .leaf(let leafView): - // TODO: Fix the as! Ghostty.InspectableSurface( - surfaceView: leafView as! Ghostty.SurfaceView, + surfaceView: leafView, isSplit: !isRoot) case .split(let split): diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index a93896732..b3409c437 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -46,7 +46,7 @@ class BaseTerminalController: NSWindowController, didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } - @Published var surfaceTree2: SplitTree = .init() + @Published var surfaceTree2: SplitTree = .init() /// This can be set to show/hide the command palette. @Published var commandPaletteIsShowing: Bool = false @@ -88,7 +88,8 @@ class BaseTerminalController: NSWindowController, init(_ ghostty: Ghostty.App, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: Ghostty.SplitNode? = nil + surfaceTree tree: Ghostty.SplitNode? = nil, + surfaceTree2 tree2: SplitTree? = nil ) { self.ghostty = ghostty self.derivedConfig = DerivedConfig(ghostty.config) @@ -98,9 +99,7 @@ class BaseTerminalController: NSWindowController, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) - - let firstView = Ghostty.SurfaceView(ghostty_app, baseConfig: base) - self.surfaceTree2 = .init(view: firstView) + self.surfaceTree2 = tree2 ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -175,16 +174,14 @@ class BaseTerminalController: NSWindowController, /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about /// what surface is focused. This must be called whenever a surface OR window changes focus. func syncFocusToSurfaceTree() { - for view in surfaceTree2 { - if let surfaceView = view as? Ghostty.SurfaceView { - // Our focus state requires that this window is key and our currently - // focused surface is the surface in this view. - let focused: Bool = (window?.isKeyWindow ?? false) && - !commandPaletteIsShowing && - focusedSurface != nil && - surfaceView == focusedSurface! - surfaceView.focusDidChange(focused) - } + for surfaceView in surfaceTree2 { + // Our focus state requires that this window is key and our currently + // focused surface is the surface in this view. + let focused: Bool = (window?.isKeyWindow ?? false) && + !commandPaletteIsShowing && + focusedSurface != nil && + surfaceView == focusedSurface! + surfaceView.focusDidChange(focused) } } @@ -335,7 +332,7 @@ class BaseTerminalController: NSWindowController, // Determine our desired direction guard let directionAny = notification.userInfo?["direction"] else { return } guard let direction = directionAny as? ghostty_action_split_direction_e else { return } - let splitDirection: SplitTree.NewDirection + let splitDirection: SplitTree.NewDirection switch (direction) { case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left @@ -388,16 +385,16 @@ class BaseTerminalController: NSWindowController, private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? { // Also update surfaceTree2 - var surfaces2: [Ghostty.SurfaceView] = surfaceTree2.compactMap { $0 as? Ghostty.SurfaceView } - + var surfaces: [Ghostty.SurfaceView] = surfaceTree2.map { $0 } + // If we're the main window receiving key input, then we want to avoid // calling this on our focused surface because that'll trigger a double // flagsChanged call. if NSApp.mainWindow == window { - surfaces2 = surfaces2.filter { $0 != focusedSurface } + surfaces = surfaces.filter { $0 != focusedSurface } } - for surface in surfaces2 { + for surface in surfaces { surface.flagsChanged(with: event) } @@ -455,7 +452,7 @@ class BaseTerminalController: NSWindowController, func zoomStateDidChange(to: Bool) {} - func splitDidResize(node: SplitTree.Node, to newRatio: Double) { + func splitDidResize(node: SplitTree.Node, to newRatio: Double) { let resizedNode = node.resize(to: newRatio) do { surfaceTree2 = try surfaceTree2.replace(node: node, with: resizedNode) @@ -675,8 +672,7 @@ class BaseTerminalController: NSWindowController, func windowDidChangeOcclusionState(_ notification: Notification) { let visible = self.window?.occlusionState.contains(.visible) ?? false for view in surfaceTree2 { - if let surfaceView = view as? Ghostty.SurfaceView, - let surface = surfaceView.surface { + if let surface = view.surface { ghostty_surface_set_occlusion(surface, visible) } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 82491e76d..5c2f58dab 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -32,7 +32,8 @@ class TerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: Ghostty.SplitNode? = nil + withSurfaceTree tree: Ghostty.SplitNode? = nil, + withSurfaceTree2 tree2: SplitTree? = nil ) { // The window we manage is not restorable if we've specified a command // to execute. We do this because the restored window is meaningless at the @@ -44,7 +45,7 @@ class TerminalController: BaseTerminalController { // Setup our initial derived config based on the current app config self.derivedConfig = DerivedConfig(ghostty.config) - super.init(ghostty, baseConfig: base, surfaceTree: tree) + super.init(ghostty, baseConfig: base, surfaceTree: tree, surfaceTree2: tree2) // Setup our notifications for behaviors let center = NotificationCenter.default diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 07735cb58..2968f8abd 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -197,9 +197,10 @@ class TerminalManager { /// Creates a window controller, adds it to our managed list, and returns it. func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: Ghostty.SplitNode? = nil) -> TerminalController { + withSurfaceTree tree: Ghostty.SplitNode? = nil, + withSurfaceTree2 tree2: SplitTree? = nil) -> TerminalController { // Initialize our controller to load the window - let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree) + let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree, withSurfaceTree2: tree2) // Create a listener for when the window is closed so we can remove it. let pubClose = NotificationCenter.default.publisher( diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index b9d9b0ac0..5531494a5 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,14 +4,16 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 2 + static let version: Int = 3 let focusedSurface: String? let surfaceTree: Ghostty.SplitNode? + let surfaceTree2: SplitTree? init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.uuid.uuidString self.surfaceTree = controller.surfaceTree + self.surfaceTree2 = controller.surfaceTree2 } init?(coder aDecoder: NSCoder) { @@ -27,6 +29,7 @@ class TerminalRestorableState: Codable { } self.surfaceTree = v.value.surfaceTree + self.surfaceTree2 = v.value.surfaceTree2 self.focusedSurface = v.value.focusedSurface } @@ -83,18 +86,37 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // can be found for events from libghostty. This uses the low-level // createWindow so that AppKit can place the window wherever it should // be. - let c = appDelegate.terminalManager.createWindow(withSurfaceTree: state.surfaceTree) + let c = appDelegate.terminalManager.createWindow( + withSurfaceTree: state.surfaceTree, + withSurfaceTree2: state.surfaceTree2 + ) guard let window = c.window else { completionHandler(nil, TerminalRestoreError.windowDidNotLoad) return } // Setup our restored state on the controller + // First try to find the focused surface in surfaceTree2 if let focusedStr = state.focusedSurface, - let focusedUUID = UUID(uuidString: focusedStr), - let view = c.surfaceTree?.findUUID(uuid: focusedUUID) { - c.focusedSurface = view - restoreFocus(to: view, inWindow: window) + let focusedUUID = UUID(uuidString: focusedStr) { + // Try surfaceTree2 first + var foundView: Ghostty.SurfaceView? + for view in c.surfaceTree2 { + if view.uuid.uuidString == focusedStr { + foundView = view + break + } + } + + // Fall back to surfaceTree if not found + if foundView == nil { + foundView = c.surfaceTree?.findUUID(uuid: focusedUUID) + } + + if let view = foundView { + c.focusedSurface = view + restoreFocus(to: view, inWindow: window) + } } completionHandler(window, nil) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 2970f19c6..d13de4a72 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -21,7 +21,7 @@ protocol TerminalViewDelegate: AnyObject { func performAction(_ action: String, on: Ghostty.SurfaceView) /// A split is resizing to a given value. - func splitDidResize(node: SplitTree.Node, to newRatio: Double) + func splitDidResize(node: SplitTree.Node, to newRatio: Double) } /// The view model is a required implementation for TerminalView callers. This contains @@ -30,7 +30,7 @@ protocol TerminalViewDelegate: AnyObject { protocol TerminalViewModel: ObservableObject { /// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView /// and children. This should be @Published. - var surfaceTree2: SplitTree { get set } + var surfaceTree2: SplitTree { get set } /// The command palette state. var commandPaletteIsShowing: Bool { get set } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 99f901792..0aecef6ad 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -6,7 +6,7 @@ import GhosttyKit extension Ghostty { /// The NSView implementation for a terminal surface. - class SurfaceView: OSView, ObservableObject { + class SurfaceView: OSView, ObservableObject, Codable { /// Unique ID per surface let uuid: UUID @@ -1431,6 +1431,35 @@ extension Ghostty { self.windowAppearance = .init(ghosttyConfig: config) } } + + // MARK: - Codable + + enum CodingKeys: String, CodingKey { + case pwd + case uuid + } + + required convenience init(from decoder: Decoder) throws { + // Decoding uses the global Ghostty app + guard let del = NSApplication.shared.delegate, + let appDel = del as? AppDelegate, + let app = appDel.ghostty.app else { + throw TerminalRestoreError.delegateInvalid + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid)) + var config = Ghostty.SurfaceConfiguration() + config.workingDirectory = try container.decode(String?.self, forKey: .pwd) + + self.init(app, baseConfig: config, uuid: uuid) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(pwd, forKey: .pwd) + try container.encode(uuid.uuidString, forKey: .uuid) + } } }