mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
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:
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.right : \.bottom
|
||||
SplitView(container.direction, left: {
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.bottom
|
||||
|
||||
TerminalSplitNested(
|
||||
node: closeableTopLeft(),
|
||||
@ -280,7 +263,7 @@ extension Ghostty {
|
||||
])
|
||||
)
|
||||
}, right: {
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.left : \.top
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.top
|
||||
|
||||
TerminalSplitNested(
|
||||
node: closeableBottomRight(),
|
||||
@ -305,6 +288,15 @@ extension Ghostty {
|
||||
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(),
|
||||
@ -327,6 +319,15 @@ extension Ghostty {
|
||||
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
|
||||
|
@ -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 }
|
||||
|
Reference in New Issue
Block a user