1#![forbid(unsafe_code)]
2
3use ahash::AHashMap;
27use ftui_core::geometry::Rect;
28
29pub type FocusId = u64;
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34pub enum NavDirection {
35 Up,
36 Down,
37 Left,
38 Right,
39 Next,
41 Prev,
43}
44
45impl NavDirection {
46 pub const ALL: [NavDirection; 6] = [
48 NavDirection::Up,
49 NavDirection::Down,
50 NavDirection::Left,
51 NavDirection::Right,
52 NavDirection::Next,
53 NavDirection::Prev,
54 ];
55}
56
57#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct FocusNode {
60 pub id: FocusId,
62 pub bounds: Rect,
64 pub tab_index: i32,
66 pub is_focusable: bool,
68 pub group_id: Option<u32>,
70}
71
72impl FocusNode {
73 #[must_use]
75 pub fn new(id: FocusId, bounds: Rect) -> Self {
76 Self {
77 id,
78 bounds,
79 tab_index: 0,
80 is_focusable: true,
81 group_id: None,
82 }
83 }
84
85 #[must_use]
87 pub fn with_tab_index(mut self, idx: i32) -> Self {
88 self.tab_index = idx;
89 self
90 }
91
92 #[must_use]
94 pub fn with_focusable(mut self, focusable: bool) -> Self {
95 self.is_focusable = focusable;
96 self
97 }
98
99 #[must_use]
101 pub fn with_group(mut self, group: u32) -> Self {
102 self.group_id = Some(group);
103 self
104 }
105}
106
107#[derive(Debug, Default)]
112pub struct FocusGraph {
113 nodes: AHashMap<FocusId, FocusNode>,
114 edges: AHashMap<(FocusId, NavDirection), FocusId>,
116}
117
118impl FocusGraph {
119 #[must_use]
121 pub fn new() -> Self {
122 Self::default()
123 }
124
125 pub fn insert(&mut self, node: FocusNode) -> FocusId {
129 let id = node.id;
130 self.nodes.insert(id, node);
131 id
132 }
133
134 #[must_use = "use the removed node (if any)"]
138 pub fn remove(&mut self, id: FocusId) -> Option<FocusNode> {
139 let node = self.nodes.remove(&id)?;
140
141 for dir in NavDirection::ALL {
143 self.edges.remove(&(id, dir));
144 }
145
146 self.edges.retain(|_, target| *target != id);
148
149 Some(node)
150 }
151
152 pub fn connect(&mut self, from: FocusId, dir: NavDirection, to: FocusId) {
156 if self.nodes.contains_key(&from) && self.nodes.contains_key(&to) {
157 self.edges.insert((from, dir), to);
158 }
159 }
160
161 pub fn disconnect(&mut self, from: FocusId, dir: NavDirection) {
163 self.edges.remove(&(from, dir));
164 }
165
166 #[must_use = "use the returned target id (if any)"]
170 pub fn navigate(&self, from: FocusId, dir: NavDirection) -> Option<FocusId> {
171 self.edges.get(&(from, dir)).copied()
172 }
173
174 #[must_use = "use the returned node (if any)"]
176 pub fn get(&self, id: FocusId) -> Option<&FocusNode> {
177 self.nodes.get(&id)
178 }
179
180 #[must_use]
182 pub fn node_count(&self) -> usize {
183 self.nodes.len()
184 }
185
186 #[must_use]
188 pub fn edge_count(&self) -> usize {
189 self.edges.len()
190 }
191
192 #[inline]
194 #[must_use]
195 pub fn is_empty(&self) -> bool {
196 self.nodes.is_empty()
197 }
198
199 pub fn node_ids(&self) -> impl Iterator<Item = FocusId> + '_ {
201 self.nodes.keys().copied()
202 }
203
204 pub fn tab_order(&self) -> Vec<FocusId> {
207 let mut ordered: Vec<_> = self
208 .nodes
209 .values()
210 .filter(|n| n.is_focusable && n.tab_index >= 0)
211 .collect();
212 ordered.sort_by(|a, b| a.tab_index.cmp(&b.tab_index).then(a.id.cmp(&b.id)));
213 ordered.iter().map(|n| n.id).collect()
214 }
215
216 pub fn group_tab_order(&self, group: u32) -> Vec<FocusId> {
218 let mut ordered: Vec<_> = self
219 .nodes
220 .values()
221 .filter(|n| n.is_focusable && n.tab_index >= 0 && n.group_id == Some(group))
222 .collect();
223 ordered.sort_by(|a, b| a.tab_index.cmp(&b.tab_index).then(a.id.cmp(&b.id)));
224 ordered.iter().map(|n| n.id).collect()
225 }
226
227 #[must_use = "use the returned cycle (if any) to diagnose invalid focus graphs"]
234 pub fn find_cycle(&self, start: FocusId) -> Option<Vec<FocusId>> {
235 let mut slow = start;
237 let mut fast = start;
238
239 loop {
240 slow = self.navigate(slow, NavDirection::Next)?;
241 fast = self.navigate(fast, NavDirection::Next)?;
242 fast = self.navigate(fast, NavDirection::Next)?;
243 if slow == fast {
244 break;
245 }
246 }
247
248 let mut p1 = start;
250 let mut p2 = slow;
251 while p1 != p2 {
252 p1 = self.navigate(p1, NavDirection::Next)?;
253 p2 = self.navigate(p2, NavDirection::Next)?;
254 }
255
256 let cycle_start = p1;
258 let mut cycle = vec![cycle_start];
259 let mut current = self.navigate(cycle_start, NavDirection::Next)?;
260 while current != cycle_start {
261 cycle.push(current);
262 current = self.navigate(current, NavDirection::Next)?;
263 }
264 cycle.push(cycle_start); Some(cycle)
267 }
268
269 #[must_use = "use the returned cycle (if any) to diagnose invalid focus graphs"]
273 pub fn find_cycle_in_direction(
274 &self,
275 start: FocusId,
276 dir: NavDirection,
277 ) -> Option<Vec<FocusId>> {
278 let mut slow = start;
279 let mut fast = start;
280
281 loop {
282 slow = self.navigate(slow, dir)?;
283 fast = self.navigate(fast, dir)?;
284 fast = self.navigate(fast, dir)?;
285 if slow == fast {
286 break;
287 }
288 }
289
290 let mut p1 = start;
291 let mut p2 = slow;
292 while p1 != p2 {
293 p1 = self.navigate(p1, dir)?;
294 p2 = self.navigate(p2, dir)?;
295 }
296
297 let cycle_start = p1;
298 let mut cycle = vec![cycle_start];
299 let mut current = self.navigate(cycle_start, dir)?;
300 while current != cycle_start {
301 cycle.push(current);
302 current = self.navigate(current, dir)?;
303 }
304 cycle.push(cycle_start);
305
306 Some(cycle)
307 }
308
309 pub fn build_tab_chain(&mut self, wrap: bool) {
314 self.edges
315 .retain(|(_, dir), _| *dir != NavDirection::Next && *dir != NavDirection::Prev);
316 let order = self.tab_order();
317 if order.len() < 2 {
318 return;
319 }
320
321 for pair in order.windows(2) {
322 self.edges.insert((pair[0], NavDirection::Next), pair[1]);
323 self.edges.insert((pair[1], NavDirection::Prev), pair[0]);
324 }
325
326 if wrap {
327 let first = order[0];
328 let last = *order.last().unwrap();
329 self.edges.insert((last, NavDirection::Next), first);
330 self.edges.insert((first, NavDirection::Prev), last);
331 }
332 }
333
334 pub fn clear(&mut self) {
336 self.nodes.clear();
337 self.edges.clear();
338 }
339}
340
341#[cfg(test)]
346mod tests {
347 use super::*;
348
349 fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
350 Rect::new(x, y, w, h)
351 }
352
353 fn node(id: FocusId) -> FocusNode {
354 FocusNode::new(id, rect(0, 0, 10, 1))
355 }
356
357 #[test]
360 fn empty_graph() {
361 let g = FocusGraph::new();
362 assert!(g.is_empty());
363 assert_eq!(g.node_count(), 0);
364 assert_eq!(g.edge_count(), 0);
365 }
366
367 #[test]
368 fn insert_node() {
369 let mut g = FocusGraph::new();
370 let id = g.insert(node(1));
371 assert_eq!(id, 1);
372 assert_eq!(g.node_count(), 1);
373 assert!(g.get(1).is_some());
374 }
375
376 #[test]
377 fn insert_replaces_existing() {
378 let mut g = FocusGraph::new();
379 g.insert(FocusNode::new(1, rect(0, 0, 10, 1)));
380 g.insert(FocusNode::new(1, rect(5, 5, 20, 2)));
381 assert_eq!(g.node_count(), 1);
382 assert_eq!(g.get(1).unwrap().bounds, rect(5, 5, 20, 2));
383 }
384
385 #[test]
386 fn remove_node() {
387 let mut g = FocusGraph::new();
388 g.insert(node(1));
389 let removed = g.remove(1);
390 assert!(removed.is_some());
391 assert_eq!(removed.unwrap().id, 1);
392 assert!(g.is_empty());
393 }
394
395 #[test]
396 fn remove_nonexistent() {
397 let mut g = FocusGraph::new();
398 assert!(g.remove(42).is_none());
399 }
400
401 #[test]
404 fn navigate_connected() {
405 let mut g = FocusGraph::new();
406 g.insert(node(1));
407 g.insert(node(2));
408 g.connect(1, NavDirection::Right, 2);
409
410 assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
411 }
412
413 #[test]
414 fn navigate_unconnected() {
415 let mut g = FocusGraph::new();
416 g.insert(node(1));
417 assert_eq!(g.navigate(1, NavDirection::Up), None);
418 }
419
420 #[test]
421 fn navigate_nonexistent_node() {
422 let g = FocusGraph::new();
423 assert_eq!(g.navigate(99, NavDirection::Down), None);
424 }
425
426 #[test]
427 fn connect_ignores_missing_nodes() {
428 let mut g = FocusGraph::new();
429 g.insert(node(1));
430 g.connect(1, NavDirection::Next, 99); assert_eq!(g.navigate(1, NavDirection::Next), None);
432 assert_eq!(g.edge_count(), 0);
433 }
434
435 #[test]
436 fn disconnect_edge() {
437 let mut g = FocusGraph::new();
438 g.insert(node(1));
439 g.insert(node(2));
440 g.connect(1, NavDirection::Right, 2);
441 assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
442
443 g.disconnect(1, NavDirection::Right);
444 assert_eq!(g.navigate(1, NavDirection::Right), None);
445 }
446
447 #[test]
450 fn remove_cleans_outgoing_edges() {
451 let mut g = FocusGraph::new();
452 g.insert(node(1));
453 g.insert(node(2));
454 g.connect(1, NavDirection::Next, 2);
455 let _ = g.remove(1);
456 assert_eq!(g.edge_count(), 0);
458 }
459
460 #[test]
461 fn remove_cleans_incoming_edges() {
462 let mut g = FocusGraph::new();
463 g.insert(node(1));
464 g.insert(node(2));
465 g.connect(1, NavDirection::Next, 2);
466 let _ = g.remove(2);
467 assert_eq!(g.edge_count(), 0);
469 assert_eq!(g.navigate(1, NavDirection::Next), None);
470 }
471
472 #[test]
475 fn tab_order_sorts_by_index_then_id() {
476 let mut g = FocusGraph::new();
477 g.insert(node(3).with_tab_index(1));
478 g.insert(node(1).with_tab_index(0));
479 g.insert(node(2).with_tab_index(0));
480
481 let order = g.tab_order();
482 assert_eq!(order, vec![1, 2, 3]);
483 }
484
485 #[test]
486 fn tab_order_skips_negative_index() {
487 let mut g = FocusGraph::new();
488 g.insert(node(1).with_tab_index(0));
489 g.insert(node(2).with_tab_index(-1));
490 g.insert(node(3).with_tab_index(1));
491
492 let order = g.tab_order();
493 assert_eq!(order, vec![1, 3]);
494 }
495
496 #[test]
497 fn tab_order_skips_unfocusable() {
498 let mut g = FocusGraph::new();
499 g.insert(node(1));
500 g.insert(node(2).with_focusable(false));
501 g.insert(node(3));
502
503 let order = g.tab_order();
504 assert_eq!(order, vec![1, 3]);
505 }
506
507 #[test]
510 fn group_tab_order_filters_by_group() {
511 let mut g = FocusGraph::new();
512 g.insert(node(1).with_group(1));
513 g.insert(node(2).with_group(2));
514 g.insert(node(3).with_group(1).with_tab_index(1));
515
516 let order = g.group_tab_order(1);
517 assert_eq!(order, vec![1, 3]);
518 }
519
520 #[test]
523 fn build_tab_chain_no_wrap() {
524 let mut g = FocusGraph::new();
525 g.insert(node(1).with_tab_index(0));
526 g.insert(node(2).with_tab_index(1));
527 g.insert(node(3).with_tab_index(2));
528
529 g.build_tab_chain(false);
530
531 assert_eq!(g.navigate(1, NavDirection::Next), Some(2));
532 assert_eq!(g.navigate(2, NavDirection::Next), Some(3));
533 assert_eq!(g.navigate(3, NavDirection::Next), None);
534
535 assert_eq!(g.navigate(3, NavDirection::Prev), Some(2));
536 assert_eq!(g.navigate(2, NavDirection::Prev), Some(1));
537 assert_eq!(g.navigate(1, NavDirection::Prev), None);
538 }
539
540 #[test]
541 fn build_tab_chain_wrap() {
542 let mut g = FocusGraph::new();
543 g.insert(node(1).with_tab_index(0));
544 g.insert(node(2).with_tab_index(1));
545 g.insert(node(3).with_tab_index(2));
546
547 g.build_tab_chain(true);
548
549 assert_eq!(g.navigate(3, NavDirection::Next), Some(1));
550 assert_eq!(g.navigate(1, NavDirection::Prev), Some(3));
551 }
552
553 #[test]
554 fn build_tab_chain_single_node_noop() {
555 let mut g = FocusGraph::new();
556 g.insert(node(1));
557 g.build_tab_chain(true);
558 assert_eq!(g.edge_count(), 0);
559 }
560
561 #[test]
564 fn no_cycle_linear() {
565 let mut g = FocusGraph::new();
566 g.insert(node(1));
567 g.insert(node(2));
568 g.insert(node(3));
569 g.connect(1, NavDirection::Next, 2);
570 g.connect(2, NavDirection::Next, 3);
571
572 assert!(g.find_cycle(1).is_none());
573 }
574
575 #[test]
576 fn simple_cycle() {
577 let mut g = FocusGraph::new();
578 g.insert(node(1));
579 g.insert(node(2));
580 g.insert(node(3));
581 g.connect(1, NavDirection::Next, 2);
582 g.connect(2, NavDirection::Next, 3);
583 g.connect(3, NavDirection::Next, 1);
584
585 let cycle = g.find_cycle(1);
586 assert!(cycle.is_some());
587 let c = cycle.unwrap();
588 assert_eq!(c.first(), c.last());
590 assert_eq!(c.len(), 4); }
592
593 #[test]
594 fn self_loop_cycle() {
595 let mut g = FocusGraph::new();
596 g.insert(node(1));
597 g.connect(1, NavDirection::Next, 1);
598
599 let cycle = g.find_cycle(1);
600 assert!(cycle.is_some());
601 let c = cycle.unwrap();
602 assert_eq!(c, vec![1, 1]);
603 }
604
605 #[test]
606 fn cycle_in_middle() {
607 let mut g = FocusGraph::new();
609 for id in 1..=4 {
610 g.insert(node(id));
611 }
612 g.connect(1, NavDirection::Next, 2);
613 g.connect(2, NavDirection::Next, 3);
614 g.connect(3, NavDirection::Next, 4);
615 g.connect(4, NavDirection::Next, 2);
616
617 let cycle = g.find_cycle(1);
618 assert!(cycle.is_some());
619 let c = cycle.unwrap();
620 assert_eq!(c.first(), c.last());
621 assert_eq!(c.len(), 4);
623 assert!(c.contains(&2));
624 assert!(c.contains(&3));
625 assert!(c.contains(&4));
626 }
627
628 #[test]
629 fn find_cycle_from_nonexistent_start() {
630 let g = FocusGraph::new();
631 assert!(g.find_cycle(99).is_none());
632 }
633
634 #[test]
635 fn find_cycle_in_direction_right() {
636 let mut g = FocusGraph::new();
637 g.insert(node(1));
638 g.insert(node(2));
639 g.connect(1, NavDirection::Right, 2);
640 g.connect(2, NavDirection::Right, 1);
641
642 let cycle = g.find_cycle_in_direction(1, NavDirection::Right);
643 assert!(cycle.is_some());
644 }
645
646 #[test]
649 fn clear_empties_graph() {
650 let mut g = FocusGraph::new();
651 g.insert(node(1));
652 g.insert(node(2));
653 g.connect(1, NavDirection::Next, 2);
654 g.clear();
655 assert!(g.is_empty());
656 assert_eq!(g.edge_count(), 0);
657 }
658
659 #[test]
662 fn node_builder_defaults() {
663 let n = FocusNode::new(1, rect(0, 0, 10, 1));
664 assert_eq!(n.tab_index, 0);
665 assert!(n.is_focusable);
666 assert!(n.group_id.is_none());
667 }
668
669 #[test]
670 fn node_builder_chain() {
671 let n = FocusNode::new(1, rect(0, 0, 10, 1))
672 .with_tab_index(5)
673 .with_focusable(false)
674 .with_group(3);
675 assert_eq!(n.tab_index, 5);
676 assert!(!n.is_focusable);
677 assert_eq!(n.group_id, Some(3));
678 }
679
680 #[test]
683 fn multiple_directions_same_source() {
684 let mut g = FocusGraph::new();
685 g.insert(node(1));
686 g.insert(node(2));
687 g.insert(node(3));
688 g.connect(1, NavDirection::Right, 2);
689 g.connect(1, NavDirection::Down, 3);
690
691 assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
692 assert_eq!(g.navigate(1, NavDirection::Down), Some(3));
693 assert_eq!(g.edge_count(), 2);
694 }
695
696 #[test]
697 fn overwrite_edge() {
698 let mut g = FocusGraph::new();
699 g.insert(node(1));
700 g.insert(node(2));
701 g.insert(node(3));
702 g.connect(1, NavDirection::Next, 2);
703 g.connect(1, NavDirection::Next, 3);
704
705 assert_eq!(g.navigate(1, NavDirection::Next), Some(3));
706 assert_eq!(g.edge_count(), 1);
707 }
708
709 #[test]
710 fn node_ids_iteration() {
711 let mut g = FocusGraph::new();
712 g.insert(node(10));
713 g.insert(node(20));
714 g.insert(node(30));
715
716 let mut ids: Vec<_> = g.node_ids().collect();
717 ids.sort();
718 assert_eq!(ids, vec![10, 20, 30]);
719 }
720
721 #[test]
724 fn property_remove_insert_idempotent() {
725 let mut g = FocusGraph::new();
726 let n = node(1);
727 g.insert(n.clone());
728 let _ = g.remove(1);
729 g.insert(n.clone());
730 assert_eq!(g.node_count(), 1);
731 assert_eq!(g.get(1).unwrap().id, 1);
732 }
733
734 #[test]
735 fn property_tab_chain_wrap_forms_cycle() {
736 let mut g = FocusGraph::new();
737 for i in 1..=5 {
738 g.insert(node(i).with_tab_index(i as i32));
739 }
740 g.build_tab_chain(true);
741
742 let cycle = g.find_cycle(1);
743 assert!(cycle.is_some());
744 let c = cycle.unwrap();
745 assert_eq!(c.len(), 6); }
747
748 #[test]
749 fn property_tab_chain_no_wrap_no_cycle() {
750 let mut g = FocusGraph::new();
751 for i in 1..=5 {
752 g.insert(node(i).with_tab_index(i as i32));
753 }
754 g.build_tab_chain(false);
755
756 assert!(g.find_cycle(1).is_none());
757 }
758
759 #[test]
760 fn property_bidirectional_chain_consistency() {
761 let mut g = FocusGraph::new();
762 for i in 1..=4 {
763 g.insert(node(i).with_tab_index(i as i32));
764 }
765 g.build_tab_chain(false);
766
767 for id in 1..=3u64 {
769 let next = g.navigate(id, NavDirection::Next).unwrap();
770 let prev_of_next = g.navigate(next, NavDirection::Prev).unwrap();
771 assert_eq!(prev_of_next, id);
772 }
773 }
774
775 #[test]
778 fn stress_many_nodes() {
779 let mut g = FocusGraph::new();
780 for i in 0..1000 {
781 g.insert(node(i).with_tab_index(i as i32));
782 }
783 g.build_tab_chain(true);
784
785 assert_eq!(g.node_count(), 1000);
786 let mut current = 0;
788 for _ in 0..1000 {
789 current = g.navigate(current, NavDirection::Next).unwrap();
790 }
791 assert_eq!(current, 0); }
793
794 #[test]
797 fn perf_insert_1000_nodes() {
798 let start = std::time::Instant::now();
799 let mut g = FocusGraph::new();
800 for i in 0..1000 {
801 g.insert(node(i));
802 }
803 let elapsed = start.elapsed();
804 assert!(
805 elapsed.as_micros() < 5000,
806 "Inserting 1000 nodes took {}μs (budget: 5000μs)",
807 elapsed.as_micros()
808 );
809 }
810
811 #[test]
812 fn perf_navigate_10000() {
813 let mut g = FocusGraph::new();
814 for i in 0..100 {
815 g.insert(node(i).with_tab_index(i as i32));
816 }
817 g.build_tab_chain(true);
818
819 let start = std::time::Instant::now();
820 let mut current = 0;
821 for _ in 0..10_000 {
822 current = g.navigate(current, NavDirection::Next).unwrap();
823 }
824 let elapsed = start.elapsed();
825 assert!(current < 100);
827 let budget: u128 =
829 if std::env::var("CARGO_LLVM_COV").is_ok() || std::env::var("COVERAGE").is_ok() {
830 16_000
831 } else {
832 8_000
833 };
834 assert!(
835 elapsed.as_micros() < budget,
836 "10,000 navigations took {}μs (budget: {}μs)",
837 elapsed.as_micros(),
838 budget
839 );
840 }
841
842 #[test]
843 fn perf_cycle_detection_1000() {
844 let mut g = FocusGraph::new();
845 for i in 0..1000 {
846 g.insert(node(i).with_tab_index(i as i32));
847 }
848 g.build_tab_chain(true);
849
850 let start = std::time::Instant::now();
851 let cycle = g.find_cycle(0);
852 let elapsed = start.elapsed();
853
854 assert!(cycle.is_some());
855 assert!(
856 elapsed.as_micros() < 10_000,
857 "Cycle detection on 1000-node ring took {}μs (budget: 10000μs)",
858 elapsed.as_micros()
859 );
860 }
861}