Skip to main content

ftui_widgets/focus/
spatial.rs

1#![forbid(unsafe_code)]
2
3//! Spatial navigation: arrow-key focus movement based on widget geometry.
4//!
5//! Given a set of focusable nodes with bounding rectangles, finds the best
6//! candidate in a given direction using a quadrant-based search with
7//! weighted distance scoring.
8//!
9//! # Algorithm
10//!
11//! 1. Check for explicit edge override in the graph (takes precedence).
12//! 2. Filter candidates to the correct quadrant relative to the current node.
13//! 3. Score each candidate: `primary_distance + 0.3 × orthogonal_distance`.
14//! 4. Return the candidate with the lowest score.
15//!
16//! # Invariants
17//!
18//! - Explicit graph edges always take precedence over spatial search.
19//! - Only focusable nodes are considered.
20//! - If no candidate exists in the direction, `None` is returned.
21//! - The algorithm is deterministic: same layout → same navigation path.
22
23use super::graph::{FocusGraph, FocusId, NavDirection};
24use ftui_core::geometry::Rect;
25
26/// Find the best spatial navigation target from `origin` in `dir`.
27///
28/// Returns `None` if no valid candidate exists. Explicit graph edges
29/// take precedence over spatial search.
30#[must_use]
31pub fn spatial_navigate(graph: &FocusGraph, origin: FocusId, dir: NavDirection) -> Option<FocusId> {
32    // 1. Check explicit edge first.
33    if let Some(target) = graph.navigate(origin, dir)
34        && graph.get(target).is_some_and(|n| n.is_focusable)
35    {
36        return Some(target);
37    }
38
39    // Only spatial directions make sense.
40    if !matches!(
41        dir,
42        NavDirection::Up | NavDirection::Down | NavDirection::Left | NavDirection::Right
43    ) {
44        return None;
45    }
46
47    let origin_node = graph.get(origin)?;
48    let oc = center_i32(&origin_node.bounds);
49
50    let mut best: Option<(FocusId, i64)> = None;
51
52    // Pre-collect candidate data to avoid per-candidate HashMap lookups.
53    for candidate_id in graph.node_ids() {
54        if candidate_id == origin {
55            continue;
56        }
57        let Some(candidate) = graph.get(candidate_id) else {
58            continue;
59        };
60        if !candidate.is_focusable {
61            continue;
62        }
63
64        let cc = center_i32(&candidate.bounds);
65
66        if !in_quadrant_i32(oc, cc, dir) {
67            continue;
68        }
69
70        let score = distance_score_i32(oc, cc, dir);
71
72        if best.is_none_or(|(_, best_score)| score < best_score) {
73            best = Some((candidate_id, score));
74        }
75    }
76
77    best.map(|(id, _)| id)
78}
79
80/// Build spatial edges for all nodes in the graph.
81///
82/// For each node and each spatial direction (Up/Down/Left/Right),
83/// computes the best spatial target and inserts the edge if no
84/// explicit edge already exists.
85pub fn build_spatial_edges(graph: &mut FocusGraph) {
86    let ids: Vec<FocusId> = graph.node_ids().collect();
87    let dirs = [
88        NavDirection::Up,
89        NavDirection::Down,
90        NavDirection::Left,
91        NavDirection::Right,
92    ];
93
94    // Collect edges to add (can't mutate graph while iterating).
95    let mut edges_to_add = Vec::new();
96
97    for &id in &ids {
98        for dir in dirs {
99            // Skip if explicit edge already exists.
100            if graph.navigate(id, dir).is_some() {
101                continue;
102            }
103            // Use a read-only spatial search.
104            if let Some(target) = spatial_navigate(graph, id, dir) {
105                edges_to_add.push((id, dir, target));
106            }
107        }
108    }
109
110    for (from, dir, to) in edges_to_add {
111        graph.connect(from, dir, to);
112    }
113}
114
115// --- Geometry helpers (integer arithmetic for debug-mode performance) ---
116
117/// Doubled center coordinates to avoid division: `(2*x + w, 2*y + h)`.
118///
119/// Using doubled coords preserves exact ordering without floating point.
120fn center_i32(r: &Rect) -> (i32, i32) {
121    (
122        2 * r.x as i32 + r.width as i32,
123        2 * r.y as i32 + r.height as i32,
124    )
125}
126
127/// Check if `candidate` center is in the correct quadrant relative to `origin`.
128fn in_quadrant_i32(origin: (i32, i32), candidate: (i32, i32), dir: NavDirection) -> bool {
129    let (ox, oy) = origin;
130    let (cx, cy) = candidate;
131    match dir {
132        NavDirection::Up => cy < oy,
133        NavDirection::Down => cy > oy,
134        NavDirection::Left => cx < ox,
135        NavDirection::Right => cx > ox,
136        _ => false,
137    }
138}
139
140/// Score a candidate: `10 × primary + 3 × orthogonal`.
141///
142/// This is equivalent to `primary + 0.3 × ortho` scaled by 10 to stay in
143/// integer space. The relative ordering is preserved.
144fn distance_score_i32(origin: (i32, i32), candidate: (i32, i32), dir: NavDirection) -> i64 {
145    let (ox, oy) = origin;
146    let (cx, cy) = candidate;
147
148    let (primary, ortho) = match dir {
149        NavDirection::Up => (i64::from(oy - cy), i64::from((ox - cx).abs())),
150        NavDirection::Down => (i64::from(cy - oy), i64::from((ox - cx).abs())),
151        NavDirection::Left => (i64::from(ox - cx), i64::from((oy - cy).abs())),
152        NavDirection::Right => (i64::from(cx - ox), i64::from((oy - cy).abs())),
153        _ => (i64::MAX / 2, 0),
154    };
155
156    10 * primary + 3 * ortho
157}
158
159// =========================================================================
160// Tests
161// =========================================================================
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::focus::FocusNode;
167
168    fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
169        Rect::new(x, y, w, h)
170    }
171
172    fn node_at(id: FocusId, x: u16, y: u16, w: u16, h: u16) -> FocusNode {
173        FocusNode::new(id, rect(x, y, w, h))
174    }
175
176    /// Layout:
177    /// ```text
178    ///   [1]  [2]  [3]
179    ///   [4]  [5]  [6]
180    ///   [7]  [8]  [9]
181    /// ```
182    fn grid_3x3() -> FocusGraph {
183        let mut g = FocusGraph::new();
184        for row in 0..3u16 {
185            for col in 0..3u16 {
186                let id = (row * 3 + col + 1) as FocusId;
187                g.insert(node_at(id, col * 12, row * 4, 10, 3));
188            }
189        }
190        g
191    }
192
193    // --- Basic spatial navigation ---
194
195    #[test]
196    fn navigate_right_in_row() {
197        let g = grid_3x3();
198        let target = spatial_navigate(&g, 1, NavDirection::Right);
199        assert_eq!(target, Some(2));
200    }
201
202    #[test]
203    fn navigate_left_in_row() {
204        let g = grid_3x3();
205        let target = spatial_navigate(&g, 3, NavDirection::Left);
206        assert_eq!(target, Some(2));
207    }
208
209    #[test]
210    fn navigate_down_in_column() {
211        let g = grid_3x3();
212        let target = spatial_navigate(&g, 1, NavDirection::Down);
213        assert_eq!(target, Some(4));
214    }
215
216    #[test]
217    fn navigate_up_in_column() {
218        let g = grid_3x3();
219        let target = spatial_navigate(&g, 7, NavDirection::Up);
220        assert_eq!(target, Some(4));
221    }
222
223    // --- Edge of grid ---
224
225    #[test]
226    fn no_target_left_at_edge() {
227        let g = grid_3x3();
228        let target = spatial_navigate(&g, 1, NavDirection::Left);
229        assert_eq!(target, None);
230    }
231
232    #[test]
233    fn no_target_up_at_edge() {
234        let g = grid_3x3();
235        let target = spatial_navigate(&g, 1, NavDirection::Up);
236        assert_eq!(target, None);
237    }
238
239    #[test]
240    fn no_target_right_at_edge() {
241        let g = grid_3x3();
242        let target = spatial_navigate(&g, 3, NavDirection::Right);
243        assert_eq!(target, None);
244    }
245
246    #[test]
247    fn no_target_down_at_edge() {
248        let g = grid_3x3();
249        let target = spatial_navigate(&g, 9, NavDirection::Down);
250        assert_eq!(target, None);
251    }
252
253    // --- Diagonal preference ---
254
255    #[test]
256    fn prefers_aligned_over_diagonal() {
257        // Node 5 (center) going right should prefer 6 (same row) over 3 (above-right).
258        let g = grid_3x3();
259        let target = spatial_navigate(&g, 5, NavDirection::Right);
260        assert_eq!(target, Some(6));
261    }
262
263    // --- Explicit edge override ---
264
265    #[test]
266    fn explicit_edge_takes_precedence() {
267        let mut g = grid_3x3();
268        // Override: right from 1 goes to 9 (instead of spatial 2).
269        g.connect(1, NavDirection::Right, 9);
270
271        let target = spatial_navigate(&g, 1, NavDirection::Right);
272        assert_eq!(target, Some(9));
273    }
274
275    // --- Unfocusable nodes ---
276
277    #[test]
278    fn skips_unfocusable_candidates() {
279        let mut g = FocusGraph::new();
280        g.insert(node_at(1, 0, 0, 10, 3));
281        g.insert(node_at(2, 12, 0, 10, 3).with_focusable(false));
282        g.insert(node_at(3, 24, 0, 10, 3));
283
284        let target = spatial_navigate(&g, 1, NavDirection::Right);
285        assert_eq!(target, Some(3)); // Skips 2.
286    }
287
288    // --- Next/Prev return None ---
289
290    #[test]
291    fn next_prev_not_spatial() {
292        let g = grid_3x3();
293        assert_eq!(spatial_navigate(&g, 1, NavDirection::Next), None);
294        assert_eq!(spatial_navigate(&g, 1, NavDirection::Prev), None);
295    }
296
297    // --- build_spatial_edges ---
298
299    #[test]
300    fn build_spatial_edges_populates_grid() {
301        let mut g = grid_3x3();
302        build_spatial_edges(&mut g);
303
304        // 1 should now have Right→2 and Down→4.
305        assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
306        assert_eq!(g.navigate(1, NavDirection::Down), Some(4));
307
308        // 5 (center) should have all four.
309        assert!(g.navigate(5, NavDirection::Up).is_some());
310        assert!(g.navigate(5, NavDirection::Down).is_some());
311        assert!(g.navigate(5, NavDirection::Left).is_some());
312        assert!(g.navigate(5, NavDirection::Right).is_some());
313    }
314
315    #[test]
316    fn build_spatial_preserves_explicit_edges() {
317        let mut g = grid_3x3();
318        g.connect(1, NavDirection::Right, 9); // Override.
319        build_spatial_edges(&mut g);
320
321        // Explicit edge should be preserved.
322        assert_eq!(g.navigate(1, NavDirection::Right), Some(9));
323    }
324
325    // --- Irregular layout ---
326
327    #[test]
328    fn navigate_irregular_layout() {
329        // Wide button spanning columns:
330        // [1] [2]
331        // [  3  ]  (wide, centered)
332        let mut g = FocusGraph::new();
333        g.insert(node_at(1, 0, 0, 10, 3));
334        g.insert(node_at(2, 12, 0, 10, 3));
335        g.insert(node_at(3, 0, 4, 22, 3)); // Full width.
336
337        // Down from 1 should go to 3 (directly below).
338        let target = spatial_navigate(&g, 1, NavDirection::Down);
339        assert_eq!(target, Some(3));
340
341        // Down from 2 should also go to 3.
342        let target = spatial_navigate(&g, 2, NavDirection::Down);
343        assert_eq!(target, Some(3));
344    }
345
346    #[test]
347    fn navigate_overlapping_widgets() {
348        // Two overlapping nodes: pick the one in the direction.
349        let mut g = FocusGraph::new();
350        g.insert(node_at(1, 0, 0, 10, 5));
351        g.insert(node_at(2, 5, 3, 10, 5)); // Overlaps partially.
352
353        // Right from 1 → 2 (center of 2 is to the right).
354        let target = spatial_navigate(&g, 1, NavDirection::Right);
355        assert_eq!(target, Some(2));
356    }
357
358    // --- Single node ---
359
360    #[test]
361    fn single_node_no_target() {
362        let mut g = FocusGraph::new();
363        g.insert(node_at(1, 0, 0, 10, 3));
364
365        for dir in NavDirection::ALL {
366            assert_eq!(spatial_navigate(&g, 1, dir), None);
367        }
368    }
369
370    // --- Empty graph ---
371
372    #[test]
373    fn empty_graph_returns_none() {
374        let g = FocusGraph::new();
375        assert_eq!(spatial_navigate(&g, 1, NavDirection::Right), None);
376    }
377
378    // --- Distance scoring ---
379
380    #[test]
381    fn closer_target_wins() {
382        let mut g = FocusGraph::new();
383        g.insert(node_at(1, 0, 0, 10, 3));
384        g.insert(node_at(2, 12, 0, 10, 3)); // Close.
385        g.insert(node_at(3, 50, 0, 10, 3)); // Far.
386
387        let target = spatial_navigate(&g, 1, NavDirection::Right);
388        assert_eq!(target, Some(2));
389    }
390
391    // --- Property: determinism ---
392
393    #[test]
394    fn property_deterministic() {
395        let g = grid_3x3();
396        let dirs = [
397            NavDirection::Up,
398            NavDirection::Down,
399            NavDirection::Left,
400            NavDirection::Right,
401        ];
402
403        for _ in 0..100 {
404            for id in 1..=9 {
405                for dir in dirs {
406                    let a = spatial_navigate(&g, id, dir);
407                    let b = spatial_navigate(&g, id, dir);
408                    assert_eq!(a, b, "Non-deterministic for id={id}, dir={dir:?}");
409                }
410            }
411        }
412    }
413
414    // --- Perf ---
415
416    #[test]
417    fn perf_spatial_navigate_100_nodes() {
418        let mut g = FocusGraph::new();
419        for row in 0..10u16 {
420            for col in 0..10u16 {
421                let id = (row * 10 + col + 1) as FocusId;
422                g.insert(node_at(id, col * 12, row * 4, 10, 3));
423            }
424        }
425
426        let start = std::time::Instant::now();
427        for id in 1..=100 {
428            for dir in [
429                NavDirection::Up,
430                NavDirection::Down,
431                NavDirection::Left,
432                NavDirection::Right,
433            ] {
434                let _ = spatial_navigate(&g, id, dir);
435            }
436        }
437        let elapsed = start.elapsed();
438        // 400 spatial navigations across 100 nodes.
439        assert!(
440            elapsed.as_micros() < 15_000,
441            "400 spatial navigations took {}μs (budget: 15000μs)",
442            elapsed.as_micros()
443        );
444    }
445
446    #[test]
447    fn perf_build_spatial_edges_100() {
448        let mut g = FocusGraph::new();
449        for row in 0..10u16 {
450            for col in 0..10u16 {
451                let id = (row * 10 + col + 1) as FocusId;
452                g.insert(node_at(id, col * 12, row * 4, 10, 3));
453            }
454        }
455
456        let start = std::time::Instant::now();
457        build_spatial_edges(&mut g);
458        let elapsed = start.elapsed();
459        assert!(
460            elapsed.as_micros() < 50_000,
461            "build_spatial_edges(100 nodes) took {}μs (budget: 50000μs)",
462            elapsed.as_micros()
463        );
464    }
465}