mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-05-28 11:08:39 +03:00
356 lines
14 KiB
Swift
356 lines
14 KiB
Swift
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 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 = .top) -> 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, .bottom, .left:
|
|
node = container.bottomRight
|
|
|
|
case .next, .top, .right:
|
|
node = container.topLeft
|
|
}
|
|
|
|
return node.preferredFocus(direction)
|
|
}
|
|
|
|
/// Close the surface associated with this node. This will likely deinitialize the
|
|
/// surface. At this point, the surface view in this node tree can never be used again.
|
|
func close() {
|
|
switch (self) {
|
|
case .leaf(let leaf):
|
|
leaf.surface.close()
|
|
|
|
case .split(let container):
|
|
container.topLeft.close()
|
|
container.bottomRight.close()
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
switch (self) {
|
|
case .leaf(let leaf):
|
|
return leaf.surface == view
|
|
|
|
case .split(let container):
|
|
return container.topLeft.contains(view: view) ||
|
|
container.bottomRight.contains(view: view)
|
|
}
|
|
}
|
|
|
|
// 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: NSUUID? = 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 = NSUUID(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
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
// 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 top: SplitNode?
|
|
var bottom: 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 top (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,
|
|
.top: \.top,
|
|
.bottom: \.bottom,
|
|
.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
|
|
}
|
|
}
|
|
}
|
|
}
|