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
commit
efc1ceab5d
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 : [])
|
||||
|
|
|
|||
|
|
@ -387,6 +387,7 @@ class TerminalWindow: NSWindow {
|
|||
if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) {
|
||||
addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController)
|
||||
}
|
||||
|
||||
resetZoomTitlebarAccessoryViewController.view.isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 &.{},
|
||||
|
|
|
|||
Loading…
Reference in New Issue