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