macos: split directional navigation should use distance to leaf

Fixes regression from #7523

I messed two things up around spatial navigation in the split tree
that this commit fixes:

  1. The distance in the spatial tree only used a single dimension
     that we were navigating. This commit now uses 2D euclidean
     distance from the top-left corners of nodes. This handles the case
     where the nodes are directly above or below each other better.

  2. The spatial slots include split containers because they are layout
     elements. But we should only navigate to leaf nodes. This was
     causing the wrong navigatin to happen in some scenarios.
pull/7527/head
Mitchell Hashimoto 2025-06-05 13:41:33 -07:00
parent efc1ceab5d
commit 045c84acb7
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
1 changed files with 37 additions and 15 deletions

View File

@ -190,17 +190,22 @@ extension SplitTree {
return nil
}
// Extract the view from the best candidate node
let bestNode = nodes[0].node
switch bestNode {
// Extract the view from the best candidate node. The best candidate
// node is the closest leaf node. If we have no leaves (impossible?)
// just use the first node.
let bestNode = nodes.first(where: {
if case .leaf = $0.node { return true } else { return false }
}) ?? nodes[0]
switch bestNode.node {
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()
case .up, .left: bestNode.node.leftmostLeaf()
case .down, .right: bestNode.node.rightmostLeaf()
}
}
}
@ -892,32 +897,47 @@ extension SplitTree.Spatial {
/// - **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.
/// Results are sorted by 2D euclidean distance from the reference node, with closest slots first.
/// Distance is calculated from the top-left corners of the bounds, prioritizing nodes that are
/// closer in both dimensions.
///
/// **Important**: The returned array contains both split nodes and leaf nodes. When using this
/// for navigation or focus management, you typically want to filter for leaf nodes first, as they
/// represent the actual views that can receive focus. Split nodes are included in the results
/// because they have bounds and occupy space in the layout, but they are structural elements
/// that cannot themselves be focused. If no leaf nodes are found in the results, you may need
/// to traverse into a split node to find its appropriate leaf child.
///
/// - 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)
/// - Returns: An array of slots in the specified direction, sorted by 2D distance (closest first)
func slots(in direction: Direction, from referenceNode: SplitTree.Node) -> [Slot] {
guard let refSlot = slots.first(where: { $0.node == referenceNode }) else { return [] }
// Helper function to calculate 2D euclidean distance between top-left corners of two rectangles
func distance(from rect1: CGRect, to rect2: CGRect) -> Double {
// Calculate distance between top-left corners
let dx = rect2.minX - rect1.minX
let dy = rect2.minY - rect1.minY
return sqrt(dx * dx + dy * dy)
}
return switch direction {
let result = 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)
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
}
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)
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
}
case .up:
@ -925,7 +945,7 @@ extension SplitTree.Spatial {
slots.filter {
$0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY
}.sorted {
(refSlot.bounds.minY - $0.bounds.maxY) < (refSlot.bounds.minY - $1.bounds.maxY)
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
}
case .down:
@ -933,9 +953,11 @@ extension SplitTree.Spatial {
slots.filter {
$0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY
}.sorted {
($0.bounds.minY - refSlot.bounds.maxY) < ($1.bounds.minY - refSlot.bounds.maxY)
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
}
}
return result
}
/// Returns whether the given node borders the specified side of the spatial bounds.