macos: resize split keybind handling

pull/7523/head
Mitchell Hashimoto 2025-06-04 12:53:31 -07:00
parent 5299f10e13
commit 69c3c359cb
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
2 changed files with 206 additions and 7 deletions

View File

@ -213,6 +213,108 @@ extension SplitTree {
let newRoot = root.equalize()
return .init(root: newRoot, zoomed: zoomed)
}
/// Resize a node in the tree by the given pixel amount in the specified direction.
///
/// This method adjusts the split ratios of the tree to accommodate the requested resize
/// operation. For up/down resizing, it finds the nearest parent vertical split and adjusts
/// its ratio. For left/right resizing, it finds the nearest parent horizontal split.
/// The bounds parameter is used to construct the spatial tree representation which is
/// needed to calculate the current pixel dimensions.
///
/// This will always reset the zoomed state.
///
/// - Parameters:
/// - node: The node to resize
/// - by: The number of pixels to resize by
/// - direction: The direction to resize in (up, down, left, right)
/// - bounds: The bounds used to construct the spatial tree representation
/// - Returns: A new SplitTree with the adjusted split ratios
/// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists
func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self {
guard let root else { throw SplitError.viewNotFound }
// Find the path to the target node
guard let path = root.path(to: node) else {
throw SplitError.viewNotFound
}
// Determine which type of split we need to find based on resize direction
let targetSplitDirection: Direction = switch direction {
case .up, .down: .vertical
case .left, .right: .horizontal
}
// Find the nearest parent split of the correct type by walking up the path
var splitPath: Path?
var splitNode: Node?
for i in stride(from: path.path.count - 1, through: 0, by: -1) {
let parentPath = Path(path: Array(path.path.prefix(i)))
if let parent = root.node(at: parentPath), case .split(let split) = parent {
if split.direction == targetSplitDirection {
splitPath = parentPath
splitNode = parent
break
}
}
}
guard let splitPath = splitPath,
let splitNode = splitNode,
case .split(let split) = splitNode else {
throw SplitError.viewNotFound
}
// Get current spatial representation to calculate pixel dimensions
let spatial = root.spatial(within: bounds.size)
guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else {
throw SplitError.viewNotFound
}
// Calculate the new ratio based on pixel change
let pixelOffset = Double(pixels)
let newRatio: Double
switch (split.direction, direction) {
case (.horizontal, .left):
// Moving left boundary: decrease left side
newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width)))
case (.horizontal, .right):
// Moving right boundary: increase left side
newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width)))
case (.vertical, .up):
// Moving top boundary: decrease top side
newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.height)))
case (.vertical, .down):
// Moving bottom boundary: increase top side
newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.height)))
default:
// Direction doesn't match split type - shouldn't happen due to earlier logic
throw SplitError.viewNotFound
}
// Create new split with adjusted ratio
let newSplit = Node.Split(
direction: split.direction,
ratio: newRatio,
left: split.left,
right: split.right
)
// Replace the split node with the new one
let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit))
return .init(root: newRoot, zoomed: nil)
}
/// Returns the total bounds of the split hierarchy using NSView bounds.
/// Ignores x/y coordinates and assumes views are laid out in a perfect grid.
/// Also ignores any possible padding between views.
/// - Returns: The total width and height needed to contain all views
func viewBounds() -> CGSize {
guard let root else { return .zero }
return root.viewBounds()
}
}
// MARK: SplitTree.Node
@ -278,6 +380,27 @@ extension SplitTree.Node {
return search(self) ? Path(path: components) : nil
}
/// Returns the node at the given path from this node as root.
func node(at path: Path) -> Node? {
if path.isEmpty {
return self
}
guard case .split(let split) = self else {
return nil
}
let component = path.path[0]
let remainingPath = Path(path: Array(path.path.dropFirst()))
switch component {
case .left:
return split.left.node(at: remainingPath)
case .right:
return split.right.node(at: remainingPath)
}
}
/// 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,
@ -541,6 +664,36 @@ extension SplitTree.Node {
split.right.calculateViewBounds(in: rightBounds)
}
}
/// Returns the total bounds of this subtree using NSView bounds.
/// Ignores x/y coordinates and assumes views are laid out in a perfect grid.
/// - Returns: The total width and height needed to contain all views in this subtree
func viewBounds() -> CGSize {
switch self {
case .leaf(let view):
return view.bounds.size
case .split(let split):
let leftBounds = split.left.viewBounds()
let rightBounds = split.right.viewBounds()
switch split.direction {
case .horizontal:
// Horizontal split: width is sum, height is max
return CGSize(
width: leftBounds.width + rightBounds.width,
height: Swift.max(leftBounds.height, rightBounds.height)
)
case .vertical:
// Vertical split: height is sum, width is max
return CGSize(
width: Swift.max(leftBounds.width, rightBounds.width),
height: leftBounds.height + rightBounds.height
)
}
}
}
}
// MARK: SplitTree.Node Spatial
@ -575,16 +728,25 @@ extension SplitTree.Node {
/// // - Node bounds based on actual split ratios
/// ```
///
/// - Parameter bounds: Optional size constraints for the spatial representation. If nil, uses artificial dimensions based
/// on grid layout
/// - Returns: A `Spatial` struct containing all slots with their calculated bounds
func spatial() -> SplitTree.Spatial {
// First, calculate the total dimensions needed
let dimensions = dimensions()
func spatial(within bounds: CGSize? = nil) -> SplitTree.Spatial {
// If we're not given bounds, we use artificial dimensions based on
// the total width/height in columns/rows.
let width: Double
let height: Double
if let bounds {
width = bounds.width
height = bounds.height
} else {
let (w, h) = self.dimensions()
width = Double(w)
height = Double(h)
}
// Calculate slots with relative bounds
let slots = spatialSlots(
in: CGRect(x: 0, y: 0, width: Double(dimensions.width), height: Double(dimensions.height))
)
let slots = spatialSlots(in: CGRect(x: 0, y: 0, width: width, height: height))
return SplitTree.Spatial(slots: slots)
}

View File

@ -151,6 +151,11 @@ class BaseTerminalController: NSWindowController,
selector: #selector(ghosttyDidToggleSplitZoom(_:)),
name: Ghostty.Notification.didToggleSplitZoom,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyDidResizeSplit(_:)),
name: Ghostty.Notification.didResizeSplit,
object: nil)
// Listen for local events that we need to know of outside of
// single surface handlers.
@ -481,6 +486,38 @@ class BaseTerminalController: NSWindowController,
}
}
@objc private func ghosttyDidResizeSplit(_ notification: Notification) {
// The target must be within our tree
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
// Extract direction and amount from notification
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 }
// Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction
let spatialDirection: SplitTree<Ghostty.SurfaceView>.Spatial.Direction
switch direction {
case .up: spatialDirection = .up
case .down: spatialDirection = .down
case .left: spatialDirection = .left
case .right: spatialDirection = .right
}
// Use viewBounds for the spatial calculation bounds
let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds())
// Perform the resize using the new SplitTree resize method
do {
surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds)
} catch {
Ghostty.logger.warning("failed to resize split: \(error)")
}
}
// MARK: Local Events
private func localEventHandler(_ event: NSEvent) -> NSEvent? {