diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 9897aeda5..56bc5d92e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -40,7 +40,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } - self.surfaceTree = .noSplit(.init(ghostty_app, base)) + self.surfaceTree = .leaf(.init(ghostty_app, base)) // Setup our notifications for behaviors let center = NotificationCenter.default diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index beba0ed8d..6e3adea96 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -11,9 +11,8 @@ extension Ghostty { /// values can further be split infinitely. /// enum SplitNode: Equatable, Hashable { - case noSplit(Leaf) - case horizontal(Container) - case vertical(Container) + case leaf(Leaf) + case split(Container) /// Returns the view that would prefer receiving focus in this tree. This is always the /// top-left-most view. This is used when creating a split or closing a split to find the @@ -21,14 +20,11 @@ extension Ghostty { func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView { let container: Container switch (self) { - case .noSplit(let leaf): + case .leaf(let leaf): // noSplit is easy because there is only one thing to focus return leaf.surface - case .horizontal(let c): - container = c - - case .vertical(let c): + case .split(let c): container = c } @@ -48,14 +44,10 @@ extension Ghostty { /// surface. At this point, the surface view in this node tree can never be used again. func close() { switch (self) { - case .noSplit(let leaf): + case .leaf(let leaf): leaf.surface.close() - case .horizontal(let container): - container.topLeft.close() - container.bottomRight.close() - - case .vertical(let container): + case .split(let container): container.topLeft.close() container.bottomRight.close() } @@ -64,14 +56,10 @@ extension Ghostty { /// Returns true if any surface in the split stack requires quit confirmation. func needsConfirmQuit() -> Bool { switch (self) { - case .noSplit(let leaf): + case .leaf(let leaf): return leaf.surface.needsConfirmQuit - case .horizontal(let container): - return container.topLeft.needsConfirmQuit() || - container.bottomRight.needsConfirmQuit() - - case .vertical(let container): + case .split(let container): return container.topLeft.needsConfirmQuit() || container.bottomRight.needsConfirmQuit() } @@ -80,14 +68,10 @@ extension Ghostty { /// Returns true if the split tree contains the given view. func contains(view: SurfaceView) -> Bool { switch (self) { - case .noSplit(let leaf): + case .leaf(let leaf): return leaf.surface == view - case .horizontal(let container): - return container.topLeft.contains(view: view) || - container.bottomRight.contains(view: view) - - case .vertical(let container): + case .split(let container): return container.topLeft.contains(view: view) || container.bottomRight.contains(view: view) } @@ -97,11 +81,9 @@ extension Ghostty { static func == (lhs: SplitNode, rhs: SplitNode) -> Bool { switch (lhs, rhs) { - case (.noSplit(let lhs_v), .noSplit(let rhs_v)): + case (.leaf(let lhs_v), .leaf(let rhs_v)): return lhs_v === rhs_v - case (.horizontal(let lhs_v), .horizontal(let rhs_v)): - return lhs_v === rhs_v - case (.vertical(let lhs_v), .vertical(let rhs_v)): + case (.split(let lhs_v), .split(let rhs_v)): return lhs_v === rhs_v default: return false @@ -112,6 +94,8 @@ extension Ghostty { let app: ghostty_app_t @Published var surface: SurfaceView + weak var parent: SplitNode.Container? + /// Initialize a new leaf which creates a new terminal surface. init(_ app: ghostty_app_t, _ baseConfig: SurfaceConfiguration?) { self.app = app @@ -134,25 +118,37 @@ extension Ghostty { class Container: ObservableObject, Equatable, Hashable { let app: ghostty_app_t + let direction: SplitViewDirection + @Published var topLeft: SplitNode @Published var bottomRight: SplitNode + weak var parent: SplitNode.Container? + /// A container is always initialized from some prior leaf because a split has to originate /// from a non-split value. When initializing, we inherit the leaf's surface and then /// initialize a new surface for the new pane. - init(from: Leaf, baseConfig: SurfaceConfiguration? = nil) { + init(from: Leaf, direction: SplitViewDirection, baseConfig: SurfaceConfiguration? = nil) { self.app = from.app + self.direction = direction + self.parent = from.parent // Initially, both topLeft and bottomRight are in the "nosplit" // state since this is a new split. - self.topLeft = .noSplit(from) - self.bottomRight = .noSplit(.init(app, baseConfig)) + self.topLeft = .leaf(from) + + let bottomRight: Leaf = .init(app, baseConfig) + self.bottomRight = .leaf(bottomRight) + + from.parent = self + bottomRight.parent = self } // MARK: - Hashable func hash(into hasher: inout Hasher) { hasher.combine(app) + hasher.combine(direction) hasher.combine(topLeft) hasher.combine(bottomRight) } @@ -161,6 +157,7 @@ extension Ghostty { static func == (lhs: Container, rhs: Container) -> Bool { return lhs.app == rhs.app && + lhs.direction == rhs.direction && lhs.topLeft == rhs.topLeft && lhs.bottomRight == rhs.bottomRight } diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 82cbd3a04..a684c2e1b 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -61,25 +61,15 @@ extension Ghostty { case nil: Color(.clear) - case .noSplit(let leaf): + case .leaf(let leaf): TerminalSplitLeaf( leaf: leaf, neighbors: .empty, node: $node ) - case .horizontal(let container): + case .split(let container): TerminalSplitContainer( - direction: .horizontal, - neighbors: .empty, - node: $node, - container: container - ) - .onReceive(pubZoom) { onZoom(notification: $0) } - - case .vertical(let container): - TerminalSplitContainer( - direction: .vertical, neighbors: .empty, node: $node, container: container @@ -105,7 +95,7 @@ extension Ghostty { func onZoom(notification: SwiftUI.Notification) { // Our node must be split to receive zooms. You can't zoom an unsplit terminal. - if case .noSplit = node { + if case .leaf = node { preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist") } @@ -234,16 +224,10 @@ extension Ghostty { } // Setup our new container since we are now split - let container = SplitNode.Container(from: leaf, baseConfig: config) + let container = SplitNode.Container(from: leaf, direction: splitDirection, baseConfig: config) - // Depending on the direction, change the parent node. This will trigger - // the parent to relayout our views. - switch (splitDirection) { - case .horizontal: - node = .horizontal(container) - case .vertical: - node = .vertical(container) - } + // Change the parent node. This will trigger the parent to relayout our views. + node = .split(container) // See moveFocus comment, we have to run this whenever split changes. Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node!.preferredFocus()) @@ -263,14 +247,13 @@ extension Ghostty { /// This represents a split view that is in the horizontal or vertical split state. private struct TerminalSplitContainer: View { - let direction: SplitViewDirection let neighbors: SplitNode.Neighbors @Binding var node: SplitNode? @StateObject var container: SplitNode.Container var body: some View { - SplitView(direction, left: { - let neighborKey: WritableKeyPath = direction == .horizontal ? \.right : \.bottom + SplitView(container.direction, left: { + let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.bottom TerminalSplitNested( node: closeableTopLeft(), @@ -280,8 +263,8 @@ extension Ghostty { ]) ) }, right: { - let neighborKey: WritableKeyPath = direction == .horizontal ? \.left : \.top - + let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.left : \.top + TerminalSplitNested( node: closeableBottomRight(), neighbors: neighbors.update([ @@ -304,7 +287,16 @@ extension Ghostty { // Closing container.topLeft.close() node = container.bottomRight - + + switch (node) { + case .leaf(let l): + l.parent = container.parent + case .split(let c): + c.parent = container.parent + case .none: + break + } + DispatchQueue.main.async { Ghostty.moveFocus( to: container.bottomRight.preferredFocus(), @@ -326,7 +318,16 @@ extension Ghostty { // Closing container.bottomRight.close() node = container.topLeft - + + switch (node) { + case .leaf(let l): + l.parent = container.parent + case .split(let c): + c.parent = container.parent + case .none: + break + } + DispatchQueue.main.async { Ghostty.moveFocus( to: container.topLeft.preferredFocus(), @@ -350,24 +351,15 @@ extension Ghostty { case nil: Color(.clear) - case .noSplit(let leaf): + case .leaf(let leaf): TerminalSplitLeaf( leaf: leaf, neighbors: neighbors, node: $node ) - case .horizontal(let container): + case .split(let container): TerminalSplitContainer( - direction: .horizontal, - neighbors: neighbors, - node: $node, - container: container - ) - - case .vertical(let container): - TerminalSplitContainer( - direction: .vertical, neighbors: neighbors, node: $node, container: container diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 2f491ae5e..97a347aab 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -508,7 +508,7 @@ extension Ghostty { guard let window = self.window else { return } guard let windowControllerRaw = window.windowController else { return } guard let windowController = windowControllerRaw as? TerminalController else { return } - guard case .noSplit = windowController.surfaceTree else { return } + guard case .leaf = windowController.surfaceTree else { return } // If our window is full screen, we do not set the frame guard !window.styleMask.contains(.fullScreen) else { return }