From 170715944185885ea404ed3dc4c12513f9a4678e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 2 Jun 2025 16:56:57 -0700 Subject: [PATCH] new SplitTree --- macos/Ghostty.xcodeproj/project.pbxproj | 16 + macos/Sources/Features/Splits/SplitTree.swift | 314 ++++++++++++++++++ .../Splits/TerminalSplitTreeView.swift | 62 ++++ .../Terminal/BaseTerminalController.swift | 70 +++- .../Terminal/TerminalController.swift | 3 + .../Features/Terminal/TerminalView.swift | 10 +- .../Features/Terminal/TerminalWindow.swift | 2 + 7 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 macos/Sources/Features/Splits/SplitTree.swift create mode 100644 macos/Sources/Features/Splits/TerminalSplitTreeView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a34c4685f..459b2b994 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -59,6 +59,8 @@ 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 */; }; 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 */; }; @@ -164,6 +166,8 @@ A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; }; A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = ""; }; A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; }; + A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = ""; }; + A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -275,6 +279,7 @@ A5CBD05A2CA0C5910017A1AE /* QuickTerminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A57D79252C9C8782001D522E /* Secure Input */, + A58636622DEF955100E04A10 /* Splits */, A53A29742DB2E04900B6E02C /* Command Palette */, A534263E2A7DCC5800EBB7A2 /* Settings */, A51BFC1C2B2FB5AB00E92F16 /* About */, @@ -428,6 +433,15 @@ path = "Secure Input"; sourceTree = ""; }; + A58636622DEF955100E04A10 /* Splits */ = { + isa = PBXGroup; + children = ( + A586365E2DEE6C2100E04A10 /* SplitTree.swift */, + A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */, + ); + path = Splits; + sourceTree = ""; + }; A5874D9B2DAD781100E83852 /* Private */ = { isa = PBXGroup; children = ( @@ -685,6 +699,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 */, @@ -734,6 +749,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 */, diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift new file mode 100644 index 000000000..6f98dfefc --- /dev/null +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -0,0 +1,314 @@ +import AppKit + +/// SplitTree represents a tree of views that can be divided. +struct SplitTree { + /// The root of the tree. This can be nil to indicate the tree is empty. + let root: Node? + + /// The node that is currently zoomed. A zoomed split is expected to take up the full + /// size of the view area where the splits are shown. + let zoomed: Node? + + /// A single node in the tree is either a leaf node (a view) or a split (has a + /// left/right or top/bottom). + indirect enum Node { + case leaf(view: NSView) + case split(Split) + + struct Split: Equatable { + let direction: Direction + let ratio: Double + let left: Node + let right: Node + } + } + + enum Direction { + case horizontal // Splits are laid out left and right + case vertical // Splits are laid out top and bottom + } + + /// The path to a specific node in the tree. + struct Path { + let path: [Component] + + var isEmpty: Bool { path.isEmpty } + + enum Component { + case left + case right + } + } + + enum SplitError: Error { + case viewNotFound + } + + enum NewDirection { + case left + case right + case down + case up + } +} + +// MARK: SplitTree + +extension SplitTree { + var isEmpty: Bool { + root == nil + } + + init() { + self.init(root: nil, zoomed: nil) + } + + init(view: NSView) { + self.init(root: .leaf(view: view), zoomed: nil) + } + + /// Insert a new view at the given view point by creating a split in the given direction. + func insert(view: NSView, at: NSView, direction: NewDirection) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + return .init( + root: try root.insert(view: view, at: at, direction: direction), + zoomed: zoomed) + } + + /// Remove a node from the tree. If the node being removed is part of a split, + /// the sibling node takes the place of the parent split. + func remove(_ target: Node) -> Self { + guard let root else { return self } + + // If we're removing the root itself, return an empty tree + if root == target { + return .init(root: nil, zoomed: nil) + } + + // Otherwise, try to remove from the tree + let newRoot = root.remove(target) + + // Update zoomed if it was the removed node + let newZoomed = (zoomed == target) ? nil : zoomed + + return .init(root: newRoot, zoomed: newZoomed) + } +} + +// MARK: SplitTree.Node + +extension SplitTree.Node { + typealias Node = SplitTree.Node + typealias NewDirection = SplitTree.NewDirection + typealias SplitError = SplitTree.SplitError + typealias Path = SplitTree.Path + + /// Returns the node in the tree that contains the given view. + func node(view: NSView) -> Node? { + switch (self) { + case .leaf(view): + return self + + case .split(let split): + if let result = split.left.node(view: view) { + return result + } else if let result = split.right.node(view: view) { + return result + } + + return nil + + default: + return nil + } + } + + /// Returns the path to a given node in the tree. If the returned value is nil then the + /// node doesn't exist. + func path(to node: Self) -> Path? { + var components: [Path.Component] = [] + func search(_ current: Self) -> Bool { + if current == node { + return true + } + + switch current { + case .leaf: + return false + + case .split(let split): + // Try left branch + components.append(.left) + if search(split.left) { + return true + } + components.removeLast() + + // Try right branch + components.append(.right) + if search(split.right) { + return true + } + components.removeLast() + + return false + } + } + + return search(self) ? Path(path: components) : nil + } + + /// Inserts a new view into the split tree by creating a split at the location of an existing view. + /// + /// This method creates a new split node containing both the existing view and the new view, + /// The position of the new view relative to the existing view is determined by the direction parameter. + /// + /// - Parameters: + /// - view: The new view to insert into the tree + /// - at: The existing view at whose location the split should be created + /// - direction: The direction relative to the existing view where the new view should be placed + /// + /// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should + /// maybe throw instead but at the moment we just do nothing. + func insert(view: NSView, at: NSView, direction: NewDirection) throws -> Self { + // Get the path to our insertion point. If it doesn't exist we do + // nothing. + guard let path = path(to: .leaf(view: at)) else { + throw SplitError.viewNotFound + } + + // Determine split direction and which side the new view goes on + let splitDirection: SplitTree.Direction + let newViewOnLeft: Bool + switch direction { + case .left: + splitDirection = .horizontal + newViewOnLeft = true + case .right: + splitDirection = .horizontal + newViewOnLeft = false + case .up: + splitDirection = .vertical + newViewOnLeft = true + case .down: + splitDirection = .vertical + newViewOnLeft = false + } + + // Create the new split node + let newNode: Node = .leaf(view: view) + let existingNode: Node = .leaf(view: at) + let newSplit: Node = .split(.init( + direction: splitDirection, + ratio: 0.5, + left: newViewOnLeft ? newNode : existingNode, + right: newViewOnLeft ? existingNode : newNode + )) + + // Replace the node at the path with the new split + return try replaceNode(at: path, with: newSplit) + } + + /// Helper function to replace a node at the given path from the root + private func replaceNode(at path: Path, with newNode: Self) throws -> Self { + // If path is empty, replace the root + if path.isEmpty { + return newNode + } + + // Otherwise, we need to replace the proper left/right all along + // the way since Node is a value type (enum). To do that, we need + // recursion. We can't use a simple iterative approach because we + // can't update in-place. + func replaceInner(current: Node, pathOffset: Int) throws -> Node { + // Base case: if we've consumed the entire path, replace this node + if pathOffset >= path.path.count { + return newNode + } + + // We need to go deeper, so current must be a split for the path + // to be valid. Otherwise, the path is invalid. + guard case .split(let split) = current else { + throw SplitError.viewNotFound + } + + let component = path.path[pathOffset] + switch component { + case .left: + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: try replaceInner(current: split.left, pathOffset: pathOffset + 1), + right: split.right + )) + case .right: + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: split.left, + right: try replaceInner(current: split.right, pathOffset: pathOffset + 1) + )) + } + } + + return try replaceInner(current: self, pathOffset: 0) + } + + /// Remove a node from the tree. Returns the modified tree, or nil if removing + /// the node results in an empty tree. + func remove(_ target: Node) -> Node? { + // If we're removing ourselves, return nil + if self == target { + return nil + } + + switch self { + case .leaf: + // A leaf that isn't the target stays as is + return self + + case .split(let split): + // Neither child is directly the target, so we need to recursively + // try to remove from both children + let newLeft = split.left.remove(target) + let newRight = split.right.remove(target) + + // If both are nil then we remove everything. This shouldn't ever + // happen because duplicate nodes shouldn't exist, but we want to + // be robust against it. + if newLeft == nil && newRight == nil { + return nil + } else if newLeft == nil { + return newRight + } else if newRight == nil { + return newLeft + } + + // Both children still exist after removal + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: newLeft!, + right: newRight! + )) + } + } +} + +// MARK: SplitTree.Node Protocols + +extension SplitTree.Node: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.leaf(leftView), .leaf(rightView)): + // Compare NSView instances by object identity + return leftView === rightView + + case let (.split(split1), .split(split2)): + return split1 == split2 + + default: + return false + } + } +} diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift new file mode 100644 index 000000000..c55192e44 --- /dev/null +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct TerminalSplitTreeView: View { + let tree: SplitTree + + var body: some View { + if let node = tree.root { + TerminalSplitSubtreeView(node: node, isRoot: true) + } + } +} + +struct TerminalSplitSubtreeView: View { + let node: SplitTree.Node + var isRoot: Bool = false + + var body: some View { + switch (node) { + case .leaf(let leafView): + // TODO: Fix the as! + Ghostty.InspectableSurface( + surfaceView: leafView as! Ghostty.SurfaceView, + isSplit: !isRoot) + + case .split(let split): + TerminalSplitSplitView(split: split) + } + } +} + +struct TerminalSplitSplitView: View { + @EnvironmentObject var ghostty: Ghostty.App + + let split: SplitTree.Node.Split + + private var splitViewDirection: SplitViewDirection { + switch (split.direction) { + case .horizontal: .horizontal + case .vertical: .vertical + } + } + + var body: some View { + SplitView( + splitViewDirection, + .init(get: { + CGFloat(split.ratio) + }, set: { _ in + // TODO + }), + dividerColor: ghostty.config.splitDividerColor, + resizeIncrements: .init(width: 1, height: 1), + resizePublisher: .init(), + left: { + TerminalSplitSubtreeView(node: split.left) + }, + right: { + TerminalSplitSubtreeView(node: split.right) + } + ) + } +} diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index fd5ca9ffb..628c0acbf 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -46,6 +46,8 @@ class BaseTerminalController: NSWindowController, didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } + @Published var surfaceTree2: SplitTree = .init() + /// This can be set to show/hide the command palette. @Published var commandPaletteIsShowing: Bool = false @@ -97,6 +99,9 @@ class BaseTerminalController: NSWindowController, guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + let firstView = Ghostty.SurfaceView(ghostty_app, baseConfig: base) + self.surfaceTree2 = .init(view: firstView) + // Setup our notifications for behaviors let center = NotificationCenter.default center.addObserver( @@ -124,6 +129,18 @@ 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(_:)), @@ -252,7 +269,58 @@ class BaseTerminalController: NSWindowController, guard surfaceTree?.contains(view: surfaceView) ?? false else { return } window.zoom(nil) } - + + @objc private func ghosttyDidCloseSurface(_ notification: Notification) { + // The target must be within our tree + guard let oldView = notification.object as? Ghostty.SurfaceView else { return } + guard let node = surfaceTree2.root?.node(view: oldView) else { return } + + // Remove it + surfaceTree2 = surfaceTree2.remove(node) + + // TODO: fix focus + } + + @objc private func ghosttyDidNewSplit(_ notification: Notification) { + // The target must be within our tree + guard let oldView = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree2.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.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 { + surfaceTree2 = try surfaceTree2.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. + // TODO: log + 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 } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f2868adb0..d8f42bb1a 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -228,6 +228,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 = surfaceTree2.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. diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 7caceb071..d2f4d8bdb 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -31,7 +31,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 surfaceTree2: SplitTree { get set } /// The command palette state. var commandPaletteIsShowing: Bool { get set } @@ -81,7 +81,7 @@ struct TerminalView: View { DebugBuildWarningView() } - Ghostty.TerminalSplit(node: $viewModel.surfaceTree) + TerminalSplitTreeView(tree: viewModel.surfaceTree2) .environmentObject(ghostty) .focused($focused) .onAppear { self.focused = true } @@ -100,12 +100,6 @@ struct TerminalView: 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) } diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 9a2bdc60f..f244b95ee 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -30,6 +30,7 @@ class TerminalWindow: NSWindow { observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in guard let tabGroup = self?.tabGroup else { return } + Ghostty.logger.warning("WOW \(window.surfaceIsZoomed)") self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed self?.updateResetZoomTitlebarButtonVisibility() }, @@ -375,6 +376,7 @@ class TerminalWindow: NSWindow { if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) { addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController) } + resetZoomTitlebarAccessoryViewController.view.isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed }