mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-05 19:08:17 +00:00
macos: spatial focus navigation
This commit is contained in:
@@ -40,6 +40,29 @@ struct SplitTree<ViewType: NSView & Codable>: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spatial representation of the split tree. This can be used to better understand
|
||||
/// its physical representation to perform tasks such as navigation.
|
||||
struct Spatial {
|
||||
let slots: [Slot]
|
||||
|
||||
/// A single slot within the spatial mapping of a tree. Note that the bounds are
|
||||
/// _relative_. They can't be mapped to physical pixels because the SplitTree
|
||||
/// isn't aware of actual rendering. But relative to each other the bounds are
|
||||
/// correct.
|
||||
struct Slot {
|
||||
let node: Node
|
||||
let bounds: CGRect
|
||||
}
|
||||
|
||||
/// Direction for spatial navigation within the split tree.
|
||||
enum Direction {
|
||||
case left
|
||||
case right
|
||||
case up
|
||||
case down
|
||||
}
|
||||
}
|
||||
|
||||
enum SplitError: Error {
|
||||
case viewNotFound
|
||||
}
|
||||
@@ -57,12 +80,10 @@ struct SplitTree<ViewType: NSView & Codable>: Codable {
|
||||
case previous
|
||||
case next
|
||||
|
||||
// Geospatially-aware navigation targets. These take into account the
|
||||
// dimensions of the view to find the correct node to go to.
|
||||
case up
|
||||
case down
|
||||
case left
|
||||
case right
|
||||
// Spatially-aware navigation targets. These take into account the
|
||||
// layout to find the spatially correct node to move to. Spatial navigation
|
||||
// is always from the top-left corner for now.
|
||||
case spatial(Spatial.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +155,7 @@ extension SplitTree {
|
||||
/// Find the next view to focus based on the current focused node and direction
|
||||
func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? {
|
||||
guard let root else { return nil }
|
||||
|
||||
|
||||
switch direction {
|
||||
case .previous:
|
||||
// For previous, we traverse in order and find the previous leaf from our leftmost
|
||||
@@ -157,18 +178,33 @@ extension SplitTree {
|
||||
let index = allLeaves.indexWrapping(after: currentIndex)
|
||||
return allLeaves[index]
|
||||
|
||||
case .up, .down, .left, .right:
|
||||
// For directional movement, we need to traverse the tree structure
|
||||
return directionalTarget(for: direction, from: currentNode)
|
||||
case .spatial(let spatialDirection):
|
||||
// Get spatial representation and find best candidate
|
||||
let spatial = root.spatial()
|
||||
let nodes = spatial.slots(in: spatialDirection, from: currentNode)
|
||||
|
||||
// If we have no nodes in the direction specified then we don't do
|
||||
// anything.
|
||||
if nodes.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract the view from the best candidate node
|
||||
let bestNode = nodes[0].node
|
||||
switch bestNode {
|
||||
case .leaf(let view):
|
||||
return view
|
||||
case .split:
|
||||
// If the best candidate is a split node, use its the leaf/rightmost
|
||||
// depending on our spatial direction.
|
||||
return switch (spatialDirection) {
|
||||
case .up, .left: bestNode.leftmostLeaf()
|
||||
case .down, .right: bestNode.rightmostLeaf()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find focus target in a specific direction by traversing split boundaries
|
||||
private func directionalTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? {
|
||||
// TODO
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
/// Equalize all splits in the tree so that each split's ratio is based on the
|
||||
/// relative weight (number of leaves) of its children.
|
||||
func equalize() -> Self {
|
||||
@@ -452,6 +488,292 @@ extension SplitTree.Node {
|
||||
return (.split(newSplit), totalWeight)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the bounds of all views in this subtree based on split ratios
|
||||
func calculateViewBounds(in bounds: CGRect) -> [(view: ViewType, bounds: CGRect)] {
|
||||
switch self {
|
||||
case .leaf(let view):
|
||||
return [(view, bounds)]
|
||||
|
||||
case .split(let split):
|
||||
// Calculate bounds for left and right based on split direction and ratio
|
||||
let leftBounds: CGRect
|
||||
let rightBounds: CGRect
|
||||
|
||||
switch split.direction {
|
||||
case .horizontal:
|
||||
// Split horizontally: left | right
|
||||
let splitX = bounds.minX + bounds.width * split.ratio
|
||||
leftBounds = CGRect(
|
||||
x: bounds.minX,
|
||||
y: bounds.minY,
|
||||
width: bounds.width * split.ratio,
|
||||
height: bounds.height
|
||||
)
|
||||
rightBounds = CGRect(
|
||||
x: splitX,
|
||||
y: bounds.minY,
|
||||
width: bounds.width * (1 - split.ratio),
|
||||
height: bounds.height
|
||||
)
|
||||
|
||||
case .vertical:
|
||||
// Split vertically: top / bottom
|
||||
// Note: In our normalized coordinate system, Y increases upward
|
||||
let splitY = bounds.minY + bounds.height * split.ratio
|
||||
leftBounds = CGRect(
|
||||
x: bounds.minX,
|
||||
y: splitY,
|
||||
width: bounds.width,
|
||||
height: bounds.height * (1 - split.ratio)
|
||||
)
|
||||
rightBounds = CGRect(
|
||||
x: bounds.minX,
|
||||
y: bounds.minY,
|
||||
width: bounds.width,
|
||||
height: bounds.height * split.ratio
|
||||
)
|
||||
}
|
||||
|
||||
// Recursively calculate bounds for children
|
||||
return split.left.calculateViewBounds(in: leftBounds) +
|
||||
split.right.calculateViewBounds(in: rightBounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: SplitTree.Node Spatial
|
||||
|
||||
extension SplitTree.Node {
|
||||
/// Returns the spatial representation of this node and its subtree.
|
||||
///
|
||||
/// This method creates a `Spatial` representation that maps the logical split tree structure
|
||||
/// to 2D coordinate space. The coordinate system uses (0,0) as the top-left corner with
|
||||
/// positive X extending right and positive Y extending down.
|
||||
///
|
||||
/// The spatial representation provides:
|
||||
/// - Relative bounds for each node based on split ratios
|
||||
/// - Grid-like dimensions where each split adds 1 to the column/row count
|
||||
/// - Accurate positioning that reflects the actual layout structure
|
||||
///
|
||||
/// The bounds are pixel perfect based on assuming that each row and column are 1 pixel
|
||||
/// tall or wide, respectively. This needs to be scaled up to the proper bounds for a real
|
||||
/// layout.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// // For a layout like:
|
||||
/// // +--------+----+
|
||||
/// // | A | B |
|
||||
/// // +--------+----+
|
||||
/// // | C | D |
|
||||
/// // +--------+----+
|
||||
/// //
|
||||
/// // The spatial representation would have:
|
||||
/// // - Total dimensions: (width: 2, height: 2)
|
||||
/// // - Node bounds based on actual split ratios
|
||||
/// ```
|
||||
///
|
||||
/// - Returns: A `Spatial` struct containing all slots with their calculated bounds
|
||||
func spatial() -> SplitTree.Spatial {
|
||||
// First, calculate the total dimensions needed
|
||||
let dimensions = dimensions()
|
||||
|
||||
// Calculate slots with relative bounds
|
||||
let slots = spatialSlots(
|
||||
in: CGRect(x: 0, y: 0, width: Double(dimensions.width), height: Double(dimensions.height))
|
||||
)
|
||||
|
||||
return SplitTree.Spatial(slots: slots)
|
||||
}
|
||||
|
||||
/// Calculates the grid dimensions (columns and rows) needed to represent this subtree.
|
||||
///
|
||||
/// This method recursively analyzes the split tree structure to determine how many
|
||||
/// columns and rows are needed to represent the layout in a 2D grid. Each leaf node
|
||||
/// occupies one grid cell (1×1), and each split extends the grid in one direction:
|
||||
///
|
||||
/// - **Horizontal splits**: Add columns (increase width)
|
||||
/// - **Vertical splits**: Add rows (increase height)
|
||||
///
|
||||
/// The calculation rules are:
|
||||
/// - **Leaf nodes**: Always (1, 1) - one column, one row
|
||||
/// - **Horizontal splits**: Width = sum of children widths, Height = max of children heights
|
||||
/// - **Vertical splits**: Width = max of children widths, Height = sum of children heights
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// // Single leaf: (1, 1)
|
||||
/// // Horizontal split with 2 leaves: (2, 1)
|
||||
/// // Vertical split with 2 leaves: (1, 2)
|
||||
/// // Complex layout with both: (2, 2) or larger
|
||||
/// ```
|
||||
///
|
||||
/// - Returns: A tuple containing (width: columns, height: rows) as unsigned integers
|
||||
private func dimensions() -> (width: UInt, height: UInt) {
|
||||
switch self {
|
||||
case .leaf:
|
||||
return (1, 1)
|
||||
|
||||
case .split(let split):
|
||||
let leftDimensions = split.left.dimensions()
|
||||
let rightDimensions = split.right.dimensions()
|
||||
|
||||
switch split.direction {
|
||||
case .horizontal:
|
||||
// Horizontal split: width is sum, height is max
|
||||
return (
|
||||
width: leftDimensions.width + rightDimensions.width,
|
||||
height: Swift.max(leftDimensions.height, rightDimensions.height)
|
||||
)
|
||||
|
||||
case .vertical:
|
||||
// Vertical split: height is sum, width is max
|
||||
return (
|
||||
width: Swift.max(leftDimensions.width, rightDimensions.width),
|
||||
height: leftDimensions.height + rightDimensions.height
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the spatial slots (nodes with bounds) for this subtree within the given bounds.
|
||||
///
|
||||
/// This method recursively traverses the split tree and calculates the precise bounds
|
||||
/// for each node based on the split ratios and directions. The bounds are calculated
|
||||
/// relative to the provided bounds rectangle.
|
||||
///
|
||||
/// The calculation process:
|
||||
/// 1. **Leaf nodes**: Create a single slot with the provided bounds
|
||||
/// 2. **Split nodes**:
|
||||
/// - Divide the bounds according to the split ratio and direction
|
||||
/// - Create a slot for the split node itself
|
||||
/// - Recursively calculate slots for both children
|
||||
/// - Return all slots combined
|
||||
///
|
||||
/// Split ratio interpretation:
|
||||
/// - **Horizontal splits**: Ratio determines left/right width distribution
|
||||
/// - Left child gets `ratio * width`
|
||||
/// - Right child gets `(1 - ratio) * width`
|
||||
/// - **Vertical splits**: Ratio determines top/bottom height distribution
|
||||
/// - Top (left) child gets `ratio * height`
|
||||
/// - Bottom (right) child gets `(1 - ratio) * height`
|
||||
///
|
||||
/// Coordinate system: (0,0) is top-left, positive X goes right, positive Y goes down.
|
||||
///
|
||||
/// - Parameter bounds: The bounding rectangle to subdivide for this subtree
|
||||
/// - Returns: An array of `Spatial.Slot` objects, each containing a node and its bounds
|
||||
private func spatialSlots(in bounds: CGRect) -> [SplitTree.Spatial.Slot] {
|
||||
switch self {
|
||||
case .leaf:
|
||||
// A leaf takes up our full bounds.
|
||||
return [.init(node: self, bounds: bounds)]
|
||||
|
||||
case .split(let split):
|
||||
let leftBounds: CGRect
|
||||
let rightBounds: CGRect
|
||||
|
||||
switch split.direction {
|
||||
case .horizontal:
|
||||
// Split horizontally: left | right using the ratio
|
||||
let splitX = bounds.minX + bounds.width * split.ratio
|
||||
leftBounds = CGRect(
|
||||
x: bounds.minX,
|
||||
y: bounds.minY,
|
||||
width: bounds.width * split.ratio,
|
||||
height: bounds.height
|
||||
)
|
||||
rightBounds = CGRect(
|
||||
x: splitX,
|
||||
y: bounds.minY,
|
||||
width: bounds.width * (1 - split.ratio),
|
||||
height: bounds.height
|
||||
)
|
||||
|
||||
case .vertical:
|
||||
// Split vertically: top / bottom using the ratio
|
||||
// Top-left is (0,0), so top (left) gets the upper portion
|
||||
let splitY = bounds.minY + bounds.height * split.ratio
|
||||
leftBounds = CGRect(
|
||||
x: bounds.minX,
|
||||
y: bounds.minY,
|
||||
width: bounds.width,
|
||||
height: bounds.height * split.ratio
|
||||
)
|
||||
rightBounds = CGRect(
|
||||
x: bounds.minX,
|
||||
y: splitY,
|
||||
width: bounds.width,
|
||||
height: bounds.height * (1 - split.ratio)
|
||||
)
|
||||
}
|
||||
|
||||
// Recursively calculate slots for children and include a slot for this split
|
||||
var slots: [SplitTree.Spatial.Slot] = [.init(node: self, bounds: bounds)]
|
||||
slots += split.left.spatialSlots(in: leftBounds)
|
||||
slots += split.right.spatialSlots(in: rightBounds)
|
||||
|
||||
return slots
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: SplitTree.Spatial
|
||||
|
||||
extension SplitTree.Spatial {
|
||||
/// Returns all slots in the specified direction relative to the reference node.
|
||||
///
|
||||
/// This method finds all slots positioned in the given direction from the reference node:
|
||||
/// - **Left**: Slots with bounds to the left of the reference node
|
||||
/// - **Right**: Slots with bounds to the right of the reference node
|
||||
/// - **Up**: Slots with bounds above the reference node (Y=0 is top)
|
||||
/// - **Down**: Slots with bounds below the reference node
|
||||
///
|
||||
/// Results are sorted by distance from the reference node, with closest slots first.
|
||||
/// Distance is calculated as the gap between the reference node and the candidate slot
|
||||
/// in the direction of movement.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - direction: The direction to search for slots
|
||||
/// - referenceNode: The node to use as the reference point
|
||||
/// - Returns: An array of slots in the specified direction, sorted by distance (closest first)
|
||||
func slots(in direction: Direction, from referenceNode: SplitTree.Node) -> [Slot] {
|
||||
guard let refSlot = slots.first(where: { $0.node == referenceNode }) else { return [] }
|
||||
|
||||
return switch direction {
|
||||
case .left:
|
||||
// Slots to the left: their right edge is at or left of reference's left edge
|
||||
slots.filter {
|
||||
$0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX
|
||||
}.sorted {
|
||||
(refSlot.bounds.minX - $0.bounds.maxX) < (refSlot.bounds.minX - $1.bounds.maxX)
|
||||
}
|
||||
|
||||
case .right:
|
||||
// Slots to the right: their left edge is at or right of reference's right edge
|
||||
slots.filter {
|
||||
$0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX
|
||||
}.sorted {
|
||||
($0.bounds.minX - refSlot.bounds.maxX) < ($1.bounds.minX - refSlot.bounds.maxX)
|
||||
}
|
||||
|
||||
case .up:
|
||||
// Slots above: their bottom edge is at or above reference's top edge
|
||||
slots.filter {
|
||||
$0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY
|
||||
}.sorted {
|
||||
(refSlot.bounds.minY - $0.bounds.maxY) < (refSlot.bounds.minY - $1.bounds.maxY)
|
||||
}
|
||||
|
||||
case .down:
|
||||
// Slots below: their top edge is at or below reference's bottom edge
|
||||
slots.filter {
|
||||
$0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY
|
||||
}.sorted {
|
||||
($0.bounds.minY - refSlot.bounds.maxY) < ($1.bounds.minY - refSlot.bounds.maxY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: SplitTree.Node Protocols
|
||||
|
@@ -405,10 +405,10 @@ class BaseTerminalController: NSWindowController,
|
||||
switch direction {
|
||||
case .previous: focusDirection = .previous
|
||||
case .next: focusDirection = .next
|
||||
case .up: focusDirection = .up
|
||||
case .down: focusDirection = .down
|
||||
case .left: focusDirection = .left
|
||||
case .right: focusDirection = .right
|
||||
case .up: focusDirection = .spatial(.up)
|
||||
case .down: focusDirection = .spatial(.down)
|
||||
case .left: focusDirection = .spatial(.left)
|
||||
case .right: focusDirection = .spatial(.right)
|
||||
}
|
||||
|
||||
// Find the node for the target surface
|
||||
|
Reference in New Issue
Block a user