ghostty/macos/Sources/Ghostty/Ghostty.SplitView.swift
2023-03-08 15:26:58 -08:00

167 lines
6.7 KiB
Swift

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.
struct TerminalSplit: View {
@Environment(\.ghosttyApp) private var app
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String?
var body: some View {
if let app = app {
TerminalSplitChild(app)
.navigationTitle(surfaceTitle ?? "Ghostty")
}
}
}
private struct TerminalSplitChild: View {
enum Direction {
case none
case vertical
case horizontal
}
/// 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
/// 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)
}
/// 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) {
self.topLeft = topLeft
}
}
let app: ghostty_app_t
@StateObject private var state: ViewState
init(_ app: ghostty_app_t) {
self.app = app
_state = StateObject(wrappedValue: ViewState(app))
}
init(_ app: ghostty_app_t, topLeft: Ghostty.SurfaceView) {
self.app = app
_state = StateObject(wrappedValue: ViewState(topLeft: topLeft))
}
func split(to: Direction) {
assert(to != .none)
assert(state.direction == .none)
// Make the split the desired value
state.direction = to
// Create the new split which always goes to the bottom right.
state.bottomRight = Ghostty.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
/// that should have it.
private func fixFocus() {
DispatchQueue.main.async {
// The view we want to focus
var view = state.topLeft
if let right = state.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()
return
}
_ = state.topLeft.resignFirstResponder()
_ = state.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() }
})
}
}
}
}