1#![forbid(unsafe_code)]
2
3use super::graph::{FocusGraph, FocusId, NavDirection};
24use ftui_core::geometry::Rect;
25
26#[must_use]
31pub fn spatial_navigate(graph: &FocusGraph, origin: FocusId, dir: NavDirection) -> Option<FocusId> {
32 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 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 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
82pub 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 let mut edges_to_add = Vec::new();
98
99 for &id in &ids {
100 for dir in dirs {
101 if graph.navigate(id, dir).is_some() {
103 continue;
104 }
105 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
117fn 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
129fn 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
142fn 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#[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 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 #[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 #[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 #[test]
270 fn prefers_aligned_over_diagonal() {
271 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)); g.insert(node_at(42, 20, 6, 4, 2)); g.insert(node_at(7, 20, 14, 4, 2)); let target = spatial_navigate(&g, 10, NavDirection::Right);
285 assert_eq!(target, Some(7));
286 }
287
288 #[test]
291 fn explicit_edge_takes_precedence() {
292 let mut g = grid_3x3();
293 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 #[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)); }
312
313 #[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 #[test]
325 fn build_spatial_edges_populates_grid() {
326 let mut g = grid_3x3();
327 build_spatial_edges(&mut g);
328
329 assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
331 assert_eq!(g.navigate(1, NavDirection::Down), Some(4));
332
333 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); build_spatial_edges(&mut g);
345
346 assert_eq!(g.navigate(1, NavDirection::Right), Some(9));
348 }
349
350 #[test]
353 fn navigate_irregular_layout() {
354 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)); let target = spatial_navigate(&g, 1, NavDirection::Down);
364 assert_eq!(target, Some(3));
365
366 let target = spatial_navigate(&g, 2, NavDirection::Down);
368 assert_eq!(target, Some(3));
369 }
370
371 #[test]
372 fn navigate_overlapping_widgets() {
373 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)); let target = spatial_navigate(&g, 1, NavDirection::Right);
380 assert_eq!(target, Some(2));
381 }
382
383 #[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 #[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 #[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)); g.insert(node_at(3, 50, 0, 10, 3)); let target = spatial_navigate(&g, 1, NavDirection::Right);
413 assert_eq!(target, Some(2));
414 }
415
416 #[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 #[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 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}