macos: restoration for new split tree
parent
33d94521ea
commit
d1dce1e372
|
|
@ -1,7 +1,7 @@
|
|||
import AppKit
|
||||
|
||||
/// 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.
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import SwiftUI
|
||||
|
||||
struct TerminalSplitTreeView: View {
|
||||
let tree: SplitTree
|
||||
let onResize: (SplitTree.Node, Double) -> Void
|
||||
let tree: SplitTree<Ghostty.SurfaceView>
|
||||
let onResize: (SplitTree<Ghostty.SurfaceView>.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<Ghostty.SurfaceView>.Node
|
||||
var isRoot: Bool = false
|
||||
let onResize: (SplitTree.Node, Double) -> Void
|
||||
let onResize: (SplitTree<Ghostty.SurfaceView>.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):
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class BaseTerminalController: NSWindowController,
|
|||
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.
|
||||
@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<Ghostty.SurfaceView>? = 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<Ghostty.SurfaceView>.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<Ghostty.SurfaceView>.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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Ghostty.SurfaceView>? = 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
|
||||
|
|
|
|||
|
|
@ -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<Ghostty.SurfaceView>? = 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(
|
||||
|
|
|
|||
|
|
@ -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<Ghostty.SurfaceView>?
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<Ghostty.SurfaceView>.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<Ghostty.SurfaceView> { get set }
|
||||
|
||||
/// The command palette state.
|
||||
var commandPaletteIsShowing: Bool { get set }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue