macOS: New value-based split tree implementation, move split logic out of SwiftUI into AppKit (#7523)

This is a major rework of how we represent, handle, and render splits in
the macOS app.

This new PR moves the split structure into a dedicated, generic
(non-Ghostty-specific) value-type called `SplitTree<V>`. All logic
associated with splits (new split, close split, move split, etc.) is now
handled by notifications on `BaseTerminalController`. The view hierarchy
is still SwiftUI but it has no logic associated with it anymore and
purely renders a static tree of splits.

Previously, the split hierarchy was owned by AppKit in a type called
`SplitNode` (a recursive class that contained the tree structure). All
logic around creating, zooming, etc. splits was handled by notification
listeners directly within the SwiftUI hierarchy. SwiftUI managed a
significant amount of state and we heavily used bindings, publishers,
and more. The reasoning for this is mostly historical: splits date back
to when Ghostty tried to go all-in on SwiftUI. Since then, we've taken a
more balanced approach of SwiftUI for views and AppKit for data and
business logic, and this has proven a lot more maintainable.

## Spatial Navigation

Previously, focus moving was handled by traversing the tree structure.
This led to some awkward behaviors. See:
https://github.com/ghostty-org/ghostty/issues/524#issuecomment-2668396095

In this PR, we now handle focus moving spatially. This means that move
"left" means moving to the visually left split (from the top-left
corner, a future improvement would be to do it from the cursor
position).

Concretely, given the following split structure:

```
+----------+-----+
|          |  b  |
|          |     |
|   a      +-----+
|          |     |
|          |     |
|          |     |
|          |     |
|----------|  d  |
|   c      |     |
|          |     |
+----------+-----+
```

Moving "right" from `c` now moves to `d`. Previously, it would go to
`b`. On Linux, it still goes to `b`.

## Value Types

One of the major architectural shifts is moving **purely to immutable
value types.** Whenever a split property changes such as a new split,
the ratio between splits, zoomed state, etc. we _create an entirely new
`SplitTree` value_ and replace it along the entire view hierarchy. This
is in some ways wasteful, but split hierarchies are relatively small
(even the largest I've seen in practical use are dozens of splits, which
is small for a computer). And using value types lets us get rid of a ton
of change notification soup around the SwiftUI hierarchy. We can rely on
reference counting to properly clean up our closed views.

> [!NOTE]
> 
> As an aside, I think value types are going to make it a lot easier in
the future to implement features like "undo close." We can just keep a
trailing list of surface tree states and just restore them. This PR
doesn't do anything like that, but it's now possible.

## SwiftUI Simplicity

Our SwiftUI view hierarchy is dramatically simplified. See the
difference in `TerminalSplitTreeView` (new) vs `TerminalSplit` (old).
There's so much less logic in our new views (almost none!). All of it is
in the AppKit layer which is just way nicer.

## AI Notes

This PR was heavily written by AI. I reviewed every line of code that
was rewritten, and I did manually rewrite at every step of the way in
minor ways. But it was very much written in concert. Each commit usually
started as an AI agent writing the whole commit, then nudging to get
cleaned up in the right way.

One thing I found in this task was that until the last commit, I kept
the entire previous implementation around and compiling. The agent
having access to a previous working version of code during a refactor
made the code it produced as follow up in the new architecture
significantly better, despite the new architecture having major
fundamental differences in how it works!
pull/7527/head
Mitchell Hashimoto 2025-06-05 12:59:43 -07:00 committed by GitHub
commit efc1ceab5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1690 additions and 1189 deletions

View File

@ -59,6 +59,9 @@
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; };
A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; };
A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; };
A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; };
A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; };
A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; };
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; };
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
@ -67,8 +70,6 @@
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; };
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; };
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; };
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */; };
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */; };
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; };
A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; };
A5985CE62C33060F00C57AD3 /* man in Resources */ = {isa = PBXBuildFile; fileRef = A5985CE52C33060F00C57AD3 /* man */; };
@ -164,6 +165,9 @@
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = "<group>"; };
A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = "<group>"; };
A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = "<group>"; };
A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = "<group>"; };
A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = "<group>"; };
A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = "<group>"; };
A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = "<group>"; };
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = "<group>"; };
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
@ -172,8 +176,6 @@
A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.TerminalSplit.swift; sourceTree = "<group>"; };
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitNode.swift; sourceTree = "<group>"; };
A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = "<group>"; };
A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = "<group>"; };
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = "<group>"; };
@ -275,6 +277,7 @@
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */,
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
A57D79252C9C8782001D522E /* Secure Input */,
A58636622DEF955100E04A10 /* Splits */,
A53A29742DB2E04900B6E02C /* Command Palette */,
A534263E2A7DCC5800EBB7A2 /* Settings */,
A51BFC1C2B2FB5AB00E92F16 /* About */,
@ -287,6 +290,7 @@
A534263D2A7DCBB000EBB7A2 /* Helpers */ = {
isa = PBXGroup;
children = (
A58636692DF0A98100E04A10 /* Extensions */,
A5874D9B2DAD781100E83852 /* Private */,
A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */,
A5A6F7292CC41B8700B232A5 /* Xcode.swift */,
@ -297,24 +301,11 @@
A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */,
A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */,
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
A5CA378D2D31D6C100931030 /* Weak.swift */,
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */,
C1F26EE82B76CBFC00404083 /* VibrantLayer.m */,
A5CEAFDA29B8005900646FDA /* SplitView */,
);
path = Helpers;
sourceTree = "<group>";
@ -402,8 +393,6 @@
A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */,
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */,
A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */,
A55685DF29A03A9F004303CE /* AppError.swift */,
A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */,
A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */,
@ -428,6 +417,37 @@
path = "Secure Input";
sourceTree = "<group>";
};
A58636622DEF955100E04A10 /* Splits */ = {
isa = PBXGroup;
children = (
A586365E2DEE6C2100E04A10 /* SplitTree.swift */,
A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */,
A5CEAFDB29B8009000646FDA /* SplitView.swift */,
A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */,
);
path = Splits;
sourceTree = "<group>";
};
A58636692DF0A98100E04A10 /* Extensions */ = {
isa = PBXGroup;
children = (
A586366A2DF0A98900E04A10 /* Array+Extension.swift */,
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */,
A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */,
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
A5874D9B2DAD781100E83852 /* Private */ = {
isa = PBXGroup;
children = (
@ -515,15 +535,6 @@
path = "Global Keybinds";
sourceTree = "<group>";
};
A5CEAFDA29B8005900646FDA /* SplitView */ = {
isa = PBXGroup;
children = (
A5CEAFDB29B8009000646FDA /* SplitView.swift */,
A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */,
);
path = SplitView;
sourceTree = "<group>";
};
A5D495A3299BECBA00DD1313 /* Frameworks */ = {
isa = PBXGroup;
children = (
@ -665,7 +676,6 @@
buildActionMask = 2147483647;
files = (
A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */,
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */,
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */,
@ -685,6 +695,7 @@
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */,
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
@ -706,11 +717,11 @@
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
A5874D992DAD751B00E83852 /* CGS.swift in Sources */,
A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */,
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */,
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */,
A5FEB3002ABB69450068369E /* main.swift in Sources */,
A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */,
A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */,
@ -734,6 +745,7 @@
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */,
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */,
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */,
A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */,
A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */,
AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */,
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */,

View File

@ -741,8 +741,10 @@ class AppDelegate: NSObject,
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? {
for c in terminalManager.windows {
if let v = c.controller.surfaceTree?.findUUID(uuid: uuid) {
return v
for view in c.controller.surfaceTree {
if view.uuid == uuid {
return view
}
}
}

View File

@ -30,7 +30,7 @@ class QuickTerminalController: BaseTerminalController {
init(_ ghostty: Ghostty.App,
position: QuickTerminalPosition = .top,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
surfaceTree tree: Ghostty.SplitNode? = nil
surfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
) {
self.position = position
self.derivedConfig = DerivedConfig(ghostty.config)
@ -185,11 +185,11 @@ class QuickTerminalController: BaseTerminalController {
// MARK: Base Controller Overrides
override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
super.surfaceTreeDidChange(from: from, to: to)
// If our surface tree is nil then we animate the window out.
if (to == nil) {
if (to.isEmpty) {
animateOut()
}
}
@ -233,13 +233,14 @@ class QuickTerminalController: BaseTerminalController {
// Animate the window in
animateWindowIn(window: window, from: position)
// If our surface tree is nil then we initialize a new terminal. The surface
// tree can be nil if for example we run "eixt" in the terminal and force
// If our surface tree is empty then we initialize a new terminal. The surface
// tree can be empty if for example we run "exit" in the terminal and force
// animate out.
if (surfaceTree == nil) {
let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil)
surfaceTree = .leaf(leaf)
focusedSurface = leaf.surface
if surfaceTree.isEmpty,
let ghostty_app = ghostty.app {
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil)
surfaceTree = SplitTree(view: view)
focusedSurface = view
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
import SwiftUI
import Combine
/// A split view shows a left and right (or top and bottom) view with a divider in the middle to do resizing.
/// The terminlogy "left" and "right" is always used but for vertical splits "left" is "top" and "right" is "bottom".
@ -13,12 +12,10 @@ struct SplitView<L: View, R: View>: View {
/// Divider color
let dividerColor: Color
/// If set, the split view supports programmatic resizing via events sent via the publisher.
/// Minimum increment (in points) that this split can be resized by, in
/// each direction. Both `height` and `width` should be whole numbers
/// greater than or equal to 1.0
let resizeIncrements: NSSize
let resizePublisher: PassthroughSubject<Double, Never>
/// The left and right views to render.
let left: L
@ -55,37 +52,15 @@ struct SplitView<L: View, R: View>: View {
.position(splitterPoint)
.gesture(dragGesture(geo.size, splitterPoint: splitterPoint))
}
.onReceive(resizePublisher) { value in
resize(for: geo.size, amount: value)
}
}
}
/// Initialize a split view. This view isn't programmatically resizable; it can only be resized
/// by manually dragging the divider.
init(_ direction: SplitViewDirection,
_ split: Binding<CGFloat>,
dividerColor: Color,
@ViewBuilder left: (() -> L),
@ViewBuilder right: (() -> R)) {
self.init(
direction,
split,
dividerColor: dividerColor,
resizeIncrements: .init(width: 1, height: 1),
resizePublisher: .init(),
left: left,
right: right
)
}
/// Initialize a split view that supports programmatic resizing.
/// Initialize a split view that can be resized by manually dragging the divider.
init(
_ direction: SplitViewDirection,
_ split: Binding<CGFloat>,
dividerColor: Color,
resizeIncrements: NSSize,
resizePublisher: PassthroughSubject<Double, Never>,
resizeIncrements: NSSize = .init(width: 1, height: 1),
@ViewBuilder left: (() -> L),
@ViewBuilder right: (() -> R)
) {
@ -93,25 +68,10 @@ struct SplitView<L: View, R: View>: View {
self._split = split
self.dividerColor = dividerColor
self.resizeIncrements = resizeIncrements
self.resizePublisher = resizePublisher
self.left = left()
self.right = right()
}
private func resize(for size: CGSize, amount: Double) {
let dim: CGFloat
switch (direction) {
case .horizontal:
dim = size.width
case .vertical:
dim = size.height
}
let pos = split * dim
let new = min(max(minSize, pos + amount), dim - minSize)
split = new / dim
}
private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture {
return DragGesture()
.onChanged { gesture in

View File

@ -0,0 +1,55 @@
import SwiftUI
struct TerminalSplitTreeView: View {
let tree: SplitTree<Ghostty.SurfaceView>
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
var body: some View {
if let node = tree.zoomed ?? tree.root {
TerminalSplitSubtreeView(
node: node,
isRoot: node == tree.root,
onResize: onResize)
}
}
}
struct TerminalSplitSubtreeView: View {
@EnvironmentObject var ghostty: Ghostty.App
let node: SplitTree<Ghostty.SurfaceView>.Node
var isRoot: Bool = false
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
var body: some View {
switch (node) {
case .leaf(let leafView):
Ghostty.InspectableSurface(
surfaceView: leafView,
isSplit: !isRoot)
case .split(let split):
let splitViewDirection: SplitViewDirection = switch (split.direction) {
case .horizontal: .horizontal
case .vertical: .vertical
}
SplitView(
splitViewDirection,
.init(get: {
CGFloat(split.ratio)
}, set: {
onResize(node, $0)
}),
dividerColor: ghostty.config.splitDividerColor,
resizeIncrements: .init(width: 1, height: 1),
left: {
TerminalSplitSubtreeView(node: split.left, onResize: onResize)
},
right: {
TerminalSplitSubtreeView(node: split.right, onResize: onResize)
}
)
}
}
}

View File

@ -41,8 +41,8 @@ class BaseTerminalController: NSWindowController,
didSet { syncFocusToSurfaceTree() }
}
/// The surface tree for this window.
@Published var surfaceTree: Ghostty.SplitNode? = nil {
/// The tree of splits within this terminal window.
@Published var surfaceTree: SplitTree<Ghostty.SurfaceView> = .init() {
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) }
}
@ -86,7 +86,7 @@ class BaseTerminalController: NSWindowController,
init(_ ghostty: Ghostty.App,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
surfaceTree tree: Ghostty.SplitNode? = nil
surfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
) {
self.ghostty = ghostty
self.derivedConfig = DerivedConfig(ghostty.config)
@ -95,7 +95,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))
self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base))
// Setup our notifications for behaviors
let center = NotificationCenter.default
@ -124,11 +124,38 @@ class BaseTerminalController: NSWindowController,
selector: #selector(ghosttyMaximizeDidToggle(_:)),
name: .ghosttyMaximizeDidToggle,
object: nil)
// Splits
center.addObserver(
self,
selector: #selector(ghosttyDidCloseSurface(_:)),
name: Ghostty.Notification.ghosttyCloseSurface,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyDidNewSplit(_:)),
name: Ghostty.Notification.ghosttyNewSplit,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyDidEqualizeSplits(_:)),
name: Ghostty.Notification.didEqualizeSplits,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyDidFocusSplit(_:)),
name: Ghostty.Notification.ghosttyFocusSplit,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyDidToggleSplitZoom(_:)),
name: Ghostty.Notification.didToggleSplitZoom,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyDidResizeSplit(_:)),
name: Ghostty.Notification.didResizeSplit,
object: nil)
// Listen for local events that we need to know of outside of
// single surface handlers.
@ -148,9 +175,9 @@ class BaseTerminalController: NSWindowController,
/// Called when the surfaceTree variable changed.
///
/// Subclasses should call super first.
func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
// If our surface tree becomes nil then we have no focused surface.
if (to == nil) {
func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
// If our surface tree becomes empty then we have no focused surface.
if (to.isEmpty) {
focusedSurface = nil
}
}
@ -158,16 +185,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() {
guard let tree = self.surfaceTree else { return }
for leaf in tree {
for surfaceView in surfaceTree {
// Our focus state requires that this window is key and our currently
// focused surface is the surface in this leaf.
// focused surface is the surface in this view.
let focused: Bool = (window?.isKeyWindow ?? false) &&
!commandPaletteIsShowing &&
focusedSurface != nil &&
leaf.surface == focusedSurface!
leaf.surface.focusDidChange(focused)
surfaceView == focusedSurface!
surfaceView.focusDidChange(focused)
}
}
@ -180,6 +205,73 @@ class BaseTerminalController: NSWindowController,
savedFrame = .init(window: window.frame, screen: screen.visibleFrame)
}
func confirmClose(
messageText: String,
informativeText: String,
completion: @escaping () -> Void
) {
// If we already have an alert, we need to wait for that one.
guard alert == nil else { return }
// If there is no window to attach the modal then we assume success
// since we'll never be able to show the modal.
guard let window else {
completion()
return
}
// If we need confirmation by any, show one confirmation for all windows
// in the tab group.
let alert = NSAlert()
alert.messageText = messageText
alert.informativeText = informativeText
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window) { response in
self.alert = nil
if response == .alertFirstButtonReturn {
completion()
}
}
// Store our alert so we only ever show one.
self.alert = alert
}
// MARK: Focus Management
/// Find the next surface to focus when a node is being closed.
/// Goes to previous split unless we're the leftmost leaf, then goes to next.
private func findNextFocusTargetAfterClosing(node: SplitTree<Ghostty.SurfaceView>.Node) -> Ghostty.SurfaceView? {
guard let root = surfaceTree.root else { return nil }
// If we're the leftmost, then we move to the next surface after closing.
// Otherwise, we move to the previous.
if root.leftmostLeaf() == node.leftmostLeaf() {
return surfaceTree.focusTarget(for: .next, from: node)
} else {
return surfaceTree.focusTarget(for: .previous, from: node)
}
}
/// Remove a node from the surface tree and move focus appropriately.
private func removeSurfaceAndMoveFocus(_ node: SplitTree<Ghostty.SurfaceView>.Node) {
let nextTarget = findNextFocusTargetAfterClosing(node: node)
let oldFocused = focusedSurface
let focused = node.contains { $0 == focusedSurface }
// Remove the node from the tree
surfaceTree = surfaceTree.remove(node)
// Move focus if the closed surface was focused and we have a next target
if let nextTarget, focused {
DispatchQueue.main.async {
Ghostty.moveFocus(to: nextTarget, from: oldFocused)
}
}
}
// MARK: Notifications
@objc private func didChangeScreenParametersNotification(_ notification: Notification) {
@ -242,25 +334,189 @@ class BaseTerminalController: NSWindowController,
@objc private func ghosttyCommandPaletteDidToggle(_ notification: Notification) {
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree?.contains(view: surfaceView) ?? false else { return }
guard surfaceTree.contains(surfaceView) else { return }
toggleCommandPalette(nil)
}
@objc private func ghosttyMaximizeDidToggle(_ notification: Notification) {
guard let window else { return }
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree?.contains(view: surfaceView) ?? false else { return }
guard surfaceTree.contains(surfaceView) else { return }
window.zoom(nil)
}
@objc private func ghosttyDidCloseSurface(_ notification: Notification) {
// The target must be within our tree
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard let node = surfaceTree.root?.node(view: target) else { return }
var processAlive = false
if let valueAny = notification.userInfo?["process_alive"] {
if let value = valueAny as? Bool {
processAlive = value
}
}
// If the child process is not alive, then we exit immediately
guard processAlive else {
removeSurfaceAndMoveFocus(node)
return
}
// Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
// due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
// confirmationDialog allows the user to Cmd-W close the alert, but when doing
// so SwiftUI does not update any of the bindings to note that window is no longer
// being shown, and provides no callback to detect this.
confirmClose(
messageText: "Close Terminal?",
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
) { [weak self] in
if let self {
self.removeSurfaceAndMoveFocus(node)
}
}
}
@objc private func ghosttyDidNewSplit(_ notification: Notification) {
// The target must be within our tree
guard let oldView = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.root?.node(view: oldView) != nil else { return }
// Notification must contain our base config
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration
// 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<Ghostty.SurfaceView>.NewDirection
switch (direction) {
case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right
case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left
case GHOSTTY_SPLIT_DIRECTION_DOWN: splitDirection = .down
case GHOSTTY_SPLIT_DIRECTION_UP: splitDirection = .up
default: return
}
// Create a new surface view
guard let ghostty_app = ghostty.app else { return }
let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
// Do the split
do {
surfaceTree = try surfaceTree.insert(view: newView, at: oldView, direction: splitDirection)
} catch {
// If splitting fails for any reason (it should not), then we just log
// and return. The new view we created will be deinitialized and its
// no big deal.
Ghostty.logger.warning("failed to insert split: \(error)")
return
}
// Once we've split, we need to move focus to the new split
Ghostty.moveFocus(to: newView, from: oldView)
}
@objc private func ghosttyDidEqualizeSplits(_ notification: Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
// Check if target surface is in current controller's tree
guard surfaceTree?.contains(view: target) ?? false else { return }
guard surfaceTree.contains(target) else { return }
if case .split(let container) = surfaceTree {
_ = container.equalize()
// Equalize the splits
surfaceTree = surfaceTree.equalize()
}
@objc private func ghosttyDidFocusSplit(_ notification: Notification) {
// The target must be within our tree
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.root?.node(view: target) != nil else { return }
// Get the direction from the notification
guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return }
guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return }
// Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection
let focusDirection: SplitTree<Ghostty.SurfaceView>.FocusDirection
switch direction {
case .previous: focusDirection = .previous
case .next: focusDirection = .next
case .up: focusDirection = .spatial(.up)
case .down: focusDirection = .spatial(.down)
case .left: focusDirection = .spatial(.left)
case .right: focusDirection = .spatial(.right)
}
// Find the node for the target surface
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
// Find the next surface to focus
guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else {
return
}
// Remove the zoomed state for this surface tree.
if surfaceTree.zoomed != nil {
surfaceTree = .init(root: surfaceTree.root, zoomed: nil)
}
// Move focus to the next surface
DispatchQueue.main.async {
Ghostty.moveFocus(to: nextSurface, from: target)
}
}
@objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) {
// The target must be within our tree
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
// Toggle the zoomed state
if surfaceTree.zoomed == targetNode {
// Already zoomed, unzoom it
surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil)
} else {
// Not zoomed or different node zoomed, zoom this node
surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode)
}
// Ensure focus stays on the target surface. We lose focus when we do
// this so we need to grab it again.
DispatchQueue.main.async {
Ghostty.moveFocus(to: target)
}
}
@objc private func ghosttyDidResizeSplit(_ notification: Notification) {
// The target must be within our tree
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
// Extract direction and amount from notification
guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return }
guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return }
guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return }
guard let amount = amountAny as? UInt16 else { return }
// Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction
let spatialDirection: SplitTree<Ghostty.SurfaceView>.Spatial.Direction
switch direction {
case .up: spatialDirection = .up
case .down: spatialDirection = .down
case .left: spatialDirection = .left
case .right: spatialDirection = .right
}
// Use viewBounds for the spatial calculation bounds
let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds())
// Perform the resize using the new SplitTree resize method
do {
surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds)
} catch {
Ghostty.logger.warning("failed to resize split: \(error)")
}
}
@ -277,20 +533,17 @@ class BaseTerminalController: NSWindowController,
}
private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? {
// Go through all our surfaces and notify it that the flags changed.
if let surfaceTree {
var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0.surface }
var surfaces: [Ghostty.SurfaceView] = surfaceTree.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 {
surfaces = surfaces.filter { $0 != focusedSurface }
}
for surface in surfaces {
surface.flagsChanged(with: event)
}
// 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 {
surfaces = surfaces.filter { $0 != focusedSurface }
}
for surface in surfaces {
surface.flagsChanged(with: event)
}
return event
@ -298,11 +551,6 @@ class BaseTerminalController: NSWindowController,
// MARK: TerminalViewDelegate
// Note: this is different from surfaceDidTreeChange(from:,to:) because this is called
// when the currently set value changed in place and the from:to: variant is called
// when the variable was set.
func surfaceTreeDidChange() {}
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
let lastFocusedSurface = focusedSurface
focusedSurface = to
@ -315,7 +563,7 @@ class BaseTerminalController: NSWindowController,
// want to care if the surface is in the tree so we don't listen to titles of
// closed surfaces.
if let titleSurface = focusedSurface ?? lastFocusedSurface,
surfaceTree?.contains(view: titleSurface) ?? false {
surfaceTree.contains(titleSurface) {
// If we have a surface, we want to listen for title changes.
titleSurface.$title
.sink { [weak self] in self?.titleDidChange(to: $0) }
@ -350,7 +598,15 @@ class BaseTerminalController: NSWindowController,
self.window?.contentResizeIncrements = to
}
func zoomStateDidChange(to: Bool) {}
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
let resizedNode = node.resize(to: newRatio)
do {
surfaceTree = try surfaceTree.replace(node: node, with: resizedNode)
} catch {
Ghostty.logger.warning("failed to replace node during split resize: \(error)")
return
}
}
func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) {
guard let surface = surfaceView.surface else { return }
@ -505,35 +761,21 @@ class BaseTerminalController: NSWindowController,
guard let window = self.window else { return true }
// If we have no surfaces, close.
guard let node = self.surfaceTree else { return true }
if surfaceTree.isEmpty { return true }
// If we already have an alert, continue with it
guard alert == nil else { return false }
// If our surfaces don't require confirmation, close.
if (!node.needsConfirmQuit()) { return true }
if !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true }
// We require confirmation, so show an alert as long as we aren't already.
let alert = NSAlert()
alert.messageText = "Close Terminal?"
alert.informativeText = "The terminal still has a running process. If you close the " +
"terminal the process will be killed."
alert.addButton(withTitle: "Close the Terminal")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window, completionHandler: { response in
self.alert = nil
switch (response) {
case .alertFirstButtonReturn:
alert.window.orderOut(nil)
window.close()
default:
break
}
})
self.alert = alert
confirmClose(
messageText: "Close Terminal?",
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
) {
window.close()
}
return false
}
@ -560,10 +802,9 @@ class BaseTerminalController: NSWindowController,
}
func windowDidChangeOcclusionState(_ notification: Notification) {
guard let surfaceTree = self.surfaceTree else { return }
let visible = self.window?.occlusionState.contains(.visible) ?? false
for leaf in surfaceTree {
if let surface = leaf.surface.surface {
for view in surfaceTree {
if let surface = view.surface {
ghostty_surface_set_occlusion(surface, visible)
}
}

View File

@ -32,7 +32,7 @@ class TerminalController: BaseTerminalController {
init(_ ghostty: Ghostty.App,
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: Ghostty.SplitNode? = nil
withSurfaceTree tree: 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
@ -105,11 +105,20 @@ class TerminalController: BaseTerminalController {
// MARK: Base Controller Overrides
override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
super.surfaceTreeDidChange(from: from, to: to)
// Whenever our surface tree changes in any way (new split, close split, etc.)
// we want to invalidate our state.
invalidateRestorableState()
// Update our zoom state
if let window = window as? TerminalWindow {
window.surfaceIsZoomed = to.zoomed != nil
}
// If our surface tree is now nil then we close our window.
if (to == nil) {
if (to.isEmpty) {
self.window?.close()
}
}
@ -143,8 +152,8 @@ class TerminalController: BaseTerminalController {
// If we have no surfaces in our window (is that possible?) then we update
// our window appearance based on the root config. If we have surfaces, we
// don't call this because the TODO
if surfaceTree == nil {
// don't call this because focused surface changes will trigger appearance updates.
if surfaceTree.isEmpty {
syncAppearance(.init(config))
}
@ -154,7 +163,7 @@ class TerminalController: BaseTerminalController {
// This is a surface-level config update. If we have the surface, we
// update our appearance based on it.
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree?.contains(view: surfaceView) ?? false else { return }
guard surfaceTree.contains(surfaceView) else { return }
// We can't use surfaceView.derivedConfig because it may not be updated
// yet since it also responds to notifications.
@ -228,6 +237,9 @@ class TerminalController: BaseTerminalController {
// Update our window light/darkness based on our updated background color
window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
// Sync our zoom state for splits
window.surfaceIsZoomed = surfaceTree.zoomed != nil
// If our window is not visible, then we do nothing. Some things such as blurring
// have no effect if the window is not visible. Ultimately, we'll have this called
// at some point when a surface becomes focused.
@ -269,15 +281,19 @@ class TerminalController: BaseTerminalController {
// If it does, we match the focused surface. If it doesn't, we use the app
// configuration.
let backgroundColor: OSColor
if let surfaceTree {
if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) {
if !surfaceTree.isEmpty {
if let focusedSurface = focusedSurface,
let treeRoot = surfaceTree.root,
let focusedNode = treeRoot.node(view: focusedSurface),
treeRoot.spatial().doesBorder(side: .up, from: focusedNode) {
// Similar to above, an alpha component of "0" causes compositor issues, so
// we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308
backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001)
} else {
// We don't have a focused surface or our surface doesn't border the
// top. We choose to match the color of the top-left most surface.
backgroundColor = OSColor(surfaceTree.topLeft().backgroundColor ?? derivedConfig.backgroundColor)
let topLeftSurface = surfaceTree.root?.leftmostLeaf()
backgroundColor = OSColor(topLeftSurface?.backgroundColor ?? derivedConfig.backgroundColor)
}
} else {
backgroundColor = OSColor(self.derivedConfig.backgroundColor)
@ -445,10 +461,10 @@ class TerminalController: BaseTerminalController {
// If we have only a single surface (no splits) and there is a default size then
// we should resize to that default size.
if case let .leaf(leaf) = surfaceTree {
if case let .leaf(view) = surfaceTree.root {
// If this is our first surface then our focused surface will be nil
// so we force the focused surface to the leaf.
focusedSurface = leaf.surface
focusedSurface = view
if let defaultSize {
window.setFrame(defaultSize, display: true)
@ -589,27 +605,6 @@ class TerminalController: BaseTerminalController {
ghostty.newTab(surface: surface)
}
private func confirmClose(
window: NSWindow,
messageText: String,
informativeText: String,
completion: @escaping () -> Void
) {
// If we need confirmation by any, show one confirmation for all windows
// in the tab group.
let alert = NSAlert()
alert.messageText = messageText
alert.informativeText = informativeText
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window) { response in
if response == .alertFirstButtonReturn {
completion()
}
}
}
@IBAction func closeTab(_ sender: Any?) {
guard let window = window else { return }
guard window.tabGroup != nil else {
@ -618,9 +613,8 @@ class TerminalController: BaseTerminalController {
return
}
if surfaceTree?.needsConfirmQuit() ?? false {
if surfaceTree.contains(where: { $0.needsConfirmQuit }) {
confirmClose(
window: window,
messageText: "Close Tab?",
informativeText: "The terminal still has a running process. If you close the tab the process will be killed."
) {
@ -656,7 +650,7 @@ class TerminalController: BaseTerminalController {
guard let controller = tabWindow.windowController as? TerminalController else {
return false
}
return controller.surfaceTree?.needsConfirmQuit() ?? false
return controller.surfaceTree.contains(where: { $0.needsConfirmQuit })
}
// If none need confirmation then we can just close all the windows.
@ -666,7 +660,6 @@ class TerminalController: BaseTerminalController {
}
confirmClose(
window: window,
messageText: "Close Window?",
informativeText: "All terminal sessions in this window will be terminated."
) {
@ -702,18 +695,7 @@ class TerminalController: BaseTerminalController {
toolbar.titleText = to
}
}
override func surfaceTreeDidChange() {
// Whenever our surface tree changes in any way (new split, close split, etc.)
// we want to invalidate our state.
invalidateRestorableState()
}
override func zoomStateDidChange(to: Bool) {
guard let window = window as? TerminalWindow else { return }
window.surfaceIsZoomed = to
}
override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
super.focusedSurfaceDidChange(to: to)
@ -846,19 +828,19 @@ class TerminalController: BaseTerminalController {
@objc private func onCloseTab(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree?.contains(view: target) ?? false else { return }
guard surfaceTree.contains(target) else { return }
closeTab(self)
}
@objc private func onCloseWindow(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree?.contains(view: target) ?? false else { return }
guard surfaceTree.contains(target) else { return }
closeWindow(self)
}
@objc private func onResetWindowSize(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree?.contains(view: target) ?? false else { return }
guard surfaceTree.contains(target) else { return }
returnToDefaultSize(nil)
}

View File

@ -197,7 +197,7 @@ 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: SplitTree<Ghostty.SurfaceView>? = nil) -> TerminalController {
// Initialize our controller to load the window
let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree)
@ -268,7 +268,7 @@ class TerminalManager {
func closeAllWindows() {
var needsConfirm: Bool = false
for w in self.windows {
if (w.controller.surfaceTree?.needsConfirmQuit() ?? false) {
if w.controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) {
needsConfirm = true
break
}

View File

@ -4,10 +4,10 @@ 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 surfaceTree: SplitTree<Ghostty.SurfaceView>
init(from controller: TerminalController) {
self.focusedSurface = controller.focusedSurface?.uuid.uuidString
@ -83,18 +83,29 @@ 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
)
guard let window = c.window else {
completionHandler(nil, TerminalRestoreError.windowDidNotLoad)
return
}
// Setup our restored state on the controller
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)
// Find the focused surface in surfaceTree
if let focusedStr = state.focusedSurface {
var foundView: Ghostty.SurfaceView?
for view in c.surfaceTree {
if view.uuid.uuidString == focusedStr {
foundView = view
break
}
}
if let view = foundView {
c.focusedSurface = view
restoreFocus(to: view, inWindow: window)
}
}
completionHandler(window, nil)

View File

@ -14,15 +14,11 @@ protocol TerminalViewDelegate: AnyObject {
/// The cell size changed.
func cellSizeDidChange(to: NSSize)
/// The surface tree did change in some way, i.e. a split was added, removed, etc. This is
/// not called initially.
func surfaceTreeDidChange()
/// This is called when a split is zoomed.
func zoomStateDidChange(to: Bool)
/// Perform an action. At the time of writing this is only triggered by the command palette.
func performAction(_ action: String, on: Ghostty.SurfaceView)
/// A split is resizing to a given value.
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double)
}
/// The view model is a required implementation for TerminalView callers. This contains
@ -31,7 +27,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 surfaceTree: Ghostty.SplitNode? { get set }
var surfaceTree: SplitTree<Ghostty.SurfaceView> { get set }
/// The command palette state.
var commandPaletteIsShowing: Bool { get set }
@ -57,7 +53,6 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
// Various state values sent back up from the currently focused terminals.
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
@FocusedValue(\.ghosttySurfacePwd) private var surfacePwd
@FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit
@FocusedValue(\.ghosttySurfaceCellSize) private var cellSize
// The pwd of the focused surface as a URL
@ -81,7 +76,9 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
DebugBuildWarningView()
}
Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
TerminalSplitTreeView(
tree: viewModel.surfaceTree,
onResize: { delegate?.splitDidResize(node: $0, to: $1) })
.environmentObject(ghostty)
.focused($focused)
.onAppear { self.focused = true }
@ -100,15 +97,6 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
guard let size = newValue else { return }
self.delegate?.cellSizeDidChange(to: size)
}
.onChange(of: viewModel.surfaceTree?.hashValue) { _ in
// This is funky, but its the best way I could think of to detect
// ANY CHANGE within the deeply nested surface tree -- detecting a change
// in the hash value.
self.delegate?.surfaceTreeDidChange()
}
.onChange(of: zoomedSplit) { newValue in
self.delegate?.zoomStateDidChange(to: newValue ?? false)
}
}
// Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : [])

View File

@ -387,6 +387,7 @@ class TerminalWindow: NSWindow {
if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) {
addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController)
}
resetZoomTitlebarAccessoryViewController.view.isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed
}

View File

@ -921,7 +921,7 @@ extension Ghostty {
// we should only be returning true if we actually performed the action,
// but this handles the most common case of caring about goto_split performability
// which is the no-split case.
guard controller.surfaceTree?.isSplit ?? false else { return false }
guard controller.surfaceTree.isSplit else { return false }
NotificationCenter.default.post(
name: Notification.ghosttyFocusSplit,

View File

@ -1,481 +0,0 @@
import SwiftUI
import Combine
import GhosttyKit
extension Ghostty {
/// This enum represents the possible states that a node in the split tree can be in. It is either:
///
/// - noSplit - This is an unsplit, single pane. This contains only a "leaf" which has a single
/// terminal surface to render.
/// - horizontal/vertical - This is split into the horizontal or vertical direction. This contains a
/// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These
/// values can further be split infinitely.
///
enum SplitNode: Equatable, Hashable, Codable, Sequence {
case leaf(Leaf)
case split(Container)
/// The parent of this node.
var parent: Container? {
get {
switch (self) {
case .leaf(let leaf):
return leaf.parent
case .split(let container):
return container.parent
}
}
set {
switch (self) {
case .leaf(let leaf):
leaf.parent = newValue
case .split(let container):
container.parent = newValue
}
}
}
/// Returns true if the tree is split.
var isSplit: Bool {
return if case .leaf = self {
false
} else {
true
}
}
func topLeft() -> SurfaceView {
switch (self) {
case .leaf(let leaf):
return leaf.surface
case .split(let container):
return container.topLeft.topLeft()
}
}
/// 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
/// next view to send focus to.
func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView {
let container: Container
switch (self) {
case .leaf(let leaf):
// noSplit is easy because there is only one thing to focus
return leaf.surface
case .split(let c):
container = c
}
let node: SplitNode
switch (direction) {
case .previous, .up, .left:
node = container.bottomRight
case .next, .down, .right:
node = container.topLeft
}
return node.preferredFocus(direction)
}
/// When direction is either next or previous, return the first or last
/// leaf. This can be used when the focus needs to move to a leaf even
/// after hitting the bottom-right-most or top-left-most surface.
/// When the direction is not next or previous (such as top, bottom,
/// left, right), it will be ignored and no leaf will be returned.
func firstOrLast(_ direction: SplitFocusDirection) -> Leaf? {
// If there is no parent, simply ignore.
guard let root = self.parent?.rootContainer() else { return nil }
switch (direction) {
case .next:
return root.firstLeaf()
case .previous:
return root.lastLeaf()
default:
return nil
}
}
/// Returns true if any surface in the split stack requires quit confirmation.
func needsConfirmQuit() -> Bool {
switch (self) {
case .leaf(let leaf):
return leaf.surface.needsConfirmQuit
case .split(let container):
return container.topLeft.needsConfirmQuit() ||
container.bottomRight.needsConfirmQuit()
}
}
/// Returns true if the split tree contains the given view.
func contains(view: SurfaceView) -> Bool {
return leaf(for: view) != nil
}
/// Find a surface view by UUID.
func findUUID(uuid: UUID) -> SurfaceView? {
switch (self) {
case .leaf(let leaf):
if (leaf.surface.uuid == uuid) {
return leaf.surface
}
return nil
case .split(let container):
return container.topLeft.findUUID(uuid: uuid) ??
container.bottomRight.findUUID(uuid: uuid)
}
}
/// Returns true if the surface borders the top. Assumes the view is in the tree.
func doesBorderTop(view: SurfaceView) -> Bool {
switch (self) {
case .leaf(let leaf):
return leaf.surface == view
case .split(let container):
switch (container.direction) {
case .vertical:
return container.topLeft.doesBorderTop(view: view)
case .horizontal:
return container.topLeft.doesBorderTop(view: view) ||
container.bottomRight.doesBorderTop(view: view)
}
}
}
/// Return the node for the given view if its in the tree.
func leaf(for view: SurfaceView) -> Leaf? {
switch (self) {
case .leaf(let leaf):
if leaf.surface == view {
return leaf
} else {
return nil
}
case .split(let container):
return container.topLeft.leaf(for: view) ??
container.bottomRight.leaf(for: view)
}
}
// MARK: - Sequence
func makeIterator() -> IndexingIterator<[Leaf]> {
return leaves().makeIterator()
}
/// Return all the leaves in this split node. This isn't very efficient but our split trees are never super
/// deep so its not an issue.
private func leaves() -> [Leaf] {
switch (self) {
case .leaf(let leaf):
return [leaf]
case .split(let container):
return container.topLeft.leaves() + container.bottomRight.leaves()
}
}
// MARK: - Equatable
static func == (lhs: SplitNode, rhs: SplitNode) -> Bool {
switch (lhs, rhs) {
case (.leaf(let lhs_v), .leaf(let rhs_v)):
return lhs_v === rhs_v
case (.split(let lhs_v), .split(let rhs_v)):
return lhs_v === rhs_v
default:
return false
}
}
class Leaf: ObservableObject, Equatable, Hashable, Codable {
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? = nil, uuid: UUID? = nil) {
self.app = app
self.surface = SurfaceView(app, baseConfig: baseConfig, uuid: uuid)
}
// MARK: - Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(app)
hasher.combine(surface)
}
// MARK: - Equatable
static func == (lhs: Leaf, rhs: Leaf) -> Bool {
return lhs.app == rhs.app && lhs.surface === rhs.surface
}
// 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 = 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(surface.pwd, forKey: .pwd)
try container.encode(surface.uuid.uuidString, forKey: .uuid)
}
}
class Container: ObservableObject, Equatable, Hashable, Codable {
let app: ghostty_app_t
let direction: SplitViewDirection
@Published var topLeft: SplitNode
@Published var bottomRight: SplitNode
@Published var split: CGFloat = 0.5
var resizeEvent: PassthroughSubject<Double, Never> = .init()
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, 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 = .leaf(from)
let bottomRight: Leaf = .init(app, baseConfig: baseConfig)
self.bottomRight = .leaf(bottomRight)
from.parent = self
bottomRight.parent = self
}
// Move the top left node to the bottom right and vice versa,
// preserving the size.
func swap() {
let topLeft: SplitNode = self.topLeft
self.topLeft = bottomRight
self.bottomRight = topLeft
self.split = 1 - self.split
}
/// Resize the split by moving the split divider in the given
/// direction by the given amount. If this container is not split
/// in the given direction, navigate up the tree until we find a
/// container that is
func resize(direction: SplitResizeDirection, amount: UInt16) {
// We send a resize event to our publisher which will be
// received by the SplitView.
switch (self.direction) {
case .horizontal:
switch (direction) {
case .left: resizeEvent.send(-Double(amount))
case .right: resizeEvent.send(Double(amount))
default: parent?.resize(direction: direction, amount: amount)
}
case .vertical:
switch (direction) {
case .up: resizeEvent.send(-Double(amount))
case .down: resizeEvent.send(Double(amount))
default: parent?.resize(direction: direction, amount: amount)
}
}
}
/// Equalize the splits in this container. Each split is equalized
/// based on its weight, i.e. the number of leaves it contains.
/// This function returns the weight of this container.
func equalize() -> UInt {
let topLeftWeight: UInt
switch (topLeft) {
case .leaf:
topLeftWeight = 1
case .split(let c):
topLeftWeight = c.equalize()
}
let bottomRightWeight: UInt
switch (bottomRight) {
case .leaf:
bottomRightWeight = 1
case .split(let c):
bottomRightWeight = c.equalize()
}
let weight = topLeftWeight + bottomRightWeight
split = Double(topLeftWeight) / Double(weight)
return weight
}
/// Returns the top most parent, or this container. Because this
/// would fall back to use to self, the return value is guaranteed.
func rootContainer() -> Container {
guard let parent = self.parent else { return self }
return parent.rootContainer()
}
/// Returns the first leaf from the given container. This is most
/// useful for root container, so that we can find the top-left-most
/// leaf.
func firstLeaf() -> Leaf {
switch (self.topLeft) {
case .leaf(let leaf):
return leaf
case .split(let s):
return s.firstLeaf()
}
}
/// Returns the last leaf from the given container. This is most
/// useful for root container, so that we can find the bottom-right-
/// most leaf.
func lastLeaf() -> Leaf {
switch (self.bottomRight) {
case .leaf(let leaf):
return leaf
case .split(let s):
return s.lastLeaf()
}
}
// MARK: - Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(app)
hasher.combine(direction)
hasher.combine(topLeft)
hasher.combine(bottomRight)
}
// MARK: - Equatable
static func == (lhs: Container, rhs: Container) -> Bool {
return lhs.app == rhs.app &&
lhs.direction == rhs.direction &&
lhs.topLeft == rhs.topLeft &&
lhs.bottomRight == rhs.bottomRight
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case direction
case split
case topLeft
case bottomRight
}
required 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)
self.app = app
self.direction = try container.decode(SplitViewDirection.self, forKey: .direction)
self.split = try container.decode(CGFloat.self, forKey: .split)
self.topLeft = try container.decode(SplitNode.self, forKey: .topLeft)
self.bottomRight = try container.decode(SplitNode.self, forKey: .bottomRight)
// Fix up the parent references
self.topLeft.parent = self
self.bottomRight.parent = self
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(direction, forKey: .direction)
try container.encode(split, forKey: .split)
try container.encode(topLeft, forKey: .topLeft)
try container.encode(bottomRight, forKey: .bottomRight)
}
}
/// This keeps track of the "neighbors" of a split: the immediately above/below/left/right
/// nodes. This is purposely weak so we don't have to worry about memory management
/// with this (although, it should always be correct).
struct Neighbors {
var left: SplitNode?
var right: SplitNode?
var up: SplitNode?
var down: SplitNode?
/// These are the previous/next nodes. It will certainly be one of the above as well
/// but we keep track of these separately because depending on the split direction
/// of the containing node, previous may be left OR up (same for next).
var previous: SplitNode?
var next: SplitNode?
/// No neighbors, used by the root node.
static let empty: Self = .init()
/// Get the node for a given direction.
func get(direction: SplitFocusDirection) -> SplitNode? {
let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [
.previous: \.previous,
.next: \.next,
.up: \.up,
.down: \.down,
.left: \.left,
.right: \.right,
]
guard let path = map[direction] else { return nil }
return self[keyPath: path]
}
/// Update multiple keys and return a new copy.
func update(_ attrs: [WritableKeyPath<Self, SplitNode?>: SplitNode?]) -> Self {
var clone = self
attrs.forEach { (key, value) in
clone[keyPath: key] = value
}
return clone
}
/// True if there are no neighbors
func isEmpty() -> Bool {
return self.previous == nil && self.next == nil
}
}
}
}

View File

@ -1,469 +0,0 @@
import SwiftUI
import GhosttyKit
extension Ghostty {
/// A spittable terminal view is one where the terminal allows for "splits" (vertical and horizontal) within the
/// view. The terminal starts in the unsplit state (a plain ol' TerminalView) but responds to changes to the
/// split direction by splitting the terminal.
///
/// This also allows one split to be "zoomed" at any time.
struct TerminalSplit: View {
/// The current state of the root node. This can be set to nil when all surfaces are closed.
@Binding var node: SplitNode?
/// Non-nil if one of the surfaces in the split tree is currently "zoomed." A zoomed surface
/// becomes "full screen" on the split tree.
@State private var zoomedSurface: SurfaceView? = nil
var body: some View {
ZStack {
TerminalSplitRoot(
node: $node,
zoomedSurface: $zoomedSurface
)
// If we have a zoomed surface, we overlay that on top of our split
// root. Our split root will become clear when there is a zoomed
// surface. We need to keep the split root around so that we don't
// lose all of the surface state so this must be a ZStack.
if let surfaceView = zoomedSurface {
InspectableSurface(surfaceView: surfaceView)
}
}
.focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil)
}
}
/// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever
/// one of these in a split tree.
private struct TerminalSplitRoot: View {
/// The root node that we're rendering. This will be set to nil if all the surfaces in this tree close.
@Binding var node: SplitNode?
/// Keeps track of whether we're in a zoomed split state or not. If one of the splits we own
/// is in the zoomed state, we clear our body since we expect a zoomed split to overlay
/// this one.
@Binding var zoomedSurface: SurfaceView?
var body: some View {
let center = NotificationCenter.default
let pubZoom = center.publisher(for: Notification.didToggleSplitZoom)
// If we're zoomed, we don't render anything, we are transparent. This
// ensures that the View stays around so we don't lose our state, but
// also that the zoomed view on top can see through if background transparency
// is enabled.
if (zoomedSurface == nil) {
ZStack {
switch (node) {
case nil:
Color(.clear)
case .leaf(let leaf):
TerminalSplitLeaf(
leaf: leaf,
neighbors: .empty,
node: $node
)
case .split(let container):
TerminalSplitContainer(
neighbors: .empty,
node: $node,
container: container
)
.onReceive(pubZoom) { onZoom(notification: $0) }
}
}
} else {
// On these events we want to reset the split state and call it.
let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!)
let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: zoomedSurface!)
let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: zoomedSurface!)
ZStack {}
.onReceive(pubZoom) { onZoomReset(notification: $0) }
.onReceive(pubSplit) { onZoomReset(notification: $0) }
.onReceive(pubClose) { onZoomReset(notification: $0) }
.onReceive(pubFocus) { onZoomReset(notification: $0) }
}
}
func onZoom(notification: SwiftUI.Notification) {
// Our node must be split to receive zooms. You can't zoom an unsplit terminal.
if case .leaf = node {
preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist")
}
// Make sure the notification has a surface and that this window owns the surface.
guard let surfaceView = notification.object as? SurfaceView else { return }
guard node?.contains(view: surfaceView) ?? false else { return }
// We are in the zoomed state.
zoomedSurface = surfaceView
// See onZoomReset, same logic.
DispatchQueue.main.async { Ghostty.moveFocus(to: surfaceView) }
}
func onZoomReset(notification: SwiftUI.Notification) {
// Make sure the notification has a surface and that this window owns the surface.
guard let surfaceView = notification.object as? SurfaceView else { return }
guard zoomedSurface == surfaceView else { return }
// We are now unzoomed
zoomedSurface = nil
// We need to stay focused on this view, but the view is going to change
// superviews. We need to do this async so it happens on the next event loop
// tick.
DispatchQueue.main.async {
Ghostty.moveFocus(to: surfaceView)
// If the notification is not a toggle zoom notification, we want to re-publish
// it after a short delay so that the split tree has a chance to re-establish
// so the proper view gets this notification.
if (notification.name != Notification.didToggleSplitZoom) {
// We have to wait ANOTHER tick since we just established.
DispatchQueue.main.async {
NotificationCenter.default.post(notification)
}
}
}
}
}
/// A noSplit leaf node of a split tree.
private struct TerminalSplitLeaf: View {
/// The leaf to draw the surface for.
let leaf: SplitNode.Leaf
/// The neighbors, used for navigation.
let neighbors: SplitNode.Neighbors
/// The SplitNode that the leaf belongs to. This will be set to nil when leaf is closed.
@Binding var node: SplitNode?
var body: some View {
let center = NotificationCenter.default
let pub = center.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface)
let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface)
let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: leaf.surface)
let pubResize = center.publisher(for: Notification.didResizeSplit, object: leaf.surface)
InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty())
.onReceive(pub) { onNewSplit(notification: $0) }
.onReceive(pubClose) { onClose(notification: $0) }
.onReceive(pubFocus) { onMoveFocus(notification: $0) }
.onReceive(pubResize) { onResize(notification: $0) }
}
private func onClose(notification: SwiftUI.Notification) {
var processAlive = false
if let valueAny = notification.userInfo?["process_alive"] {
if let value = valueAny as? Bool {
processAlive = value
}
}
// If the child process is not alive, then we exit immediately
guard processAlive else {
node = nil
return
}
// If we don't have a window to attach our modal to, we also exit immediately.
// This should NOT happen.
guard let window = leaf.surface.window else {
node = nil
return
}
// Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
// due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
// confirmationDialog allows the user to Cmd-W close the alert, but when doing
// so SwiftUI does not update any of the bindings to note that window is no longer
// being shown, and provides no callback to detect this.
let alert = NSAlert()
alert.messageText = "Close Terminal?"
alert.informativeText = "The terminal still has a running process. If you close the " +
"terminal the process will be killed."
alert.addButton(withTitle: "Close the Terminal")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window, completionHandler: { response in
switch (response) {
case .alertFirstButtonReturn:
alert.window.orderOut(nil)
node = nil
default:
break
}
})
}
private func onNewSplit(notification: SwiftUI.Notification) {
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? SurfaceConfiguration
// 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: SplitViewDirection
let swap: Bool
switch (direction) {
case GHOSTTY_SPLIT_DIRECTION_RIGHT:
splitDirection = .horizontal
swap = false
case GHOSTTY_SPLIT_DIRECTION_LEFT:
splitDirection = .horizontal
swap = true
case GHOSTTY_SPLIT_DIRECTION_DOWN:
splitDirection = .vertical
swap = false
case GHOSTTY_SPLIT_DIRECTION_UP:
splitDirection = .vertical
swap = true
default:
return
}
// Setup our new container since we are now split
let container = SplitNode.Container(from: leaf, direction: splitDirection, baseConfig: config)
// 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())
// If we are swapping, swap now. We do this after our focus event
// so that focus is in the right place.
if swap {
container.swap()
}
}
/// This handles the event to move the split focus (i.e. previous/next) from a keyboard event.
private func onMoveFocus(notification: SwiftUI.Notification) {
// Determine our desired direction
guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return }
guard let direction = directionAny as? SplitFocusDirection else { return }
// Find the next surface to move to. In most cases this should be
// finding the neighbor in provided direction, and focus it. When
// the neighbor cannot be found based on next or previous direction,
// this would instead search for first or last leaf and focus it
// instead, giving the wrap around effect.
// When other directions are provided, this can be nil, and early
// returned.
guard let nextSurface = neighbors.get(direction: direction)?.preferredFocus(direction)
?? node?.firstOrLast(direction)?.surface else { return }
Ghostty.moveFocus(
to: nextSurface
)
}
/// Handle a resize event.
private func onResize(notification: SwiftUI.Notification) {
// If this leaf is not part of a split then there is nothing to do
guard let parent = leaf.parent else { return }
guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return }
guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return }
guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return }
guard let amount = amountAny as? UInt16 else { return }
parent.resize(direction: direction, amount: amount)
}
}
/// This represents a split view that is in the horizontal or vertical split state.
private struct TerminalSplitContainer: View {
@EnvironmentObject var ghostty: Ghostty.App
let neighbors: SplitNode.Neighbors
@Binding var node: SplitNode?
@ObservedObject var container: SplitNode.Container
var body: some View {
SplitView(
container.direction,
$container.split,
dividerColor: ghostty.config.splitDividerColor,
resizeIncrements: .init(width: 1, height: 1),
resizePublisher: container.resizeEvent,
left: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.down
TerminalSplitNested(
node: closeableTopLeft(),
neighbors: neighbors.update([
neighborKey: container.bottomRight,
\.next: container.bottomRight,
])
)
}, right: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.up
TerminalSplitNested(
node: closeableBottomRight(),
neighbors: neighbors.update([
neighborKey: container.topLeft,
\.previous: container.topLeft,
])
)
})
}
private func closeableTopLeft() -> Binding<SplitNode?> {
return .init(get: {
container.topLeft
}, set: { newValue in
if let newValue {
container.topLeft = newValue
return
}
// Closing
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(),
from: container.topLeft.preferredFocus()
)
}
})
}
private func closeableBottomRight() -> Binding<SplitNode?> {
return .init(get: {
container.bottomRight
}, set: { newValue in
if let newValue {
container.bottomRight = newValue
return
}
// Closing
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(),
from: container.bottomRight.preferredFocus()
)
}
})
}
}
/// This is like TerminalSplitRoot, but... not the root. This renders a SplitNode in any state but
/// requires there be a binding to the parent node.
private struct TerminalSplitNested: View {
@Binding var node: SplitNode?
let neighbors: SplitNode.Neighbors
var body: some View {
Group {
switch (node) {
case nil:
Color(.clear)
case .leaf(let leaf):
TerminalSplitLeaf(
leaf: leaf,
neighbors: neighbors,
node: $node
)
case .split(let container):
TerminalSplitContainer(
neighbors: neighbors,
node: $node,
container: container
)
}
}
.id(node)
}
}
/// When changing the split state, or going full screen (native or non), the terminal view
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
/// figure it out so we're going to do this hacky thing to bring focus back to the terminal
/// that should have it.
static func moveFocus(
to: SurfaceView,
from: SurfaceView? = nil,
delay: TimeInterval? = nil
) {
// The whole delay machinery is a bit of a hack to work around a
// situation where the window is destroyed and the surface view
// will never be attached to a window. Realistically, we should
// handle this upstream but we also don't want this function to be
// a source of infinite loops.
// Our max delay before we give up
let maxDelay: TimeInterval = 0.5
guard (delay ?? 0) < maxDelay else { return }
// We start at a 50 millisecond delay and do a doubling backoff
let nextDelay: TimeInterval = if let delay {
delay * 2
} else {
// 100 milliseconds
0.05
}
let work: DispatchWorkItem = .init {
// If the callback runs before the surface is attached to a view
// then the window will be nil. We just reschedule in that case.
guard let window = to.window else {
moveFocus(to: to, from: from, delay: nextDelay)
return
}
// If we had a previously focused node and its not where we're sending
// focus, make sure that we explicitly tell it to lose focus. In theory
// we should NOT have to do this but the focus callback isn't getting
// called for some reason.
if let from = from {
_ = from.resignFirstResponder()
}
window.makeFirstResponder(to)
}
let queue = DispatchQueue.main
if let delay {
queue.asyncAfter(deadline: .now() + delay, execute: work)
} else {
queue.async(execute: work)
}
}
}

View File

@ -460,6 +460,62 @@ extension Ghostty {
return config
}
}
#if canImport(AppKit)
/// When changing the split state, or going full screen (native or non), the terminal view
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
/// figure it out so we're going to do this hacky thing to bring focus back to the terminal
/// that should have it.
static func moveFocus(
to: SurfaceView,
from: SurfaceView? = nil,
delay: TimeInterval? = nil
) {
// The whole delay machinery is a bit of a hack to work around a
// situation where the window is destroyed and the surface view
// will never be attached to a window. Realistically, we should
// handle this upstream but we also don't want this function to be
// a source of infinite loops.
// Our max delay before we give up
let maxDelay: TimeInterval = 0.5
guard (delay ?? 0) < maxDelay else { return }
// We start at a 50 millisecond delay and do a doubling backoff
let nextDelay: TimeInterval = if let delay {
delay * 2
} else {
// 100 milliseconds
0.05
}
let work: DispatchWorkItem = .init {
// If the callback runs before the surface is attached to a view
// then the window will be nil. We just reschedule in that case.
guard let window = to.window else {
moveFocus(to: to, from: from, delay: nextDelay)
return
}
// If we had a previously focused node and its not where we're sending
// focus, make sure that we explicitly tell it to lose focus. In theory
// we should NOT have to do this but the focus callback isn't getting
// called for some reason.
if let from = from {
_ = from.resignFirstResponder()
}
window.makeFirstResponder(to)
}
let queue = DispatchQueue.main
if let delay {
queue.asyncAfter(deadline: .now() + delay, execute: work)
} else {
queue.async(execute: work)
}
}
#endif
}
// MARK: Surface Environment Keys
@ -502,15 +558,6 @@ extension FocusedValues {
typealias Value = String
}
var ghosttySurfaceZoomed: Bool? {
get { self[FocusedGhosttySurfaceZoomed.self] }
set { self[FocusedGhosttySurfaceZoomed.self] = newValue }
}
struct FocusedGhosttySurfaceZoomed: FocusedValueKey {
typealias Value = Bool
}
var ghosttySurfaceCellSize: OSSize? {
get { self[FocusedGhosttySurfaceCellSize.self] }
set { self[FocusedGhosttySurfaceCellSize.self] = newValue }

View File

@ -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)
}
}
}

View File

@ -0,0 +1,19 @@
extension Array {
/// Returns the index before i, with wraparound. Assumes i is a valid index.
func indexWrapping(before i: Int) -> Int {
if i == 0 {
return count - 1
}
return i - 1
}
/// Returns the index after i, with wraparound. Assumes i is a valid index.
func indexWrapping(after i: Int) -> Int {
if i == count - 1 {
return 0
}
return i + 1
}
}

View File

@ -1,6 +1,19 @@
import AppKit
extension NSView {
/// Returns true if this view is currently in the responder chain
var isInResponderChain: Bool {
var responder = window?.firstResponder
while let currentResponder = responder {
if currentResponder === self {
return true
}
responder = currentResponder.nextResponder
}
return false
}
/// Recursively finds and returns the first descendant view that has the given class name.
func firstDescendant(withClassName name: String) -> NSView? {
for subview in subviews {

View File

@ -274,6 +274,39 @@ fn actionCommands(action: Action.Key) []const Command {
},
},
.goto_split => comptime &.{
.{
.action = .{ .goto_split = .previous },
.title = "Focus Split: Previous",
.description = "Focus the previous split, if any.",
},
.{
.action = .{ .goto_split = .next },
.title = "Focus Split: Next",
.description = "Focus the next split, if any.",
},
.{
.action = .{ .goto_split = .left },
.title = "Focus Split: Left",
.description = "Focus the split to the left, if it exists.",
},
.{
.action = .{ .goto_split = .right },
.title = "Focus Split: Right",
.description = "Focus the split to the right, if it exists.",
},
.{
.action = .{ .goto_split = .up },
.title = "Focus Split: Up",
.description = "Focus the split above, if it exists.",
},
.{
.action = .{ .goto_split = .down },
.title = "Focus Split: Down",
.description = "Focus the split below, if it exists.",
},
},
.toggle_split_zoom => comptime &.{.{
.action = .toggle_split_zoom,
.title = "Toggle Split Zoom",
@ -396,7 +429,6 @@ fn actionCommands(action: Action.Key) []const Command {
.jump_to_prompt,
.write_scrollback_file,
.goto_tab,
.goto_split,
.resize_split,
.crash,
=> comptime &.{},