macos: remove the old split implementation
parent
69c3c359cb
commit
9474092f77
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue