new SplitTree

This commit is contained in:
Mitchell Hashimoto
2025-06-02 16:56:57 -07:00
parent 722629f9fa
commit 1707159441
7 changed files with 468 additions and 9 deletions

View File

@ -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 */,

View 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
}
}
}

View 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)
}
)
}
}

View File

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

View File

@ -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.

View File

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

View File

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