1#![forbid(unsafe_code)]
2
3use ftui_core::geometry::Rect;
27use std::collections::HashMap;
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: HashMap<FocusId, FocusNode>,
114 edges: HashMap<(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 pub fn remove(&mut self, id: FocusId) -> Option<FocusNode> {
138 let node = self.nodes.remove(&id)?;
139
140 for dir in NavDirection::ALL {
142 self.edges.remove(&(id, dir));
143 }
144
145 self.edges.retain(|_, target| *target != id);
147
148 Some(node)
149 }
150
151 pub fn connect(&mut self, from: FocusId, dir: NavDirection, to: FocusId) {
155 if self.nodes.contains_key(&from) && self.nodes.contains_key(&to) {
156 self.edges.insert((from, dir), to);
157 }
158 }
159
160 pub fn disconnect(&mut self, from: FocusId, dir: NavDirection) {
162 self.edges.remove(&(from, dir));
163 }
164
165 #[must_use]
169 pub fn navigate(&self, from: FocusId, dir: NavDirection) -> Option<FocusId> {
170 self.edges.get(&(from, dir)).copied()
171 }
172
173 #[must_use]
175 pub fn get(&self, id: FocusId) -> Option<&FocusNode> {
176 self.nodes.get(&id)
177 }
178
179 #[must_use]
181 pub fn node_count(&self) -> usize {
182 self.nodes.len()
183 }
184
185 #[must_use]
187 pub fn edge_count(&self) -> usize {
188 self.edges.len()
189 }
190
191 #[must_use]
193 pub fn is_empty(&self) -> bool {
194 self.nodes.is_empty()
195 }
196
197 pub fn node_ids(&self) -> impl Iterator<Item = FocusId> + '_ {
199 self.nodes.keys().copied()
200 }
201
202 pub fn tab_order(&self) -> Vec<FocusId> {
205 let mut ordered: Vec<_> = self
206 .nodes
207 .values()
208 .filter(|n| n.is_focusable && n.tab_index >= 0)
209 .collect();
210 ordered.sort_by(|a, b| a.tab_index.cmp(&b.tab_index).then(a.id.cmp(&b.id)));
211 ordered.iter().map(|n| n.id).collect()
212 }
213
214 pub fn group_tab_order(&self, group: u32) -> Vec<FocusId> {
216 let mut ordered: Vec<_> = self
217 .nodes
218 .values()
219 .filter(|n| n.is_focusable && n.tab_index >= 0 && n.group_id == Some(group))
220 .collect();
221 ordered.sort_by(|a, b| a.tab_index.cmp(&b.tab_index).then(a.id.cmp(&b.id)));
222 ordered.iter().map(|n| n.id).collect()
223 }
224
225 #[must_use]
232 pub fn find_cycle(&self, start: FocusId) -> Option<Vec<FocusId>> {
233 let mut slow = start;
235 let mut fast = start;
236
237 loop {
238 slow = self.navigate(slow, NavDirection::Next)?;
239 fast = self.navigate(fast, NavDirection::Next)?;
240 fast = self.navigate(fast, NavDirection::Next)?;
241 if slow == fast {
242 break;
243 }
244 }
245
246 let mut p1 = start;
248 let mut p2 = slow;
249 while p1 != p2 {
250 p1 = self.navigate(p1, NavDirection::Next)?;
251 p2 = self.navigate(p2, NavDirection::Next)?;
252 }
253
254 let cycle_start = p1;
256 let mut cycle = vec![cycle_start];
257 let mut current = self.navigate(cycle_start, NavDirection::Next)?;
258 while current != cycle_start {
259 cycle.push(current);
260 current = self.navigate(current, NavDirection::Next)?;
261 }
262 cycle.push(cycle_start); Some(cycle)
265 }
266
267 #[must_use]
271 pub fn find_cycle_in_direction(
272 &self,
273 start: FocusId,
274 dir: NavDirection,
275 ) -> Option<Vec<FocusId>> {
276 let mut slow = start;
277 let mut fast = start;
278
279 loop {
280 slow = self.navigate(slow, dir)?;
281 fast = self.navigate(fast, dir)?;
282 fast = self.navigate(fast, dir)?;
283 if slow == fast {
284 break;
285 }
286 }
287
288 let mut p1 = start;
289 let mut p2 = slow;
290 while p1 != p2 {
291 p1 = self.navigate(p1, dir)?;
292 p2 = self.navigate(p2, dir)?;
293 }
294
295 let cycle_start = p1;
296 let mut cycle = vec![cycle_start];
297 let mut current = self.navigate(cycle_start, dir)?;
298 while current != cycle_start {
299 cycle.push(current);
300 current = self.navigate(current, dir)?;
301 }
302 cycle.push(cycle_start);
303
304 Some(cycle)
305 }
306
307 pub fn build_tab_chain(&mut self, wrap: bool) {
312 self.edges
313 .retain(|(_, dir), _| *dir != NavDirection::Next && *dir != NavDirection::Prev);
314 let order = self.tab_order();
315 if order.len() < 2 {
316 return;
317 }
318
319 for pair in order.windows(2) {
320 self.edges.insert((pair[0], NavDirection::Next), pair[1]);
321 self.edges.insert((pair[1], NavDirection::Prev), pair[0]);
322 }
323
324 if wrap {
325 let first = order[0];
326 let last = *order.last().unwrap();
327 self.edges.insert((last, NavDirection::Next), first);
328 self.edges.insert((first, NavDirection::Prev), last);
329 }
330 }
331
332 pub fn clear(&mut self) {
334 self.nodes.clear();
335 self.edges.clear();
336 }
337}
338
339#[cfg(test)]
344mod tests {
345 use super::*;
346
347 fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
348 Rect::new(x, y, w, h)
349 }
350
351 fn node(id: FocusId) -> FocusNode {
352 FocusNode::new(id, rect(0, 0, 10, 1))
353 }
354
355 #[test]
358 fn empty_graph() {
359 let g = FocusGraph::new();
360 assert!(g.is_empty());
361 assert_eq!(g.node_count(), 0);
362 assert_eq!(g.edge_count(), 0);
363 }
364
365 #[test]
366 fn insert_node() {
367 let mut g = FocusGraph::new();
368 let id = g.insert(node(1));
369 assert_eq!(id, 1);
370 assert_eq!(g.node_count(), 1);
371 assert!(g.get(1).is_some());
372 }
373
374 #[test]
375 fn insert_replaces_existing() {
376 let mut g = FocusGraph::new();
377 g.insert(FocusNode::new(1, rect(0, 0, 10, 1)));
378 g.insert(FocusNode::new(1, rect(5, 5, 20, 2)));
379 assert_eq!(g.node_count(), 1);
380 assert_eq!(g.get(1).unwrap().bounds, rect(5, 5, 20, 2));
381 }
382
383 #[test]
384 fn remove_node() {
385 let mut g = FocusGraph::new();
386 g.insert(node(1));
387 let removed = g.remove(1);
388 assert!(removed.is_some());
389 assert_eq!(removed.unwrap().id, 1);
390 assert!(g.is_empty());
391 }
392
393 #[test]
394 fn remove_nonexistent() {
395 let mut g = FocusGraph::new();
396 assert!(g.remove(42).is_none());
397 }
398
399 #[test]
402 fn navigate_connected() {
403 let mut g = FocusGraph::new();
404 g.insert(node(1));
405 g.insert(node(2));
406 g.connect(1, NavDirection::Right, 2);
407
408 assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
409 }
410
411 #[test]
412 fn navigate_unconnected() {
413 let mut g = FocusGraph::new();
414 g.insert(node(1));
415 assert_eq!(g.navigate(1, NavDirection::Up), None);
416 }
417
418 #[test]
419 fn navigate_nonexistent_node() {
420 let g = FocusGraph::new();
421 assert_eq!(g.navigate(99, NavDirection::Down), None);
422 }
423
424 #[test]
425 fn connect_ignores_missing_nodes() {
426 let mut g = FocusGraph::new();
427 g.insert(node(1));
428 g.connect(1, NavDirection::Next, 99); assert_eq!(g.navigate(1, NavDirection::Next), None);
430 assert_eq!(g.edge_count(), 0);
431 }
432
433 #[test]
434 fn disconnect_edge() {
435 let mut g = FocusGraph::new();
436 g.insert(node(1));
437 g.insert(node(2));
438 g.connect(1, NavDirection::Right, 2);
439 assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
440
441 g.disconnect(1, NavDirection::Right);
442 assert_eq!(g.navigate(1, NavDirection::Right), None);
443 }
444
445 #[test]
448 fn remove_cleans_outgoing_edges() {
449 let mut g = FocusGraph::new();
450 g.insert(node(1));
451 g.insert(node(2));
452 g.connect(1, NavDirection::Next, 2);
453 g.remove(1);
454 assert_eq!(g.edge_count(), 0);
456 }
457
458 #[test]
459 fn remove_cleans_incoming_edges() {
460 let mut g = FocusGraph::new();
461 g.insert(node(1));
462 g.insert(node(2));
463 g.connect(1, NavDirection::Next, 2);
464 g.remove(2);
465 assert_eq!(g.edge_count(), 0);
467 assert_eq!(g.navigate(1, NavDirection::Next), None);
468 }
469
470 #[test]
473 fn tab_order_sorts_by_index_then_id() {
474 let mut g = FocusGraph::new();
475 g.insert(node(3).with_tab_index(1));
476 g.insert(node(1).with_tab_index(0));
477 g.insert(node(2).with_tab_index(0));
478
479 let order = g.tab_order();
480 assert_eq!(order, vec![1, 2, 3]);
481 }
482
483 #[test]
484 fn tab_order_skips_negative_index() {
485 let mut g = FocusGraph::new();
486 g.insert(node(1).with_tab_index(0));
487 g.insert(node(2).with_tab_index(-1));
488 g.insert(node(3).with_tab_index(1));
489
490 let order = g.tab_order();
491 assert_eq!(order, vec![1, 3]);
492 }
493
494 #[test]
495 fn tab_order_skips_unfocusable() {
496 let mut g = FocusGraph::new();
497 g.insert(node(1));
498 g.insert(node(2).with_focusable(false));
499 g.insert(node(3));
500
501 let order = g.tab_order();
502 assert_eq!(order, vec![1, 3]);
503 }
504
505 #[test]
508 fn group_tab_order_filters_by_group() {
509 let mut g = FocusGraph::new();
510 g.insert(node(1).with_group(1));
511 g.insert(node(2).with_group(2));
512 g.insert(node(3).with_group(1).with_tab_index(1));
513
514 let order = g.group_tab_order(1);
515 assert_eq!(order, vec![1, 3]);
516 }
517
518 #[test]
521 fn build_tab_chain_no_wrap() {
522 let mut g = FocusGraph::new();
523 g.insert(node(1).with_tab_index(0));
524 g.insert(node(2).with_tab_index(1));
525 g.insert(node(3).with_tab_index(2));
526
527 g.build_tab_chain(false);
528
529 assert_eq!(g.navigate(1, NavDirection::Next), Some(2));
530 assert_eq!(g.navigate(2, NavDirection::Next), Some(3));
531 assert_eq!(g.navigate(3, NavDirection::Next), None);
532
533 assert_eq!(g.navigate(3, NavDirection::Prev), Some(2));
534 assert_eq!(g.navigate(2, NavDirection::Prev), Some(1));
535 assert_eq!(g.navigate(1, NavDirection::Prev), None);
536 }
537
538 #[test]
539 fn build_tab_chain_wrap() {
540 let mut g = FocusGraph::new();
541 g.insert(node(1).with_tab_index(0));
542 g.insert(node(2).with_tab_index(1));
543 g.insert(node(3).with_tab_index(2));
544
545 g.build_tab_chain(true);
546
547 assert_eq!(g.navigate(3, NavDirection::Next), Some(1));
548 assert_eq!(g.navigate(1, NavDirection::Prev), Some(3));
549 }
550
551 #[test]
552 fn build_tab_chain_single_node_noop() {
553 let mut g = FocusGraph::new();
554 g.insert(node(1));
555 g.build_tab_chain(true);
556 assert_eq!(g.edge_count(), 0);
557 }
558
559 #[test]
562 fn no_cycle_linear() {
563 let mut g = FocusGraph::new();
564 g.insert(node(1));
565 g.insert(node(2));
566 g.insert(node(3));
567 g.connect(1, NavDirection::Next, 2);
568 g.connect(2, NavDirection::Next, 3);
569
570 assert!(g.find_cycle(1).is_none());
571 }
572
573 #[test]
574 fn simple_cycle() {
575 let mut g = FocusGraph::new();
576 g.insert(node(1));
577 g.insert(node(2));
578 g.insert(node(3));
579 g.connect(1, NavDirection::Next, 2);
580 g.connect(2, NavDirection::Next, 3);
581 g.connect(3, NavDirection::Next, 1);
582
583 let cycle = g.find_cycle(1);
584 assert!(cycle.is_some());
585 let c = cycle.unwrap();
586 assert_eq!(c.first(), c.last());
588 assert_eq!(c.len(), 4); }
590
591 #[test]
592 fn self_loop_cycle() {
593 let mut g = FocusGraph::new();
594 g.insert(node(1));
595 g.connect(1, NavDirection::Next, 1);
596
597 let cycle = g.find_cycle(1);
598 assert!(cycle.is_some());
599 let c = cycle.unwrap();
600 assert_eq!(c, vec![1, 1]);
601 }
602
603 #[test]
604 fn cycle_in_middle() {
605 let mut g = FocusGraph::new();
607 for id in 1..=4 {
608 g.insert(node(id));
609 }
610 g.connect(1, NavDirection::Next, 2);
611 g.connect(2, NavDirection::Next, 3);
612 g.connect(3, NavDirection::Next, 4);
613 g.connect(4, NavDirection::Next, 2);
614
615 let cycle = g.find_cycle(1);
616 assert!(cycle.is_some());
617 let c = cycle.unwrap();
618 assert_eq!(c.first(), c.last());
619 assert_eq!(c.len(), 4);
621 assert!(c.contains(&2));
622 assert!(c.contains(&3));
623 assert!(c.contains(&4));
624 }
625
626 #[test]
627 fn find_cycle_from_nonexistent_start() {
628 let g = FocusGraph::new();
629 assert!(g.find_cycle(99).is_none());
630 }
631
632 #[test]
633 fn find_cycle_in_direction_right() {
634 let mut g = FocusGraph::new();
635 g.insert(node(1));
636 g.insert(node(2));
637 g.connect(1, NavDirection::Right, 2);
638 g.connect(2, NavDirection::Right, 1);
639
640 let cycle = g.find_cycle_in_direction(1, NavDirection::Right);
641 assert!(cycle.is_some());
642 }
643
644 #[test]
647 fn clear_empties_graph() {
648 let mut g = FocusGraph::new();
649 g.insert(node(1));
650 g.insert(node(2));
651 g.connect(1, NavDirection::Next, 2);
652 g.clear();
653 assert!(g.is_empty());
654 assert_eq!(g.edge_count(), 0);
655 }
656
657 #[test]
660 fn node_builder_defaults() {
661 let n = FocusNode::new(1, rect(0, 0, 10, 1));
662 assert_eq!(n.tab_index, 0);
663 assert!(n.is_focusable);
664 assert!(n.group_id.is_none());
665 }
666
667 #[test]
668 fn node_builder_chain() {
669 let n = FocusNode::new(1, rect(0, 0, 10, 1))
670 .with_tab_index(5)
671 .with_focusable(false)
672 .with_group(3);
673 assert_eq!(n.tab_index, 5);
674 assert!(!n.is_focusable);
675 assert_eq!(n.group_id, Some(3));
676 }
677
678 #[test]
681 fn multiple_directions_same_source() {
682 let mut g = FocusGraph::new();
683 g.insert(node(1));
684 g.insert(node(2));
685 g.insert(node(3));
686 g.connect(1, NavDirection::Right, 2);
687 g.connect(1, NavDirection::Down, 3);
688
689 assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
690 assert_eq!(g.navigate(1, NavDirection::Down), Some(3));
691 assert_eq!(g.edge_count(), 2);
692 }
693
694 #[test]
695 fn overwrite_edge() {
696 let mut g = FocusGraph::new();
697 g.insert(node(1));
698 g.insert(node(2));
699 g.insert(node(3));
700 g.connect(1, NavDirection::Next, 2);
701 g.connect(1, NavDirection::Next, 3);
702
703 assert_eq!(g.navigate(1, NavDirection::Next), Some(3));
704 assert_eq!(g.edge_count(), 1);
705 }
706
707 #[test]
708 fn node_ids_iteration() {
709 let mut g = FocusGraph::new();
710 g.insert(node(10));
711 g.insert(node(20));
712 g.insert(node(30));
713
714 let mut ids: Vec<_> = g.node_ids().collect();
715 ids.sort();
716 assert_eq!(ids, vec![10, 20, 30]);
717 }
718
719 #[test]
722 fn property_remove_insert_idempotent() {
723 let mut g = FocusGraph::new();
724 let n = node(1);
725 g.insert(n.clone());
726 g.remove(1);
727 g.insert(n.clone());
728 assert_eq!(g.node_count(), 1);
729 assert_eq!(g.get(1).unwrap().id, 1);
730 }
731
732 #[test]
733 fn property_tab_chain_wrap_forms_cycle() {
734 let mut g = FocusGraph::new();
735 for i in 1..=5 {
736 g.insert(node(i).with_tab_index(i as i32));
737 }
738 g.build_tab_chain(true);
739
740 let cycle = g.find_cycle(1);
741 assert!(cycle.is_some());
742 let c = cycle.unwrap();
743 assert_eq!(c.len(), 6); }
745
746 #[test]
747 fn property_tab_chain_no_wrap_no_cycle() {
748 let mut g = FocusGraph::new();
749 for i in 1..=5 {
750 g.insert(node(i).with_tab_index(i as i32));
751 }
752 g.build_tab_chain(false);
753
754 assert!(g.find_cycle(1).is_none());
755 }
756
757 #[test]
758 fn property_bidirectional_chain_consistency() {
759 let mut g = FocusGraph::new();
760 for i in 1..=4 {
761 g.insert(node(i).with_tab_index(i as i32));
762 }
763 g.build_tab_chain(false);
764
765 for id in 1..=3u64 {
767 let next = g.navigate(id, NavDirection::Next).unwrap();
768 let prev_of_next = g.navigate(next, NavDirection::Prev).unwrap();
769 assert_eq!(prev_of_next, id);
770 }
771 }
772
773 #[test]
776 fn stress_many_nodes() {
777 let mut g = FocusGraph::new();
778 for i in 0..1000 {
779 g.insert(node(i).with_tab_index(i as i32));
780 }
781 g.build_tab_chain(true);
782
783 assert_eq!(g.node_count(), 1000);
784 let mut current = 0;
786 for _ in 0..1000 {
787 current = g.navigate(current, NavDirection::Next).unwrap();
788 }
789 assert_eq!(current, 0); }
791
792 #[test]
795 fn perf_insert_1000_nodes() {
796 let start = std::time::Instant::now();
797 let mut g = FocusGraph::new();
798 for i in 0..1000 {
799 g.insert(node(i));
800 }
801 let elapsed = start.elapsed();
802 assert!(
803 elapsed.as_micros() < 5000,
804 "Inserting 1000 nodes took {}μs (budget: 5000μs)",
805 elapsed.as_micros()
806 );
807 }
808
809 #[test]
810 fn perf_navigate_10000() {
811 let mut g = FocusGraph::new();
812 for i in 0..100 {
813 g.insert(node(i).with_tab_index(i as i32));
814 }
815 g.build_tab_chain(true);
816
817 let start = std::time::Instant::now();
818 let mut current = 0;
819 for _ in 0..10_000 {
820 current = g.navigate(current, NavDirection::Next).unwrap();
821 }
822 let elapsed = start.elapsed();
823 assert!(current < 100);
825 assert!(
826 elapsed.as_micros() < 5000,
827 "10,000 navigations took {}μs (budget: 5000μs)",
828 elapsed.as_micros()
829 );
830 }
831
832 #[test]
833 fn perf_cycle_detection_1000() {
834 let mut g = FocusGraph::new();
835 for i in 0..1000 {
836 g.insert(node(i).with_tab_index(i as i32));
837 }
838 g.build_tab_chain(true);
839
840 let start = std::time::Instant::now();
841 let cycle = g.find_cycle(0);
842 let elapsed = start.elapsed();
843
844 assert!(cycle.is_some());
845 assert!(
846 elapsed.as_micros() < 10_000,
847 "Cycle detection on 1000-node ring took {}μs (budget: 10000μs)",
848 elapsed.as_micros()
849 );
850 }
851}