macos: refactor SplitNode

This commit does two things: adds a weak reference to the parent
container of each SplitNode.Container and SplitNode.Leaf and moves the
"direction" of a split out of the SplitNode enum and into the
SplitNode.Container struct as a field.

Both changes are required for supporting split resizing. A reference to
the parent in each leaf and split container is needed in order to
traverse upwards through the split tree. If the focused split is not
part of a container that is split along the direction that was requested
to be resized, then we instead try and resize the parent. If the parent
is split along the requested direction, then it is resized
appropriately; otherwise, repeat until the root of the tree is reached.

The direction is needed inside the SplitNode.Container object itself so
that the container knows whether or not it is able to resize itself in
the direction requested by the user. Once the split direction was moved
inside of SplitNode.Container, it became redundant to also have it as
part of the SplitNode enum, so this simplifies things.
This commit is contained in:
Gregory Anders
2023-11-05 19:41:14 -06:00
parent e77a7e2dcd
commit 3b2799ce97
4 changed files with 64 additions and 75 deletions

View File

@ -40,7 +40,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
// 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 = .noSplit(.init(ghostty_app, base)) self.surfaceTree = .leaf(.init(ghostty_app, base))
// Setup our notifications for behaviors // Setup our notifications for behaviors
let center = NotificationCenter.default let center = NotificationCenter.default

View File

@ -11,9 +11,8 @@ extension Ghostty {
/// values can further be split infinitely. /// values can further be split infinitely.
/// ///
enum SplitNode: Equatable, Hashable { enum SplitNode: Equatable, Hashable {
case noSplit(Leaf) case leaf(Leaf)
case horizontal(Container) case split(Container)
case vertical(Container)
/// Returns the view that would prefer receiving focus in this tree. This is always the /// 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 /// 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 { func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView {
let container: Container let container: Container
switch (self) { switch (self) {
case .noSplit(let leaf): case .leaf(let leaf):
// noSplit is easy because there is only one thing to focus // noSplit is easy because there is only one thing to focus
return leaf.surface return leaf.surface
case .horizontal(let c): case .split(let c):
container = c
case .vertical(let c):
container = 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. /// surface. At this point, the surface view in this node tree can never be used again.
func close() { func close() {
switch (self) { switch (self) {
case .noSplit(let leaf): case .leaf(let leaf):
leaf.surface.close() leaf.surface.close()
case .horizontal(let container): case .split(let container):
container.topLeft.close()
container.bottomRight.close()
case .vertical(let container):
container.topLeft.close() container.topLeft.close()
container.bottomRight.close() container.bottomRight.close()
} }
@ -64,14 +56,10 @@ extension Ghostty {
/// Returns true if any surface in the split stack requires quit confirmation. /// Returns true if any surface in the split stack requires quit confirmation.
func needsConfirmQuit() -> Bool { func needsConfirmQuit() -> Bool {
switch (self) { switch (self) {
case .noSplit(let leaf): case .leaf(let leaf):
return leaf.surface.needsConfirmQuit return leaf.surface.needsConfirmQuit
case .horizontal(let container): case .split(let container):
return container.topLeft.needsConfirmQuit() ||
container.bottomRight.needsConfirmQuit()
case .vertical(let container):
return container.topLeft.needsConfirmQuit() || return container.topLeft.needsConfirmQuit() ||
container.bottomRight.needsConfirmQuit() container.bottomRight.needsConfirmQuit()
} }
@ -80,14 +68,10 @@ extension Ghostty {
/// Returns true if the split tree contains the given view. /// Returns true if the split tree contains the given view.
func contains(view: SurfaceView) -> Bool { func contains(view: SurfaceView) -> Bool {
switch (self) { switch (self) {
case .noSplit(let leaf): case .leaf(let leaf):
return leaf.surface == view return leaf.surface == view
case .horizontal(let container): case .split(let container):
return container.topLeft.contains(view: view) ||
container.bottomRight.contains(view: view)
case .vertical(let container):
return container.topLeft.contains(view: view) || return container.topLeft.contains(view: view) ||
container.bottomRight.contains(view: view) container.bottomRight.contains(view: view)
} }
@ -97,11 +81,9 @@ extension Ghostty {
static func == (lhs: SplitNode, rhs: SplitNode) -> Bool { static func == (lhs: SplitNode, rhs: SplitNode) -> Bool {
switch (lhs, rhs) { 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 return lhs_v === rhs_v
case (.horizontal(let lhs_v), .horizontal(let rhs_v)): case (.split(let lhs_v), .split(let rhs_v)):
return lhs_v === rhs_v
case (.vertical(let lhs_v), .vertical(let rhs_v)):
return lhs_v === rhs_v return lhs_v === rhs_v
default: default:
return false return false
@ -112,6 +94,8 @@ extension Ghostty {
let app: ghostty_app_t let app: ghostty_app_t
@Published var surface: SurfaceView @Published var surface: SurfaceView
weak var parent: SplitNode.Container?
/// Initialize a new leaf which creates a new terminal surface. /// Initialize a new leaf which creates a new terminal surface.
init(_ app: ghostty_app_t, _ baseConfig: SurfaceConfiguration?) { init(_ app: ghostty_app_t, _ baseConfig: SurfaceConfiguration?) {
self.app = app self.app = app
@ -134,25 +118,37 @@ extension Ghostty {
class Container: ObservableObject, Equatable, Hashable { class Container: ObservableObject, Equatable, Hashable {
let app: ghostty_app_t let app: ghostty_app_t
let direction: SplitViewDirection
@Published var topLeft: SplitNode @Published var topLeft: SplitNode
@Published var bottomRight: 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 /// 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 /// from a non-split value. When initializing, we inherit the leaf's surface and then
/// initialize a new surface for the new pane. /// 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.app = from.app
self.direction = direction
self.parent = from.parent
// Initially, both topLeft and bottomRight are in the "nosplit" // Initially, both topLeft and bottomRight are in the "nosplit"
// state since this is a new split. // state since this is a new split.
self.topLeft = .noSplit(from) self.topLeft = .leaf(from)
self.bottomRight = .noSplit(.init(app, baseConfig))
let bottomRight: Leaf = .init(app, baseConfig)
self.bottomRight = .leaf(bottomRight)
from.parent = self
bottomRight.parent = self
} }
// MARK: - Hashable // MARK: - Hashable
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(app) hasher.combine(app)
hasher.combine(direction)
hasher.combine(topLeft) hasher.combine(topLeft)
hasher.combine(bottomRight) hasher.combine(bottomRight)
} }
@ -161,6 +157,7 @@ extension Ghostty {
static func == (lhs: Container, rhs: Container) -> Bool { static func == (lhs: Container, rhs: Container) -> Bool {
return lhs.app == rhs.app && return lhs.app == rhs.app &&
lhs.direction == rhs.direction &&
lhs.topLeft == rhs.topLeft && lhs.topLeft == rhs.topLeft &&
lhs.bottomRight == rhs.bottomRight lhs.bottomRight == rhs.bottomRight
} }

View File

@ -61,25 +61,15 @@ extension Ghostty {
case nil: case nil:
Color(.clear) Color(.clear)
case .noSplit(let leaf): case .leaf(let leaf):
TerminalSplitLeaf( TerminalSplitLeaf(
leaf: leaf, leaf: leaf,
neighbors: .empty, neighbors: .empty,
node: $node node: $node
) )
case .horizontal(let container): case .split(let container):
TerminalSplitContainer( TerminalSplitContainer(
direction: .horizontal,
neighbors: .empty,
node: $node,
container: container
)
.onReceive(pubZoom) { onZoom(notification: $0) }
case .vertical(let container):
TerminalSplitContainer(
direction: .vertical,
neighbors: .empty, neighbors: .empty,
node: $node, node: $node,
container: container container: container
@ -105,7 +95,7 @@ extension Ghostty {
func onZoom(notification: SwiftUI.Notification) { func onZoom(notification: SwiftUI.Notification) {
// Our node must be split to receive zooms. You can't zoom an unsplit terminal. // 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") 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 // 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 // Change the parent node. This will trigger the parent to relayout our views.
// the parent to relayout our views. node = .split(container)
switch (splitDirection) {
case .horizontal:
node = .horizontal(container)
case .vertical:
node = .vertical(container)
}
// See moveFocus comment, we have to run this whenever split changes. // See moveFocus comment, we have to run this whenever split changes.
Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node!.preferredFocus()) 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. /// This represents a split view that is in the horizontal or vertical split state.
private struct TerminalSplitContainer: View { private struct TerminalSplitContainer: View {
let direction: SplitViewDirection
let neighbors: SplitNode.Neighbors let neighbors: SplitNode.Neighbors
@Binding var node: SplitNode? @Binding var node: SplitNode?
@StateObject var container: SplitNode.Container @StateObject var container: SplitNode.Container
var body: some View { var body: some View {
SplitView(direction, left: { SplitView(container.direction, left: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.right : \.bottom let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.bottom
TerminalSplitNested( TerminalSplitNested(
node: closeableTopLeft(), node: closeableTopLeft(),
@ -280,7 +263,7 @@ extension Ghostty {
]) ])
) )
}, right: { }, right: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.left : \.top let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.top
TerminalSplitNested( TerminalSplitNested(
node: closeableBottomRight(), node: closeableBottomRight(),
@ -305,6 +288,15 @@ extension Ghostty {
container.topLeft.close() container.topLeft.close()
node = container.bottomRight 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 { DispatchQueue.main.async {
Ghostty.moveFocus( Ghostty.moveFocus(
to: container.bottomRight.preferredFocus(), to: container.bottomRight.preferredFocus(),
@ -327,6 +319,15 @@ extension Ghostty {
container.bottomRight.close() container.bottomRight.close()
node = container.topLeft 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 { DispatchQueue.main.async {
Ghostty.moveFocus( Ghostty.moveFocus(
to: container.topLeft.preferredFocus(), to: container.topLeft.preferredFocus(),
@ -350,24 +351,15 @@ extension Ghostty {
case nil: case nil:
Color(.clear) Color(.clear)
case .noSplit(let leaf): case .leaf(let leaf):
TerminalSplitLeaf( TerminalSplitLeaf(
leaf: leaf, leaf: leaf,
neighbors: neighbors, neighbors: neighbors,
node: $node node: $node
) )
case .horizontal(let container): case .split(let container):
TerminalSplitContainer( TerminalSplitContainer(
direction: .horizontal,
neighbors: neighbors,
node: $node,
container: container
)
case .vertical(let container):
TerminalSplitContainer(
direction: .vertical,
neighbors: neighbors, neighbors: neighbors,
node: $node, node: $node,
container: container container: container

View File

@ -508,7 +508,7 @@ extension Ghostty {
guard let window = self.window else { return } guard let window = self.window else { return }
guard let windowControllerRaw = window.windowController else { return } guard let windowControllerRaw = window.windowController else { return }
guard let windowController = windowControllerRaw as? TerminalController 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 // If our window is full screen, we do not set the frame
guard !window.styleMask.contains(.fullScreen) else { return } guard !window.styleMask.contains(.fullScreen) else { return }