macos: remove the old split implementation

pull/7523/head
Mitchell Hashimoto 2025-06-04 13:20:14 -07:00
parent 69c3c359cb
commit 9474092f77
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
4 changed files with 54 additions and 957 deletions

View File

@ -70,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 */; };
@ -178,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>"; };
@ -409,8 +405,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 */,
@ -690,7 +684,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 */,
@ -737,7 +730,6 @@
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 */,

View File

@ -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 {
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
}
}
}
}

View File

@ -1,468 +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)
}
}
}
}
/// 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)
}
}
}

View File

@ -460,6 +460,60 @@ extension Ghostty {
return config
}
}
/// 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)
}
}
}
// MARK: Surface Environment Keys