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_score)| score < best_score) {
73 best = Some((candidate_id, score));
74 }
75 }
76
77 best.map(|(id, _)| id)
78}
79
80pub 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 let mut edges_to_add = Vec::new();
96
97 for &id in &ids {
98 for dir in dirs {
99 if graph.navigate(id, dir).is_some() {
101 continue;
102 }
103 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
115fn 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
127fn 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
140fn 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#[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 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 #[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 #[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 #[test]
256 fn prefers_aligned_over_diagonal() {
257 let g = grid_3x3();
259 let target = spatial_navigate(&g, 5, NavDirection::Right);
260 assert_eq!(target, Some(6));
261 }
262
263 #[test]
266 fn explicit_edge_takes_precedence() {
267 let mut g = grid_3x3();
268 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 #[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)); }
287
288 #[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 #[test]
300 fn build_spatial_edges_populates_grid() {
301 let mut g = grid_3x3();
302 build_spatial_edges(&mut g);
303
304 assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
306 assert_eq!(g.navigate(1, NavDirection::Down), Some(4));
307
308 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); build_spatial_edges(&mut g);
320
321 assert_eq!(g.navigate(1, NavDirection::Right), Some(9));
323 }
324
325 #[test]
328 fn navigate_irregular_layout() {
329 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)); let target = spatial_navigate(&g, 1, NavDirection::Down);
339 assert_eq!(target, Some(3));
340
341 let target = spatial_navigate(&g, 2, NavDirection::Down);
343 assert_eq!(target, Some(3));
344 }
345
346 #[test]
347 fn navigate_overlapping_widgets() {
348 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)); let target = spatial_navigate(&g, 1, NavDirection::Right);
355 assert_eq!(target, Some(2));
356 }
357
358 #[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 #[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 #[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)); g.insert(node_at(3, 50, 0, 10, 3)); let target = spatial_navigate(&g, 1, NavDirection::Right);
388 assert_eq!(target, Some(2));
389 }
390
391 #[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 #[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 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}