diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 1e49f968e..bdfd9c2b1 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -11,164 +11,218 @@ extension Ghostty { var body: some View { if let app = app { - TerminalSplitContainer(app: app) + TerminalSplitRoot(app: app) .navigationTitle(surfaceTitle ?? "Ghostty") } } } - private struct TerminalSplitPane: View { - @ObservedObject var surfaceView: SurfaceView - @Binding var requestSplit: SplitViewDirection? - @Binding var requestClose: Bool - + /// 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 { + case noSplit(Leaf) + case horizontal(Container) + case vertical(Container) + + /// Returns the view that would prefer receiving focus in this tree. This is always the + /// top-left-most view. This is used when creating a split or closing a split to find the + /// next view to send focus to. + func preferredFocus() -> SurfaceView { + switch (self) { + case .noSplit(let leaf): + return leaf.surface + + case .horizontal(let container): + return container.topLeft.preferredFocus() + + case .vertical(let container): + return container.topLeft.preferredFocus() + } + } + + class Leaf: ObservableObject { + let app: ghostty_app_t + @Published var surface: SurfaceView + @Published var parent: SplitNode? = nil + + /// Initialize a new leaf which creates a new terminal surface. + init(_ app: ghostty_app_t) { + self.app = app + self.surface = SurfaceView(app) + } + } + + class Container: ObservableObject { + let app: ghostty_app_t + @Published var topLeft: SplitNode + @Published var bottomRight: SplitNode + + /// 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) { + self.app = from.app + + // Initially, both topLeft and bottomRight are in the "nosplit" + // state since this is a new split. + self.topLeft = .noSplit(from) + self.bottomRight = .noSplit(.init(app)) + } + } + } + + /// 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 { + @State private var node: SplitNode + + /// This is an ignored value because at the root we can't close. + @State private var ignoredRequestClose: Bool = false + + init(app: ghostty_app_t) { + _node = State(initialValue: SplitNode.noSplit(.init(app))) + } + var body: some View { - let pub = NotificationCenter.default.publisher(for: Notification.ghosttyNewSplit, object: surfaceView) - let pubClose = NotificationCenter.default.publisher(for: Notification.ghosttyCloseSurface, object: surfaceView) - SurfaceWrapper(surfaceView: surfaceView) + switch (node) { + case .noSplit(let leaf): + TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $ignoredRequestClose) + + case .horizontal(let container): + TerminalSplitContainer(direction: .horizontal, node: $node, container: container) + + case .vertical(let container): + TerminalSplitContainer(direction: .vertical, node: $node, container: container) + } + } + } + + /// A noSplit leaf node of a split tree. + private struct TerminalSplitLeaf: View { + /// The leaf to draw the surface for. + let leaf: SplitNode.Leaf + + /// The SplitNode that the leaf belongs to. + @Binding var node: SplitNode + + /// This will be set to true when the split requests that is become closed. + @Binding var requestClose: Bool + + var body: some View { + let pub = NotificationCenter.default.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface) + let pubClose = NotificationCenter.default.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface) + SurfaceWrapper(surfaceView: leaf.surface) .onReceive(pub) { onNewSplit(notification: $0) } .onReceive(pubClose) { _ in requestClose = true } } private func onNewSplit(notification: SwiftUI.Notification) { + // Determine our desired direction guard let directionAny = notification.userInfo?["direction"] else { return } guard let direction = directionAny as? ghostty_split_direction_e else { return } + var splitDirection: SplitViewDirection switch (direction) { case GHOSTTY_SPLIT_RIGHT: - requestSplit = .horizontal + splitDirection = .horizontal case GHOSTTY_SPLIT_DOWN: - requestSplit = .vertical + splitDirection = .vertical default: - break - } - } - } - - private struct TerminalSplitContainer: View { - let app: ghostty_app_t - var parentClose: Binding? = nil - @State private var direction: SplitViewDirection? = nil - @State private var proposedDirection: SplitViewDirection? = nil - @State private var closeTopLeft: Bool = false - @State private var closeBottomRight: Bool = false - @StateObject private var panes: PaneState - - class PaneState: ObservableObject { - @Published var topLeft: SurfaceView - @Published var bottomRight: SurfaceView? = nil - - /// Initialize the view state for the first time. This will create our topLeft view from new. - init(_ app: ghostty_app_t) { - self.topLeft = SurfaceView(app) + return } - /// Initialize the view state using an existing top left. This is usually used when a split happens and - /// the child view inherits the top left. - init(topLeft: SurfaceView) { - self.topLeft = topLeft - } - } - - init(app: ghostty_app_t) { - self.app = app - _panes = StateObject(wrappedValue: PaneState(app)) - } - - init(app: ghostty_app_t, parentClose: Binding, topLeft: SurfaceView) { - self.app = app - self.parentClose = parentClose - _panes = StateObject(wrappedValue: PaneState(topLeft: topLeft)) - } - - var body: some View { - if let direction = self.direction { - SplitView(direction, left: { - TerminalSplitContainer( - app: app, - parentClose: $closeTopLeft, - topLeft: panes.topLeft - ) - .onChange(of: closeTopLeft) { value in - guard value else { return } - - // Move our bottom to our top and reset all of our state - panes.topLeft = panes.bottomRight! - panes.bottomRight = nil - self.direction = nil - closeTopLeft = false - closeBottomRight = false - - // See fixFocus comment, we have to run this whenever split changes. - fixFocus() - } - }, right: { - TerminalSplitContainer( - app: app, - parentClose: $closeBottomRight, - topLeft: panes.bottomRight! - ) - .onChange(of: closeBottomRight) { value in - guard value else { return } - - // Move our bottom to our top and reset all of our state - panes.bottomRight = nil - self.direction = nil - closeTopLeft = false - closeBottomRight = false - - // See fixFocus comment, we have to run this whenever split changes. - fixFocus() - } - }) - } else { - TerminalSplitPane(surfaceView: panes.topLeft, requestSplit: $proposedDirection, requestClose: $closeTopLeft) - .onChange(of: proposedDirection) { value in - guard let newDirection = value else { return } - split(to: newDirection) - } - .onChange(of: closeTopLeft) { value in - guard value else { return } - self.parentClose?.wrappedValue = value - } - } - } - - private func split(to: SplitViewDirection) { - assert(direction == nil) + // Setup our new container since we are now split + let container = SplitNode.Container(from: leaf) - // Make the split the desired value - direction = to + // Depending on the direction, change the parent node. This will trigger + // the parent to relayout our views. + switch (splitDirection) { + case .horizontal: + node = .horizontal(container) + case .vertical: + node = .vertical(container) + } - // Create the new split which always goes to the bottom right. - panes.bottomRight = SurfaceView(app) - // See fixFocus comment, we have to run this whenever split changes. - fixFocus() + Self.fixFocus(container.bottomRight) } /// There is a bug I can't figure out where when changing the split state, 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. - private func fixFocus() { + fileprivate static func fixFocus(_ target: SplitNode) { + let view = target.preferredFocus() + DispatchQueue.main.async { - // The view we want to focus - var view = panes.topLeft - if let right = panes.bottomRight { view = right } - // 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 = view.window else { - self.fixFocus() + self.fixFocus(target) return } - _ = panes.topLeft.resignFirstResponder() - _ = panes.bottomRight?.resignFirstResponder() window.makeFirstResponder(view) } } } + + /// This represents a split view that is in the horizontal or vertical split state. + private struct TerminalSplitContainer: View { + let direction: SplitViewDirection + @Binding var node: SplitNode + @StateObject var container: SplitNode.Container + + @State private var closeTopLeft: Bool = false + @State private var closeBottomRight: Bool = false + + var body: some View { + SplitView(direction, left: { + TerminalSplitNested(node: $container.topLeft, requestClose: $closeTopLeft) + .onChange(of: closeTopLeft) { value in + guard value else { return } + + // When closing the topLeft, our parent becomes the bottomRight. + node = container.bottomRight + TerminalSplitLeaf.fixFocus(node) + } + }, right: { + TerminalSplitNested(node: $container.bottomRight, requestClose: $closeBottomRight) + .onChange(of: closeBottomRight) { value in + guard value else { return } + + // When closing the bottomRight, our parent becomes the topLeft. + node = container.topLeft + TerminalSplitLeaf.fixFocus(node) + } + }) + } + } + + /// 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 + @Binding var requestClose: Bool + + var body: some View { + switch (node) { + case .noSplit(let leaf): + TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $requestClose) + + case .horizontal(let container): + TerminalSplitContainer(direction: .horizontal, node: $node, container: container) + + case .vertical(let container): + TerminalSplitContainer(direction: .vertical, node: $node, container: container) + } + } + } }