mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-17 09:16:11 +03:00
new SplitTree
This commit is contained in:
@ -59,6 +59,8 @@
|
|||||||
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
|
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
|
||||||
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; };
|
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; };
|
||||||
A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; };
|
A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; };
|
||||||
|
A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; };
|
||||||
|
A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; };
|
||||||
A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; };
|
A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; };
|
||||||
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; };
|
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; };
|
||||||
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
|
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
|
||||||
@ -164,6 +166,8 @@
|
|||||||
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = "<group>"; };
|
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = "<group>"; };
|
||||||
A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = "<group>"; };
|
A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = "<group>"; };
|
||||||
A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = "<group>"; };
|
A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = "<group>"; };
|
||||||
|
A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = "<group>"; };
|
||||||
|
A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = "<group>"; };
|
||||||
A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = "<group>"; };
|
A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = "<group>"; };
|
||||||
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = "<group>"; };
|
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = "<group>"; };
|
||||||
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
@ -275,6 +279,7 @@
|
|||||||
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */,
|
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */,
|
||||||
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
|
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
|
||||||
A57D79252C9C8782001D522E /* Secure Input */,
|
A57D79252C9C8782001D522E /* Secure Input */,
|
||||||
|
A58636622DEF955100E04A10 /* Splits */,
|
||||||
A53A29742DB2E04900B6E02C /* Command Palette */,
|
A53A29742DB2E04900B6E02C /* Command Palette */,
|
||||||
A534263E2A7DCC5800EBB7A2 /* Settings */,
|
A534263E2A7DCC5800EBB7A2 /* Settings */,
|
||||||
A51BFC1C2B2FB5AB00E92F16 /* About */,
|
A51BFC1C2B2FB5AB00E92F16 /* About */,
|
||||||
@ -428,6 +433,15 @@
|
|||||||
path = "Secure Input";
|
path = "Secure Input";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A58636622DEF955100E04A10 /* Splits */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A586365E2DEE6C2100E04A10 /* SplitTree.swift */,
|
||||||
|
A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */,
|
||||||
|
);
|
||||||
|
path = Splits;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A5874D9B2DAD781100E83852 /* Private */ = {
|
A5874D9B2DAD781100E83852 /* Private */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -685,6 +699,7 @@
|
|||||||
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
|
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
|
||||||
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
|
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
|
||||||
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
|
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
|
||||||
|
A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */,
|
||||||
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
|
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
|
||||||
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
|
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
|
||||||
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
|
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
|
||||||
@ -734,6 +749,7 @@
|
|||||||
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */,
|
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */,
|
||||||
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */,
|
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */,
|
||||||
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */,
|
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */,
|
||||||
|
A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */,
|
||||||
A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */,
|
A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */,
|
||||||
AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */,
|
AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */,
|
||||||
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */,
|
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */,
|
||||||
|
314
macos/Sources/Features/Splits/SplitTree.swift
Normal file
314
macos/Sources/Features/Splits/SplitTree.swift
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// SplitTree represents a tree of views that can be divided.
|
||||||
|
struct SplitTree {
|
||||||
|
/// The root of the tree. This can be nil to indicate the tree is empty.
|
||||||
|
let root: Node?
|
||||||
|
|
||||||
|
/// The node that is currently zoomed. A zoomed split is expected to take up the full
|
||||||
|
/// size of the view area where the splits are shown.
|
||||||
|
let zoomed: Node?
|
||||||
|
|
||||||
|
/// A single node in the tree is either a leaf node (a view) or a split (has a
|
||||||
|
/// left/right or top/bottom).
|
||||||
|
indirect enum Node {
|
||||||
|
case leaf(view: NSView)
|
||||||
|
case split(Split)
|
||||||
|
|
||||||
|
struct Split: Equatable {
|
||||||
|
let direction: Direction
|
||||||
|
let ratio: Double
|
||||||
|
let left: Node
|
||||||
|
let right: Node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Direction {
|
||||||
|
case horizontal // Splits are laid out left and right
|
||||||
|
case vertical // Splits are laid out top and bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path to a specific node in the tree.
|
||||||
|
struct Path {
|
||||||
|
let path: [Component]
|
||||||
|
|
||||||
|
var isEmpty: Bool { path.isEmpty }
|
||||||
|
|
||||||
|
enum Component {
|
||||||
|
case left
|
||||||
|
case right
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SplitError: Error {
|
||||||
|
case viewNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NewDirection {
|
||||||
|
case left
|
||||||
|
case right
|
||||||
|
case down
|
||||||
|
case up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: SplitTree
|
||||||
|
|
||||||
|
extension SplitTree {
|
||||||
|
var isEmpty: Bool {
|
||||||
|
root == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.init(root: nil, zoomed: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(view: NSView) {
|
||||||
|
self.init(root: .leaf(view: view), zoomed: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a new view at the given view point by creating a split in the given direction.
|
||||||
|
func insert(view: NSView, at: NSView, direction: NewDirection) throws -> Self {
|
||||||
|
guard let root else { throw SplitError.viewNotFound }
|
||||||
|
return .init(
|
||||||
|
root: try root.insert(view: view, at: at, direction: direction),
|
||||||
|
zoomed: zoomed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a node from the tree. If the node being removed is part of a split,
|
||||||
|
/// the sibling node takes the place of the parent split.
|
||||||
|
func remove(_ target: Node) -> Self {
|
||||||
|
guard let root else { return self }
|
||||||
|
|
||||||
|
// If we're removing the root itself, return an empty tree
|
||||||
|
if root == target {
|
||||||
|
return .init(root: nil, zoomed: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to remove from the tree
|
||||||
|
let newRoot = root.remove(target)
|
||||||
|
|
||||||
|
// Update zoomed if it was the removed node
|
||||||
|
let newZoomed = (zoomed == target) ? nil : zoomed
|
||||||
|
|
||||||
|
return .init(root: newRoot, zoomed: newZoomed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: SplitTree.Node
|
||||||
|
|
||||||
|
extension SplitTree.Node {
|
||||||
|
typealias Node = SplitTree.Node
|
||||||
|
typealias NewDirection = SplitTree.NewDirection
|
||||||
|
typealias SplitError = SplitTree.SplitError
|
||||||
|
typealias Path = SplitTree.Path
|
||||||
|
|
||||||
|
/// Returns the node in the tree that contains the given view.
|
||||||
|
func node(view: NSView) -> Node? {
|
||||||
|
switch (self) {
|
||||||
|
case .leaf(view):
|
||||||
|
return self
|
||||||
|
|
||||||
|
case .split(let split):
|
||||||
|
if let result = split.left.node(view: view) {
|
||||||
|
return result
|
||||||
|
} else if let result = split.right.node(view: view) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path to a given node in the tree. If the returned value is nil then the
|
||||||
|
/// node doesn't exist.
|
||||||
|
func path(to node: Self) -> Path? {
|
||||||
|
var components: [Path.Component] = []
|
||||||
|
func search(_ current: Self) -> Bool {
|
||||||
|
if current == node {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch current {
|
||||||
|
case .leaf:
|
||||||
|
return false
|
||||||
|
|
||||||
|
case .split(let split):
|
||||||
|
// Try left branch
|
||||||
|
components.append(.left)
|
||||||
|
if search(split.left) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
components.removeLast()
|
||||||
|
|
||||||
|
// Try right branch
|
||||||
|
components.append(.right)
|
||||||
|
if search(split.right) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
components.removeLast()
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return search(self) ? Path(path: components) : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts a new view into the split tree by creating a split at the location of an existing view.
|
||||||
|
///
|
||||||
|
/// This method creates a new split node containing both the existing view and the new view,
|
||||||
|
/// The position of the new view relative to the existing view is determined by the direction parameter.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - view: The new view to insert into the tree
|
||||||
|
/// - at: The existing view at whose location the split should be created
|
||||||
|
/// - direction: The direction relative to the existing view where the new view should be placed
|
||||||
|
///
|
||||||
|
/// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should
|
||||||
|
/// maybe throw instead but at the moment we just do nothing.
|
||||||
|
func insert(view: NSView, at: NSView, direction: NewDirection) throws -> Self {
|
||||||
|
// Get the path to our insertion point. If it doesn't exist we do
|
||||||
|
// nothing.
|
||||||
|
guard let path = path(to: .leaf(view: at)) else {
|
||||||
|
throw SplitError.viewNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine split direction and which side the new view goes on
|
||||||
|
let splitDirection: SplitTree.Direction
|
||||||
|
let newViewOnLeft: Bool
|
||||||
|
switch direction {
|
||||||
|
case .left:
|
||||||
|
splitDirection = .horizontal
|
||||||
|
newViewOnLeft = true
|
||||||
|
case .right:
|
||||||
|
splitDirection = .horizontal
|
||||||
|
newViewOnLeft = false
|
||||||
|
case .up:
|
||||||
|
splitDirection = .vertical
|
||||||
|
newViewOnLeft = true
|
||||||
|
case .down:
|
||||||
|
splitDirection = .vertical
|
||||||
|
newViewOnLeft = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the new split node
|
||||||
|
let newNode: Node = .leaf(view: view)
|
||||||
|
let existingNode: Node = .leaf(view: at)
|
||||||
|
let newSplit: Node = .split(.init(
|
||||||
|
direction: splitDirection,
|
||||||
|
ratio: 0.5,
|
||||||
|
left: newViewOnLeft ? newNode : existingNode,
|
||||||
|
right: newViewOnLeft ? existingNode : newNode
|
||||||
|
))
|
||||||
|
|
||||||
|
// Replace the node at the path with the new split
|
||||||
|
return try replaceNode(at: path, with: newSplit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to replace a node at the given path from the root
|
||||||
|
private func replaceNode(at path: Path, with newNode: Self) throws -> Self {
|
||||||
|
// If path is empty, replace the root
|
||||||
|
if path.isEmpty {
|
||||||
|
return newNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, we need to replace the proper left/right all along
|
||||||
|
// the way since Node is a value type (enum). To do that, we need
|
||||||
|
// recursion. We can't use a simple iterative approach because we
|
||||||
|
// can't update in-place.
|
||||||
|
func replaceInner(current: Node, pathOffset: Int) throws -> Node {
|
||||||
|
// Base case: if we've consumed the entire path, replace this node
|
||||||
|
if pathOffset >= path.path.count {
|
||||||
|
return newNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to go deeper, so current must be a split for the path
|
||||||
|
// to be valid. Otherwise, the path is invalid.
|
||||||
|
guard case .split(let split) = current else {
|
||||||
|
throw SplitError.viewNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
let component = path.path[pathOffset]
|
||||||
|
switch component {
|
||||||
|
case .left:
|
||||||
|
return .split(.init(
|
||||||
|
direction: split.direction,
|
||||||
|
ratio: split.ratio,
|
||||||
|
left: try replaceInner(current: split.left, pathOffset: pathOffset + 1),
|
||||||
|
right: split.right
|
||||||
|
))
|
||||||
|
case .right:
|
||||||
|
return .split(.init(
|
||||||
|
direction: split.direction,
|
||||||
|
ratio: split.ratio,
|
||||||
|
left: split.left,
|
||||||
|
right: try replaceInner(current: split.right, pathOffset: pathOffset + 1)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try replaceInner(current: self, pathOffset: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a node from the tree. Returns the modified tree, or nil if removing
|
||||||
|
/// the node results in an empty tree.
|
||||||
|
func remove(_ target: Node) -> Node? {
|
||||||
|
// If we're removing ourselves, return nil
|
||||||
|
if self == target {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch self {
|
||||||
|
case .leaf:
|
||||||
|
// A leaf that isn't the target stays as is
|
||||||
|
return self
|
||||||
|
|
||||||
|
case .split(let split):
|
||||||
|
// Neither child is directly the target, so we need to recursively
|
||||||
|
// try to remove from both children
|
||||||
|
let newLeft = split.left.remove(target)
|
||||||
|
let newRight = split.right.remove(target)
|
||||||
|
|
||||||
|
// If both are nil then we remove everything. This shouldn't ever
|
||||||
|
// happen because duplicate nodes shouldn't exist, but we want to
|
||||||
|
// be robust against it.
|
||||||
|
if newLeft == nil && newRight == nil {
|
||||||
|
return nil
|
||||||
|
} else if newLeft == nil {
|
||||||
|
return newRight
|
||||||
|
} else if newRight == nil {
|
||||||
|
return newLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both children still exist after removal
|
||||||
|
return .split(.init(
|
||||||
|
direction: split.direction,
|
||||||
|
ratio: split.ratio,
|
||||||
|
left: newLeft!,
|
||||||
|
right: newRight!
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: SplitTree.Node Protocols
|
||||||
|
|
||||||
|
extension SplitTree.Node: Equatable {
|
||||||
|
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case let (.leaf(leftView), .leaf(rightView)):
|
||||||
|
// Compare NSView instances by object identity
|
||||||
|
return leftView === rightView
|
||||||
|
|
||||||
|
case let (.split(split1), .split(split2)):
|
||||||
|
return split1 == split2
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
62
macos/Sources/Features/Splits/TerminalSplitTreeView.swift
Normal file
62
macos/Sources/Features/Splits/TerminalSplitTreeView.swift
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TerminalSplitTreeView: View {
|
||||||
|
let tree: SplitTree
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let node = tree.root {
|
||||||
|
TerminalSplitSubtreeView(node: node, isRoot: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TerminalSplitSubtreeView: View {
|
||||||
|
let node: SplitTree.Node
|
||||||
|
var isRoot: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch (node) {
|
||||||
|
case .leaf(let leafView):
|
||||||
|
// TODO: Fix the as!
|
||||||
|
Ghostty.InspectableSurface(
|
||||||
|
surfaceView: leafView as! Ghostty.SurfaceView,
|
||||||
|
isSplit: !isRoot)
|
||||||
|
|
||||||
|
case .split(let split):
|
||||||
|
TerminalSplitSplitView(split: split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TerminalSplitSplitView: View {
|
||||||
|
@EnvironmentObject var ghostty: Ghostty.App
|
||||||
|
|
||||||
|
let split: SplitTree.Node.Split
|
||||||
|
|
||||||
|
private var splitViewDirection: SplitViewDirection {
|
||||||
|
switch (split.direction) {
|
||||||
|
case .horizontal: .horizontal
|
||||||
|
case .vertical: .vertical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SplitView(
|
||||||
|
splitViewDirection,
|
||||||
|
.init(get: {
|
||||||
|
CGFloat(split.ratio)
|
||||||
|
}, set: { _ in
|
||||||
|
// TODO
|
||||||
|
}),
|
||||||
|
dividerColor: ghostty.config.splitDividerColor,
|
||||||
|
resizeIncrements: .init(width: 1, height: 1),
|
||||||
|
resizePublisher: .init(),
|
||||||
|
left: {
|
||||||
|
TerminalSplitSubtreeView(node: split.left)
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
TerminalSplitSubtreeView(node: split.right)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -46,6 +46,8 @@ class BaseTerminalController: NSWindowController,
|
|||||||
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) }
|
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Published var surfaceTree2: SplitTree = .init()
|
||||||
|
|
||||||
/// This can be set to show/hide the command palette.
|
/// This can be set to show/hide the command palette.
|
||||||
@Published var commandPaletteIsShowing: Bool = false
|
@Published var commandPaletteIsShowing: Bool = false
|
||||||
|
|
||||||
@ -97,6 +99,9 @@ class BaseTerminalController: NSWindowController,
|
|||||||
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
|
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
|
||||||
self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base))
|
self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base))
|
||||||
|
|
||||||
|
let firstView = Ghostty.SurfaceView(ghostty_app, baseConfig: base)
|
||||||
|
self.surfaceTree2 = .init(view: firstView)
|
||||||
|
|
||||||
// Setup our notifications for behaviors
|
// Setup our notifications for behaviors
|
||||||
let center = NotificationCenter.default
|
let center = NotificationCenter.default
|
||||||
center.addObserver(
|
center.addObserver(
|
||||||
@ -124,6 +129,18 @@ class BaseTerminalController: NSWindowController,
|
|||||||
selector: #selector(ghosttyMaximizeDidToggle(_:)),
|
selector: #selector(ghosttyMaximizeDidToggle(_:)),
|
||||||
name: .ghosttyMaximizeDidToggle,
|
name: .ghosttyMaximizeDidToggle,
|
||||||
object: nil)
|
object: nil)
|
||||||
|
|
||||||
|
// Splits
|
||||||
|
center.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(ghosttyDidCloseSurface(_:)),
|
||||||
|
name: Ghostty.Notification.ghosttyCloseSurface,
|
||||||
|
object: nil)
|
||||||
|
center.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(ghosttyDidNewSplit(_:)),
|
||||||
|
name: Ghostty.Notification.ghosttyNewSplit,
|
||||||
|
object: nil)
|
||||||
center.addObserver(
|
center.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(ghosttyDidEqualizeSplits(_:)),
|
selector: #selector(ghosttyDidEqualizeSplits(_:)),
|
||||||
@ -252,7 +269,58 @@ class BaseTerminalController: NSWindowController,
|
|||||||
guard surfaceTree?.contains(view: surfaceView) ?? false else { return }
|
guard surfaceTree?.contains(view: surfaceView) ?? false else { return }
|
||||||
window.zoom(nil)
|
window.zoom(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func ghosttyDidCloseSurface(_ notification: Notification) {
|
||||||
|
// The target must be within our tree
|
||||||
|
guard let oldView = notification.object as? Ghostty.SurfaceView else { return }
|
||||||
|
guard let node = surfaceTree2.root?.node(view: oldView) else { return }
|
||||||
|
|
||||||
|
// Remove it
|
||||||
|
surfaceTree2 = surfaceTree2.remove(node)
|
||||||
|
|
||||||
|
// TODO: fix focus
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func ghosttyDidNewSplit(_ notification: Notification) {
|
||||||
|
// The target must be within our tree
|
||||||
|
guard let oldView = notification.object as? Ghostty.SurfaceView else { return }
|
||||||
|
guard surfaceTree2.root?.node(view: oldView) != nil else { return }
|
||||||
|
|
||||||
|
// Notification must contain our base config
|
||||||
|
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
|
||||||
|
let config = configAny as? Ghostty.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: SplitTree.NewDirection
|
||||||
|
switch (direction) {
|
||||||
|
case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right
|
||||||
|
case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left
|
||||||
|
case GHOSTTY_SPLIT_DIRECTION_DOWN: splitDirection = .down
|
||||||
|
case GHOSTTY_SPLIT_DIRECTION_UP: splitDirection = .up
|
||||||
|
default: return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new surface view
|
||||||
|
guard let ghostty_app = ghostty.app else { return }
|
||||||
|
let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
|
||||||
|
|
||||||
|
// Do the split
|
||||||
|
do {
|
||||||
|
surfaceTree2 = try surfaceTree2.insert(view: newView, at: oldView, direction: splitDirection)
|
||||||
|
} catch {
|
||||||
|
// If splitting fails for any reason (it should not), then we just log
|
||||||
|
// and return. The new view we created will be deinitialized and its
|
||||||
|
// no big deal.
|
||||||
|
// TODO: log
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once we've split, we need to move focus to the new split
|
||||||
|
Ghostty.moveFocus(to: newView, from: oldView)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func ghosttyDidEqualizeSplits(_ notification: Notification) {
|
@objc private func ghosttyDidEqualizeSplits(_ notification: Notification) {
|
||||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||||
|
|
||||||
|
@ -228,6 +228,9 @@ class TerminalController: BaseTerminalController {
|
|||||||
// Update our window light/darkness based on our updated background color
|
// Update our window light/darkness based on our updated background color
|
||||||
window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
|
window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
|
||||||
|
|
||||||
|
// Sync our zoom state for splits
|
||||||
|
window.surfaceIsZoomed = surfaceTree2.zoomed != nil
|
||||||
|
|
||||||
// If our window is not visible, then we do nothing. Some things such as blurring
|
// If our window is not visible, then we do nothing. Some things such as blurring
|
||||||
// have no effect if the window is not visible. Ultimately, we'll have this called
|
// have no effect if the window is not visible. Ultimately, we'll have this called
|
||||||
// at some point when a surface becomes focused.
|
// at some point when a surface becomes focused.
|
||||||
|
@ -31,7 +31,7 @@ protocol TerminalViewDelegate: AnyObject {
|
|||||||
protocol TerminalViewModel: ObservableObject {
|
protocol TerminalViewModel: ObservableObject {
|
||||||
/// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView
|
/// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView
|
||||||
/// and children. This should be @Published.
|
/// and children. This should be @Published.
|
||||||
var surfaceTree: Ghostty.SplitNode? { get set }
|
var surfaceTree2: SplitTree { get set }
|
||||||
|
|
||||||
/// The command palette state.
|
/// The command palette state.
|
||||||
var commandPaletteIsShowing: Bool { get set }
|
var commandPaletteIsShowing: Bool { get set }
|
||||||
@ -81,7 +81,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
|||||||
DebugBuildWarningView()
|
DebugBuildWarningView()
|
||||||
}
|
}
|
||||||
|
|
||||||
Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
|
TerminalSplitTreeView(tree: viewModel.surfaceTree2)
|
||||||
.environmentObject(ghostty)
|
.environmentObject(ghostty)
|
||||||
.focused($focused)
|
.focused($focused)
|
||||||
.onAppear { self.focused = true }
|
.onAppear { self.focused = true }
|
||||||
@ -100,12 +100,6 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
|||||||
guard let size = newValue else { return }
|
guard let size = newValue else { return }
|
||||||
self.delegate?.cellSizeDidChange(to: size)
|
self.delegate?.cellSizeDidChange(to: size)
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.surfaceTree?.hashValue) { _ in
|
|
||||||
// This is funky, but its the best way I could think of to detect
|
|
||||||
// ANY CHANGE within the deeply nested surface tree -- detecting a change
|
|
||||||
// in the hash value.
|
|
||||||
self.delegate?.surfaceTreeDidChange()
|
|
||||||
}
|
|
||||||
.onChange(of: zoomedSplit) { newValue in
|
.onChange(of: zoomedSplit) { newValue in
|
||||||
self.delegate?.zoomStateDidChange(to: newValue ?? false)
|
self.delegate?.zoomStateDidChange(to: newValue ?? false)
|
||||||
}
|
}
|
||||||
|
@ -30,6 +30,7 @@ class TerminalWindow: NSWindow {
|
|||||||
observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in
|
observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in
|
||||||
guard let tabGroup = self?.tabGroup else { return }
|
guard let tabGroup = self?.tabGroup else { return }
|
||||||
|
|
||||||
|
Ghostty.logger.warning("WOW \(window.surfaceIsZoomed)")
|
||||||
self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed
|
self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed
|
||||||
self?.updateResetZoomTitlebarButtonVisibility()
|
self?.updateResetZoomTitlebarButtonVisibility()
|
||||||
},
|
},
|
||||||
@ -375,6 +376,7 @@ class TerminalWindow: NSWindow {
|
|||||||
if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) {
|
if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) {
|
||||||
addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController)
|
addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController)
|
||||||
}
|
}
|
||||||
|
|
||||||
resetZoomTitlebarAccessoryViewController.view.isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed
|
resetZoomTitlebarAccessoryViewController.view.isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user