From 69c3c359cb65cf0e09f8a3c8e7013b02f23c6005 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 12:53:31 -0700 Subject: [PATCH] macos: resize split keybind handling --- macos/Sources/Features/Splits/SplitTree.swift | 176 +++++++++++++++++- .../Terminal/BaseTerminalController.swift | 37 ++++ 2 files changed, 206 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index d47e51bec..ab4b387a4 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -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 @@ -277,6 +379,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. /// @@ -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) } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index ba57fbf70..5e2777195 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -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. @@ -480,6 +485,38 @@ class BaseTerminalController: NSWindowController, Ghostty.moveFocus(to: target) } } + + @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.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