macos: spatial focus navigation

This commit is contained in:
Mitchell Hashimoto
2025-06-04 10:04:03 -07:00
parent ec7fd94d0f
commit b7c01b5b4a
2 changed files with 343 additions and 21 deletions

View File

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

View File

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