macos: spatial focus navigation
parent
ec7fd94d0f
commit
b7c01b5b4a
|
|
@ -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 {
|
enum SplitError: Error {
|
||||||
case viewNotFound
|
case viewNotFound
|
||||||
}
|
}
|
||||||
|
|
@ -57,12 +80,10 @@ struct SplitTree<ViewType: NSView & Codable>: Codable {
|
||||||
case previous
|
case previous
|
||||||
case next
|
case next
|
||||||
|
|
||||||
// Geospatially-aware navigation targets. These take into account the
|
// Spatially-aware navigation targets. These take into account the
|
||||||
// dimensions of the view to find the correct node to go to.
|
// layout to find the spatially correct node to move to. Spatial navigation
|
||||||
case up
|
// is always from the top-left corner for now.
|
||||||
case down
|
case spatial(Spatial.Direction)
|
||||||
case left
|
|
||||||
case right
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,7 +155,7 @@ extension SplitTree {
|
||||||
/// Find the next view to focus based on the current focused node and direction
|
/// Find the next view to focus based on the current focused node and direction
|
||||||
func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? {
|
func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? {
|
||||||
guard let root else { return nil }
|
guard let root else { return nil }
|
||||||
|
|
||||||
switch direction {
|
switch direction {
|
||||||
case .previous:
|
case .previous:
|
||||||
// For previous, we traverse in order and find the previous leaf from our leftmost
|
// 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)
|
let index = allLeaves.indexWrapping(after: currentIndex)
|
||||||
return allLeaves[index]
|
return allLeaves[index]
|
||||||
|
|
||||||
case .up, .down, .left, .right:
|
case .spatial(let spatialDirection):
|
||||||
// For directional movement, we need to traverse the tree structure
|
// Get spatial representation and find best candidate
|
||||||
return directionalTarget(for: direction, from: currentNode)
|
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
|
/// Equalize all splits in the tree so that each split's ratio is based on the
|
||||||
/// relative weight (number of leaves) of its children.
|
/// relative weight (number of leaves) of its children.
|
||||||
func equalize() -> Self {
|
func equalize() -> Self {
|
||||||
|
|
@ -452,6 +488,292 @@ extension SplitTree.Node {
|
||||||
return (.split(newSplit), totalWeight)
|
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
|
// MARK: SplitTree.Node Protocols
|
||||||
|
|
|
||||||
|
|
@ -405,10 +405,10 @@ class BaseTerminalController: NSWindowController,
|
||||||
switch direction {
|
switch direction {
|
||||||
case .previous: focusDirection = .previous
|
case .previous: focusDirection = .previous
|
||||||
case .next: focusDirection = .next
|
case .next: focusDirection = .next
|
||||||
case .up: focusDirection = .up
|
case .up: focusDirection = .spatial(.up)
|
||||||
case .down: focusDirection = .down
|
case .down: focusDirection = .spatial(.down)
|
||||||
case .left: focusDirection = .left
|
case .left: focusDirection = .spatial(.left)
|
||||||
case .right: focusDirection = .right
|
case .right: focusDirection = .spatial(.right)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the node for the target surface
|
// Find the node for the target surface
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue