From 31378bcaa5e03f3b141130bab1ee4c3aee4a0d4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Mar 2023 22:14:29 -0800 Subject: [PATCH] macos: redo all the split views --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 208 +++++++++--------- macos/Sources/Ghostty/SurfaceView.swift | 16 +- .../Sources/SplitView/SplitView.Divider.swift | 2 +- macos/Sources/SplitView/SplitView.swift | 10 +- 4 files changed, 124 insertions(+), 112 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 2b0f1a29e..1e49f968e 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -11,90 +11,143 @@ extension Ghostty { var body: some View { if let app = app { - TerminalSplitChild(app) + TerminalSplitContainer(app: app) .navigationTitle(surfaceTitle ?? "Ghostty") } } } - private struct TerminalSplitChild: View { - enum Direction { - case none - case vertical - case horizontal + private struct TerminalSplitPane: View { + @ObservedObject var surfaceView: SurfaceView + @Binding var requestSplit: SplitViewDirection? + @Binding var requestClose: Bool + + 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) + .onReceive(pub) { onNewSplit(notification: $0) } + .onReceive(pubClose) { _ in requestClose = true } } - /// The stored state between invocations. - class ViewState: ObservableObject { - /// The direction of the split currently - @Published var direction: Direction = .none - - /// The top or left view. This is always set. - @Published var topLeft: Ghostty.SurfaceView - - /// The bottom or right view. This can be nil if the direction == .none. - @Published var bottomRight: Ghostty.SurfaceView? = nil + private func onNewSplit(notification: SwiftUI.Notification) { + guard let directionAny = notification.userInfo?["direction"] else { return } + guard let direction = directionAny as? ghostty_split_direction_e else { return } + switch (direction) { + case GHOSTTY_SPLIT_RIGHT: + requestSplit = .horizontal + + case GHOSTTY_SPLIT_DOWN: + requestSplit = .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 = Ghostty.SurfaceView(app) + self.topLeft = SurfaceView(app) } /// 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: Ghostty.SurfaceView) { + init(topLeft: SurfaceView) { self.topLeft = topLeft } } - let app: ghostty_app_t - @StateObject private var state: ViewState - - init(_ app: ghostty_app_t) { + init(app: ghostty_app_t) { self.app = app - _state = StateObject(wrappedValue: ViewState(app)) + _panes = StateObject(wrappedValue: PaneState(app)) } - init(_ app: ghostty_app_t, topLeft: Ghostty.SurfaceView) { + init(app: ghostty_app_t, parentClose: Binding, topLeft: SurfaceView) { self.app = app - _state = StateObject(wrappedValue: ViewState(topLeft: topLeft)) + 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 + } + } } - func split(to: Direction) { - assert(to != .none) - assert(state.direction == .none) + private func split(to: SplitViewDirection) { + assert(direction == nil) // Make the split the desired value - state.direction = to + direction = to // Create the new split which always goes to the bottom right. - state.bottomRight = Ghostty.SurfaceView(app) + panes.bottomRight = SurfaceView(app) // See fixFocus comment, we have to run this whenever split changes. fixFocus() } - func closeTopLeft() { - assert(state.direction != .none) - assert(state.bottomRight != nil) - state.topLeft = state.bottomRight! - state.bottomRight = nil - state.direction = .none - - // See fixFocus comment, we have to run this whenever split changes. - fixFocus() - } - - func closeBottomRight() { - assert(state.direction != .none) - assert(state.bottomRight != nil) - state.bottomRight = nil - state.direction = .none - - // See fixFocus comment, we have to run this whenever split changes. - fixFocus() - } - /// 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 @@ -102,8 +155,8 @@ extension Ghostty { private func fixFocus() { DispatchQueue.main.async { // The view we want to focus - var view = state.topLeft - if let right = state.bottomRight { view = right } + 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. @@ -112,55 +165,10 @@ extension Ghostty { return } - _ = state.topLeft.resignFirstResponder() - _ = state.bottomRight?.resignFirstResponder() + _ = panes.topLeft.resignFirstResponder() + _ = panes.bottomRight?.resignFirstResponder() window.makeFirstResponder(view) } } - - private func onNewSplit(notification: SwiftUI.Notification) { - guard let directionAny = notification.userInfo?["direction"] else { return } - guard let direction = directionAny as? ghostty_split_direction_e else { return } - switch (direction) { - case GHOSTTY_SPLIT_RIGHT: - split(to: .horizontal) - - case GHOSTTY_SPLIT_DOWN: - split(to: .vertical) - - default: - break - } - } - - var body: some View { - switch (state.direction) { - case .none: - let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyNewSplit, object: state.topLeft) - SurfaceWrapper(surfaceView: state.topLeft) - .onReceive(pub) { onNewSplit(notification: $0) } - case .horizontal: - SplitView(.horizontal, left: { - let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.topLeft) - TerminalSplitChild(app, topLeft: state.topLeft) - .onReceive(pub) { _ in closeTopLeft() } - }, right: { - let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.bottomRight!) - TerminalSplitChild(app, topLeft: state.bottomRight!) - .onReceive(pub) { _ in closeBottomRight() } - }) - case .vertical: - SplitView(.vertical, left: { - let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.topLeft) - TerminalSplitChild(app, topLeft: state.topLeft) - .onReceive(pub) { _ in closeTopLeft() } - }, right: { - let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.bottomRight!) - TerminalSplitChild(app, topLeft: state.bottomRight!) - .onReceive(pub) { _ in closeBottomRight() } - }) - } - } } - } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 76c123fcd..776c762a9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -9,21 +9,27 @@ extension Ghostty { var body: some View { if let app = self.app { - TerminalForApp(app) - .navigationTitle(surfaceTitle ?? "Ghostty") + SurfaceForApp(app) { surfaceView in + SurfaceWrapper(surfaceView: surfaceView) + } + .navigationTitle(surfaceTitle ?? "Ghostty") } } } - private struct TerminalForApp: View { + /// Yields a SurfaceView for a ghostty app that can then be used however you want. + struct SurfaceForApp: View { + let content: ((SurfaceView) -> Content) + @StateObject private var surfaceView: SurfaceView - init(_ app: ghostty_app_t) { + init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) { _surfaceView = StateObject(wrappedValue: SurfaceView(app)) + self.content = content } var body: some View { - SurfaceWrapper(surfaceView: surfaceView) + content(surfaceView) } } diff --git a/macos/Sources/SplitView/SplitView.Divider.swift b/macos/Sources/SplitView/SplitView.Divider.swift index a5e3eb946..aba1c48f4 100644 --- a/macos/Sources/SplitView/SplitView.Divider.swift +++ b/macos/Sources/SplitView/SplitView.Divider.swift @@ -3,7 +3,7 @@ import SwiftUI extension SplitView { /// The split divider that is rendered and can be used to resize a split view. struct Divider: View { - let direction: Direction + let direction: SplitViewDirection let visibleSize: CGFloat let invisibleSize: CGFloat diff --git a/macos/Sources/SplitView/SplitView.swift b/macos/Sources/SplitView/SplitView.swift index 68973bdbb..c28b6b578 100644 --- a/macos/Sources/SplitView/SplitView.swift +++ b/macos/Sources/SplitView/SplitView.swift @@ -7,7 +7,7 @@ import SwiftUI /// as time goes on. For example, the splitter divider size and styling is all hardcoded. struct SplitView: View { /// Direction of the split - let direction: Direction + let direction: SplitViewDirection /// The left and right views to render. let left: L @@ -41,7 +41,7 @@ struct SplitView: View { } } - init(_ direction: Direction, @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R)) { + init(_ direction: SplitViewDirection, @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R)) { self.direction = direction self.left = left() self.right = right() @@ -114,8 +114,6 @@ struct SplitView: View { } } -extension SplitView { - enum Direction { - case horizontal, vertical - } +enum SplitViewDirection { + case horizontal, vertical }