macos: restoration for new split tree

pull/7523/head
Mitchell Hashimoto 2025-06-03 16:05:23 -07:00
parent 33d94521ea
commit d1dce1e372
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
8 changed files with 142 additions and 54 deletions

View File

@ -1,7 +1,7 @@
import AppKit import AppKit
/// SplitTree represents a tree of views that can be divided. /// SplitTree represents a tree of views that can be divided.
struct SplitTree { struct SplitTree<ViewType: NSView & Codable>: Codable {
/// The root of the tree. This can be nil to indicate the tree is empty. /// The root of the tree. This can be nil to indicate the tree is empty.
let root: Node? 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 /// A single node in the tree is either a leaf node (a view) or a split (has a
/// left/right or top/bottom). /// left/right or top/bottom).
indirect enum Node { indirect enum Node: Codable {
case leaf(view: NSView) case leaf(view: ViewType)
case split(Split) case split(Split)
struct Split: Equatable { struct Split: Equatable, Codable {
let direction: Direction let direction: Direction
let ratio: Double let ratio: Double
let left: Node let left: Node
@ -23,7 +23,7 @@ struct SplitTree {
} }
} }
enum Direction { enum Direction: Codable {
case horizontal // Splits are laid out left and right case horizontal // Splits are laid out left and right
case vertical // Splits are laid out top and bottom case vertical // Splits are laid out top and bottom
} }
@ -63,12 +63,12 @@ extension SplitTree {
self.init(root: nil, zoomed: nil) self.init(root: nil, zoomed: nil)
} }
init(view: NSView) { init(view: ViewType) {
self.init(root: .leaf(view: view), zoomed: nil) 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. /// 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 } guard let root else { throw SplitError.viewNotFound }
return .init( return .init(
root: try root.insert(view: view, at: at, direction: direction), root: try root.insert(view: view, at: at, direction: direction),
@ -122,7 +122,7 @@ extension SplitTree.Node {
typealias Path = SplitTree.Path typealias Path = SplitTree.Path
/// Returns the node in the tree that contains the given view. /// Returns the node in the tree that contains the given view.
func node(view: NSView) -> Node? { func node(view: ViewType) -> Node? {
switch (self) { switch (self) {
case .leaf(view): case .leaf(view):
return self 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 /// - 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. /// 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 // Get the path to our insertion point. If it doesn't exist we do
// nothing. // nothing.
guard let path = path(to: .leaf(view: at)) else { 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 // MARK: SplitTree Sequences
extension SplitTree.Node { extension SplitTree.Node {
/// Returns all leaf views in this subtree /// Returns all leaf views in this subtree
func leaves() -> [NSView] { func leaves() -> [ViewType] {
switch self { switch self {
case .leaf(let view): case .leaf(let view):
return [view] return [view]
@ -367,13 +407,13 @@ extension SplitTree.Node {
} }
extension SplitTree: Sequence { extension SplitTree: Sequence {
func makeIterator() -> [NSView].Iterator { func makeIterator() -> [ViewType].Iterator {
return root?.leaves().makeIterator() ?? [].makeIterator() return root?.leaves().makeIterator() ?? [].makeIterator()
} }
} }
extension SplitTree.Node: Sequence { extension SplitTree.Node: Sequence {
func makeIterator() -> [NSView].Iterator { func makeIterator() -> [ViewType].Iterator {
return leaves().makeIterator() return leaves().makeIterator()
} }
} }

View File

@ -1,8 +1,8 @@
import SwiftUI import SwiftUI
struct TerminalSplitTreeView: View { struct TerminalSplitTreeView: View {
let tree: SplitTree let tree: SplitTree<Ghostty.SurfaceView>
let onResize: (SplitTree.Node, Double) -> Void let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
var body: some View { var body: some View {
if let node = tree.root { if let node = tree.root {
@ -14,16 +14,15 @@ struct TerminalSplitTreeView: View {
struct TerminalSplitSubtreeView: View { struct TerminalSplitSubtreeView: View {
@EnvironmentObject var ghostty: Ghostty.App @EnvironmentObject var ghostty: Ghostty.App
let node: SplitTree.Node let node: SplitTree<Ghostty.SurfaceView>.Node
var isRoot: Bool = false var isRoot: Bool = false
let onResize: (SplitTree.Node, Double) -> Void let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
var body: some View { var body: some View {
switch (node) { switch (node) {
case .leaf(let leafView): case .leaf(let leafView):
// TODO: Fix the as!
Ghostty.InspectableSurface( Ghostty.InspectableSurface(
surfaceView: leafView as! Ghostty.SurfaceView, surfaceView: leafView,
isSplit: !isRoot) isSplit: !isRoot)
case .split(let split): case .split(let split):

View File

@ -46,7 +46,7 @@ class BaseTerminalController: NSWindowController,
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) }
} }
@Published var surfaceTree2: SplitTree = .init() @Published var surfaceTree2: SplitTree<Ghostty.SurfaceView> = .init()
/// This can be set to show/hide the command palette. /// This can be set to show/hide the command palette.
@Published var commandPaletteIsShowing: Bool = false @Published var commandPaletteIsShowing: Bool = false
@ -88,7 +88,8 @@ class BaseTerminalController: NSWindowController,
init(_ ghostty: Ghostty.App, init(_ ghostty: Ghostty.App,
baseConfig base: Ghostty.SurfaceConfiguration? = nil, baseConfig base: Ghostty.SurfaceConfiguration? = nil,
surfaceTree tree: Ghostty.SplitNode? = nil surfaceTree tree: Ghostty.SplitNode? = nil,
surfaceTree2 tree2: SplitTree<Ghostty.SurfaceView>? = nil
) { ) {
self.ghostty = ghostty self.ghostty = ghostty
self.derivedConfig = DerivedConfig(ghostty.config) self.derivedConfig = DerivedConfig(ghostty.config)
@ -98,9 +99,7 @@ class BaseTerminalController: NSWindowController,
// Initialize our initial surface. // Initialize our initial surface.
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base))
self.surfaceTree2 = tree2 ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base))
let firstView = Ghostty.SurfaceView(ghostty_app, baseConfig: base)
self.surfaceTree2 = .init(view: firstView)
// Setup our notifications for behaviors // Setup our notifications for behaviors
let center = NotificationCenter.default let center = NotificationCenter.default
@ -175,8 +174,7 @@ class BaseTerminalController: NSWindowController,
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about /// 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. /// what surface is focused. This must be called whenever a surface OR window changes focus.
func syncFocusToSurfaceTree() { func syncFocusToSurfaceTree() {
for view in surfaceTree2 { for surfaceView in surfaceTree2 {
if let surfaceView = view as? Ghostty.SurfaceView {
// Our focus state requires that this window is key and our currently // Our focus state requires that this window is key and our currently
// focused surface is the surface in this view. // focused surface is the surface in this view.
let focused: Bool = (window?.isKeyWindow ?? false) && let focused: Bool = (window?.isKeyWindow ?? false) &&
@ -186,7 +184,6 @@ class BaseTerminalController: NSWindowController,
surfaceView.focusDidChange(focused) surfaceView.focusDidChange(focused)
} }
} }
}
// Call this whenever the frame changes // Call this whenever the frame changes
private func windowFrameDidChange() { private func windowFrameDidChange() {
@ -335,7 +332,7 @@ class BaseTerminalController: NSWindowController,
// Determine our desired direction // Determine our desired direction
guard let directionAny = notification.userInfo?["direction"] else { return } guard let directionAny = notification.userInfo?["direction"] else { return }
guard let direction = directionAny as? ghostty_action_split_direction_e else { return } guard let direction = directionAny as? ghostty_action_split_direction_e else { return }
let splitDirection: SplitTree.NewDirection let splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection
switch (direction) { switch (direction) {
case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right
case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left
@ -388,16 +385,16 @@ class BaseTerminalController: NSWindowController,
private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? { private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? {
// Also update surfaceTree2 // 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 // 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 // calling this on our focused surface because that'll trigger a double
// flagsChanged call. // flagsChanged call.
if NSApp.mainWindow == window { 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) surface.flagsChanged(with: event)
} }
@ -455,7 +452,7 @@ class BaseTerminalController: NSWindowController,
func zoomStateDidChange(to: Bool) {} func zoomStateDidChange(to: Bool) {}
func splitDidResize(node: SplitTree.Node, to newRatio: Double) { func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
let resizedNode = node.resize(to: newRatio) let resizedNode = node.resize(to: newRatio)
do { do {
surfaceTree2 = try surfaceTree2.replace(node: node, with: resizedNode) surfaceTree2 = try surfaceTree2.replace(node: node, with: resizedNode)
@ -675,8 +672,7 @@ class BaseTerminalController: NSWindowController,
func windowDidChangeOcclusionState(_ notification: Notification) { func windowDidChangeOcclusionState(_ notification: Notification) {
let visible = self.window?.occlusionState.contains(.visible) ?? false let visible = self.window?.occlusionState.contains(.visible) ?? false
for view in surfaceTree2 { for view in surfaceTree2 {
if let surfaceView = view as? Ghostty.SurfaceView, if let surface = view.surface {
let surface = surfaceView.surface {
ghostty_surface_set_occlusion(surface, visible) ghostty_surface_set_occlusion(surface, visible)
} }
} }

View File

@ -32,7 +32,8 @@ class TerminalController: BaseTerminalController {
init(_ ghostty: Ghostty.App, init(_ ghostty: Ghostty.App,
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: Ghostty.SplitNode? = nil withSurfaceTree tree: Ghostty.SplitNode? = nil,
withSurfaceTree2 tree2: SplitTree<Ghostty.SurfaceView>? = nil
) { ) {
// The window we manage is not restorable if we've specified a command // 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 // 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 // Setup our initial derived config based on the current app config
self.derivedConfig = DerivedConfig(ghostty.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 // Setup our notifications for behaviors
let center = NotificationCenter.default let center = NotificationCenter.default

View File

@ -197,9 +197,10 @@ class TerminalManager {
/// Creates a window controller, adds it to our managed list, and returns it. /// Creates a window controller, adds it to our managed list, and returns it.
func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: Ghostty.SplitNode? = nil) -> TerminalController { withSurfaceTree tree: Ghostty.SplitNode? = nil,
withSurfaceTree2 tree2: SplitTree<Ghostty.SurfaceView>? = nil) -> TerminalController {
// Initialize our controller to load the window // 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. // Create a listener for when the window is closed so we can remove it.
let pubClose = NotificationCenter.default.publisher( let pubClose = NotificationCenter.default.publisher(

View File

@ -4,14 +4,16 @@ import Cocoa
class TerminalRestorableState: Codable { class TerminalRestorableState: Codable {
static let selfKey = "state" static let selfKey = "state"
static let versionKey = "version" static let versionKey = "version"
static let version: Int = 2 static let version: Int = 3
let focusedSurface: String? let focusedSurface: String?
let surfaceTree: Ghostty.SplitNode? let surfaceTree: Ghostty.SplitNode?
let surfaceTree2: SplitTree<Ghostty.SurfaceView>?
init(from controller: TerminalController) { init(from controller: TerminalController) {
self.focusedSurface = controller.focusedSurface?.uuid.uuidString self.focusedSurface = controller.focusedSurface?.uuid.uuidString
self.surfaceTree = controller.surfaceTree self.surfaceTree = controller.surfaceTree
self.surfaceTree2 = controller.surfaceTree2
} }
init?(coder aDecoder: NSCoder) { init?(coder aDecoder: NSCoder) {
@ -27,6 +29,7 @@ class TerminalRestorableState: Codable {
} }
self.surfaceTree = v.value.surfaceTree self.surfaceTree = v.value.surfaceTree
self.surfaceTree2 = v.value.surfaceTree2
self.focusedSurface = v.value.focusedSurface self.focusedSurface = v.value.focusedSurface
} }
@ -83,19 +86,38 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
// can be found for events from libghostty. This uses the low-level // can be found for events from libghostty. This uses the low-level
// createWindow so that AppKit can place the window wherever it should // createWindow so that AppKit can place the window wherever it should
// be. // 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 { guard let window = c.window else {
completionHandler(nil, TerminalRestoreError.windowDidNotLoad) completionHandler(nil, TerminalRestoreError.windowDidNotLoad)
return return
} }
// Setup our restored state on the controller // Setup our restored state on the controller
// First try to find the focused surface in surfaceTree2
if let focusedStr = state.focusedSurface, if let focusedStr = state.focusedSurface,
let focusedUUID = UUID(uuidString: focusedStr), let focusedUUID = UUID(uuidString: focusedStr) {
let view = c.surfaceTree?.findUUID(uuid: focusedUUID) { // 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 c.focusedSurface = view
restoreFocus(to: view, inWindow: window) restoreFocus(to: view, inWindow: window)
} }
}
completionHandler(window, nil) completionHandler(window, nil)
} }

View File

@ -21,7 +21,7 @@ protocol TerminalViewDelegate: AnyObject {
func performAction(_ action: String, on: Ghostty.SurfaceView) func performAction(_ action: String, on: Ghostty.SurfaceView)
/// A split is resizing to a given value. /// A split is resizing to a given value.
func splitDidResize(node: SplitTree.Node, to newRatio: Double) func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double)
} }
/// The view model is a required implementation for TerminalView callers. This contains /// The view model is a required implementation for TerminalView callers. This contains
@ -30,7 +30,7 @@ protocol TerminalViewDelegate: AnyObject {
protocol TerminalViewModel: ObservableObject { protocol TerminalViewModel: ObservableObject {
/// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView /// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView
/// and children. This should be @Published. /// and children. This should be @Published.
var surfaceTree2: SplitTree { get set } var surfaceTree2: SplitTree<Ghostty.SurfaceView> { get set }
/// The command palette state. /// The command palette state.
var commandPaletteIsShowing: Bool { get set } var commandPaletteIsShowing: Bool { get set }

View File

@ -6,7 +6,7 @@ import GhosttyKit
extension Ghostty { extension Ghostty {
/// The NSView implementation for a terminal surface. /// The NSView implementation for a terminal surface.
class SurfaceView: OSView, ObservableObject { class SurfaceView: OSView, ObservableObject, Codable {
/// Unique ID per surface /// Unique ID per surface
let uuid: UUID let uuid: UUID
@ -1431,6 +1431,35 @@ extension Ghostty {
self.windowAppearance = .init(ghosttyConfig: config) 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)
}
} }
} }