Skip to main content

ftui_widgets/focus/
manager.rs

1#![forbid(unsafe_code)]
2
3//! Focus manager coordinating focus traversal, history, and traps.
4//!
5//! The manager tracks the current focus, maintains a navigation history,
6//! and enforces focus traps for modal dialogs. It also provides a
7//! configurable [`FocusIndicator`] for styling the focused widget.
8
9use ahash::AHashMap;
10
11use ftui_core::event::KeyCode;
12
13use super::indicator::FocusIndicator;
14use super::spatial;
15use super::{FocusGraph, FocusId, NavDirection};
16
17/// Focus change events emitted by the manager.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum FocusEvent {
20    FocusGained { id: FocusId },
21    FocusLost { id: FocusId },
22    FocusMoved { from: FocusId, to: FocusId },
23}
24
25/// Group of focusable widgets for tab traversal.
26#[derive(Debug, Clone)]
27pub struct FocusGroup {
28    pub id: u32,
29    pub members: Vec<FocusId>,
30    pub wrap: bool,
31    pub exit_key: Option<KeyCode>,
32}
33
34impl FocusGroup {
35    #[must_use]
36    pub fn new(id: u32, members: Vec<FocusId>) -> Self {
37        Self {
38            id,
39            members,
40            wrap: true,
41            exit_key: None,
42        }
43    }
44
45    #[must_use]
46    pub fn with_wrap(mut self, wrap: bool) -> Self {
47        self.wrap = wrap;
48        self
49    }
50
51    #[must_use]
52    pub fn with_exit_key(mut self, key: KeyCode) -> Self {
53        self.exit_key = Some(key);
54        self
55    }
56
57    fn contains(&self, id: FocusId) -> bool {
58        self.members.contains(&id)
59    }
60}
61
62/// Active focus trap (e.g., modal).
63#[derive(Debug, Clone, Copy)]
64pub struct FocusTrap {
65    pub group_id: u32,
66    pub return_focus: Option<FocusId>,
67}
68
69/// Central focus coordinator.
70///
71/// Tracks focus state, navigation history, focus traps (for modals),
72/// and focus indicator styling. Emits [`FocusEvent`]s on focus changes.
73#[derive(Debug, Default)]
74pub struct FocusManager {
75    graph: FocusGraph,
76    current: Option<FocusId>,
77    history: Vec<FocusId>,
78    trap_stack: Vec<FocusTrap>,
79    groups: AHashMap<u32, FocusGroup>,
80    last_event: Option<FocusEvent>,
81    indicator: FocusIndicator,
82    /// Running count of focus changes for metrics.
83    focus_change_count: u64,
84}
85
86impl FocusManager {
87    /// Create a new focus manager.
88    #[must_use]
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Access the underlying focus graph.
94    #[must_use]
95    pub fn graph(&self) -> &FocusGraph {
96        &self.graph
97    }
98
99    /// Mutably access the underlying focus graph.
100    pub fn graph_mut(&mut self) -> &mut FocusGraph {
101        &mut self.graph
102    }
103
104    /// Get currently focused widget.
105    #[inline]
106    #[must_use]
107    pub fn current(&self) -> Option<FocusId> {
108        self.current
109    }
110
111    /// Check if a widget is focused.
112    #[must_use]
113    pub fn is_focused(&self, id: FocusId) -> bool {
114        self.current == Some(id)
115    }
116
117    /// Set focus to widget, returns previous focus.
118    pub fn focus(&mut self, id: FocusId) -> Option<FocusId> {
119        if !self.can_focus(id) || !self.allowed_by_trap(id) {
120            return None;
121        }
122        let prev = self.current;
123        if prev == Some(id) {
124            return prev;
125        }
126        self.set_focus(id);
127        prev
128    }
129
130    /// Remove focus from current widget.
131    pub fn blur(&mut self) -> Option<FocusId> {
132        let prev = self.current.take();
133        if let Some(id) = prev {
134            #[cfg(feature = "tracing")]
135            tracing::debug!(from_widget = id, trigger = "blur", "focus.change");
136            self.last_event = Some(FocusEvent::FocusLost { id });
137            self.focus_change_count += 1;
138        }
139        prev
140    }
141
142    /// Move focus in direction.
143    pub fn navigate(&mut self, dir: NavDirection) -> bool {
144        match dir {
145            NavDirection::Next => self.focus_next(),
146            NavDirection::Prev => self.focus_prev(),
147            _ => {
148                let Some(current) = self.current else {
149                    return false;
150                };
151                // Explicit edges take precedence; fall back to spatial navigation.
152                let target = self
153                    .graph
154                    .navigate(current, dir)
155                    .or_else(|| spatial::spatial_navigate(&self.graph, current, dir));
156                let Some(target) = target else {
157                    return false;
158                };
159                if !self.allowed_by_trap(target) {
160                    return false;
161                }
162                self.set_focus(target)
163            }
164        }
165    }
166
167    /// Move to next in tab order.
168    pub fn focus_next(&mut self) -> bool {
169        self.move_in_tab_order(true)
170    }
171
172    /// Move to previous in tab order.
173    pub fn focus_prev(&mut self) -> bool {
174        self.move_in_tab_order(false)
175    }
176
177    /// Focus first focusable widget.
178    pub fn focus_first(&mut self) -> bool {
179        let order = self.active_tab_order();
180        let Some(first) = order.first().copied() else {
181            return false;
182        };
183        self.set_focus(first)
184    }
185
186    /// Focus last focusable widget.
187    pub fn focus_last(&mut self) -> bool {
188        let order = self.active_tab_order();
189        let Some(last) = order.last().copied() else {
190            return false;
191        };
192        self.set_focus(last)
193    }
194
195    /// Go back to previous focus.
196    pub fn focus_back(&mut self) -> bool {
197        while let Some(id) = self.history.pop() {
198            if self.can_focus(id) && self.allowed_by_trap(id) {
199                // Set focus directly without pushing current to history
200                // (going back shouldn't create a forward entry).
201                let prev = self.current;
202                self.current = Some(id);
203                self.last_event = Some(match prev {
204                    Some(from) => FocusEvent::FocusMoved { from, to: id },
205                    None => FocusEvent::FocusGained { id },
206                });
207                return true;
208            }
209        }
210        false
211    }
212
213    /// Clear focus history.
214    pub fn clear_history(&mut self) {
215        self.history.clear();
216    }
217
218    /// Push focus trap (for modals).
219    pub fn push_trap(&mut self, group_id: u32) {
220        let return_focus = self.current;
221        #[cfg(feature = "tracing")]
222        tracing::debug!(
223            group_id,
224            return_focus = ?return_focus,
225            "focus.trap_push"
226        );
227        self.trap_stack.push(FocusTrap {
228            group_id,
229            return_focus,
230        });
231
232        if !self.is_current_in_group(group_id) {
233            self.focus_first_in_group(group_id);
234        }
235    }
236
237    /// Pop focus trap, restore previous focus.
238    pub fn pop_trap(&mut self) -> bool {
239        let Some(trap) = self.trap_stack.pop() else {
240            return false;
241        };
242        #[cfg(feature = "tracing")]
243        tracing::debug!(
244            group_id = trap.group_id,
245            return_focus = ?trap.return_focus,
246            "focus.trap_pop"
247        );
248
249        if let Some(id) = trap.return_focus
250            && self.can_focus(id)
251            && self.allowed_by_trap(id)
252        {
253            return self.set_focus(id);
254        }
255
256        if let Some(active) = self.active_trap_group() {
257            return self.focus_first_in_group(active);
258        }
259
260        self.focus_first()
261    }
262
263    /// Check if focus is currently trapped.
264    #[must_use]
265    pub fn is_trapped(&self) -> bool {
266        !self.trap_stack.is_empty()
267    }
268
269    /// Create focus group.
270    pub fn create_group(&mut self, id: u32, members: Vec<FocusId>) {
271        let members = self.filter_focusable(members);
272        self.groups.insert(id, FocusGroup::new(id, members));
273    }
274
275    /// Add widget to group.
276    pub fn add_to_group(&mut self, group_id: u32, widget_id: FocusId) {
277        if !self.can_focus(widget_id) {
278            return;
279        }
280        let group = self
281            .groups
282            .entry(group_id)
283            .or_insert_with(|| FocusGroup::new(group_id, Vec::new()));
284        if !group.contains(widget_id) {
285            group.members.push(widget_id);
286        }
287    }
288
289    /// Remove widget from group.
290    pub fn remove_from_group(&mut self, group_id: u32, widget_id: FocusId) {
291        let Some(group) = self.groups.get_mut(&group_id) else {
292            return;
293        };
294        group.members.retain(|id| *id != widget_id);
295    }
296
297    /// Get the last focus event.
298    #[must_use]
299    pub fn focus_event(&self) -> Option<&FocusEvent> {
300        self.last_event.as_ref()
301    }
302
303    /// Take and clear the last focus event.
304    #[must_use]
305    pub fn take_focus_event(&mut self) -> Option<FocusEvent> {
306        self.last_event.take()
307    }
308
309    /// Get the focus indicator configuration.
310    #[inline]
311    #[must_use]
312    pub fn indicator(&self) -> &FocusIndicator {
313        &self.indicator
314    }
315
316    /// Set the focus indicator configuration.
317    pub fn set_indicator(&mut self, indicator: FocusIndicator) {
318        self.indicator = indicator;
319    }
320
321    /// Total number of focus changes since creation (for metrics).
322    #[inline]
323    #[must_use]
324    pub fn focus_change_count(&self) -> u64 {
325        self.focus_change_count
326    }
327
328    fn set_focus(&mut self, id: FocusId) -> bool {
329        if !self.can_focus(id) || !self.allowed_by_trap(id) {
330            return false;
331        }
332        if self.current == Some(id) {
333            return false;
334        }
335
336        let prev = self.current;
337        if let Some(prev_id) = prev {
338            if Some(prev_id) != self.history.last().copied() {
339                self.history.push(prev_id);
340            }
341            let event = FocusEvent::FocusMoved {
342                from: prev_id,
343                to: id,
344            };
345            #[cfg(feature = "tracing")]
346            tracing::debug!(
347                from_widget = prev_id,
348                to_widget = id,
349                trigger = "navigate",
350                "focus.change"
351            );
352            self.last_event = Some(event);
353        } else {
354            #[cfg(feature = "tracing")]
355            tracing::debug!(to_widget = id, trigger = "initial", "focus.change");
356            self.last_event = Some(FocusEvent::FocusGained { id });
357        }
358
359        self.current = Some(id);
360        self.focus_change_count += 1;
361        true
362    }
363
364    fn can_focus(&self, id: FocusId) -> bool {
365        self.graph.get(id).map(|n| n.is_focusable).unwrap_or(false)
366    }
367
368    fn active_trap_group(&self) -> Option<u32> {
369        self.trap_stack.last().map(|t| t.group_id)
370    }
371
372    fn allowed_by_trap(&self, id: FocusId) -> bool {
373        let Some(group_id) = self.active_trap_group() else {
374            return true;
375        };
376        self.groups
377            .get(&group_id)
378            .map(|g| g.contains(id))
379            .unwrap_or(false)
380    }
381
382    fn is_current_in_group(&self, group_id: u32) -> bool {
383        let Some(current) = self.current else {
384            return false;
385        };
386        self.groups
387            .get(&group_id)
388            .map(|g| g.contains(current))
389            .unwrap_or(false)
390    }
391
392    fn active_tab_order(&self) -> Vec<FocusId> {
393        if let Some(group_id) = self.active_trap_group() {
394            return self.group_tab_order(group_id);
395        }
396        self.graph.tab_order()
397    }
398
399    fn group_tab_order(&self, group_id: u32) -> Vec<FocusId> {
400        let Some(group) = self.groups.get(&group_id) else {
401            return Vec::new();
402        };
403        let order = self.graph.tab_order();
404        order.into_iter().filter(|id| group.contains(*id)).collect()
405    }
406
407    fn focus_first_in_group(&mut self, group_id: u32) -> bool {
408        let order = self.group_tab_order(group_id);
409        let Some(first) = order.first().copied() else {
410            return false;
411        };
412        self.set_focus(first)
413    }
414
415    fn move_in_tab_order(&mut self, forward: bool) -> bool {
416        let order = self.active_tab_order();
417        if order.is_empty() {
418            return false;
419        }
420
421        let wrap = self
422            .active_trap_group()
423            .and_then(|id| self.groups.get(&id).map(|g| g.wrap))
424            .unwrap_or(true);
425
426        let next = match self.current {
427            None => order[0],
428            Some(current) => {
429                let pos = order.iter().position(|id| *id == current);
430                match pos {
431                    None => order[0],
432                    Some(idx) if forward => {
433                        if idx + 1 < order.len() {
434                            order[idx + 1]
435                        } else if wrap {
436                            order[0]
437                        } else {
438                            return false;
439                        }
440                    }
441                    Some(idx) => {
442                        if idx > 0 {
443                            order[idx - 1]
444                        } else if wrap {
445                            *order.last().unwrap()
446                        } else {
447                            return false;
448                        }
449                    }
450                }
451            }
452        };
453
454        self.set_focus(next)
455    }
456
457    fn filter_focusable(&self, ids: Vec<FocusId>) -> Vec<FocusId> {
458        let mut out = Vec::new();
459        for id in ids {
460            if self.can_focus(id) && !out.contains(&id) {
461                out.push(id);
462            }
463        }
464        out
465    }
466}
467
468// =========================================================================
469// Tests
470// =========================================================================
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::focus::FocusNode;
476    use ftui_core::geometry::Rect;
477
478    fn node(id: FocusId, tab: i32) -> FocusNode {
479        FocusNode::new(id, Rect::new(0, 0, 1, 1)).with_tab_index(tab)
480    }
481
482    #[test]
483    fn focus_basic() {
484        let mut fm = FocusManager::new();
485        fm.graph_mut().insert(node(1, 0));
486        fm.graph_mut().insert(node(2, 1));
487
488        assert!(fm.focus(1).is_none());
489        assert_eq!(fm.current(), Some(1));
490
491        assert_eq!(fm.focus(2), Some(1));
492        assert_eq!(fm.current(), Some(2));
493
494        assert_eq!(fm.blur(), Some(2));
495        assert_eq!(fm.current(), None);
496    }
497
498    #[test]
499    fn focus_history_back() {
500        let mut fm = FocusManager::new();
501        fm.graph_mut().insert(node(1, 0));
502        fm.graph_mut().insert(node(2, 1));
503        fm.graph_mut().insert(node(3, 2));
504
505        fm.focus(1);
506        fm.focus(2);
507        fm.focus(3);
508
509        assert!(fm.focus_back());
510        assert_eq!(fm.current(), Some(2));
511
512        assert!(fm.focus_back());
513        assert_eq!(fm.current(), Some(1));
514    }
515
516    #[test]
517    fn focus_next_prev() {
518        let mut fm = FocusManager::new();
519        fm.graph_mut().insert(node(1, 0));
520        fm.graph_mut().insert(node(2, 1));
521        fm.graph_mut().insert(node(3, 2));
522
523        assert!(fm.focus_next());
524        assert_eq!(fm.current(), Some(1));
525
526        assert!(fm.focus_next());
527        assert_eq!(fm.current(), Some(2));
528
529        assert!(fm.focus_prev());
530        assert_eq!(fm.current(), Some(1));
531    }
532
533    #[test]
534    fn focus_trap_push_pop() {
535        let mut fm = FocusManager::new();
536        fm.graph_mut().insert(node(1, 0));
537        fm.graph_mut().insert(node(2, 1));
538        fm.graph_mut().insert(node(3, 2));
539
540        fm.focus(3);
541        fm.create_group(7, vec![1, 2]);
542
543        fm.push_trap(7);
544        assert!(fm.is_trapped());
545        assert_eq!(fm.current(), Some(1));
546
547        fm.pop_trap();
548        assert!(!fm.is_trapped());
549        assert_eq!(fm.current(), Some(3));
550    }
551
552    #[test]
553    fn focus_group_wrap_respected() {
554        let mut fm = FocusManager::new();
555        fm.graph_mut().insert(node(1, 0));
556        fm.graph_mut().insert(node(2, 1));
557        fm.create_group(9, vec![1, 2]);
558        fm.groups.get_mut(&9).unwrap().wrap = false;
559
560        fm.push_trap(9);
561        fm.focus(2);
562        assert!(!fm.focus_next());
563        assert_eq!(fm.current(), Some(2));
564    }
565
566    #[test]
567    fn focus_event_generation() {
568        let mut fm = FocusManager::new();
569        fm.graph_mut().insert(node(1, 0));
570        fm.graph_mut().insert(node(2, 1));
571
572        fm.focus(1);
573        assert_eq!(
574            fm.take_focus_event(),
575            Some(FocusEvent::FocusGained { id: 1 })
576        );
577
578        fm.focus(2);
579        assert_eq!(
580            fm.take_focus_event(),
581            Some(FocusEvent::FocusMoved { from: 1, to: 2 })
582        );
583
584        fm.blur();
585        assert_eq!(fm.take_focus_event(), Some(FocusEvent::FocusLost { id: 2 }));
586    }
587
588    #[test]
589    fn trap_prevents_focus_outside_group() {
590        let mut fm = FocusManager::new();
591        fm.graph_mut().insert(node(1, 0));
592        fm.graph_mut().insert(node(2, 1));
593        fm.graph_mut().insert(node(3, 2));
594        fm.create_group(5, vec![1, 2]);
595
596        fm.push_trap(5);
597        assert_eq!(fm.current(), Some(1));
598
599        // Attempt to focus outside trap should fail.
600        assert!(fm.focus(3).is_none());
601        assert_ne!(fm.current(), Some(3));
602    }
603
604    // --- Spatial navigation integration ---
605
606    fn spatial_node(id: FocusId, x: u16, y: u16, w: u16, h: u16, tab: i32) -> FocusNode {
607        FocusNode::new(id, Rect::new(x, y, w, h)).with_tab_index(tab)
608    }
609
610    #[test]
611    fn navigate_spatial_fallback() {
612        let mut fm = FocusManager::new();
613        // Two nodes side by side — no explicit edges.
614        fm.graph_mut().insert(spatial_node(1, 0, 0, 10, 3, 0));
615        fm.graph_mut().insert(spatial_node(2, 20, 0, 10, 3, 1));
616
617        fm.focus(1);
618        assert!(fm.navigate(NavDirection::Right));
619        assert_eq!(fm.current(), Some(2));
620
621        assert!(fm.navigate(NavDirection::Left));
622        assert_eq!(fm.current(), Some(1));
623    }
624
625    #[test]
626    fn navigate_explicit_edge_overrides_spatial() {
627        let mut fm = FocusManager::new();
628        fm.graph_mut().insert(spatial_node(1, 0, 0, 10, 3, 0));
629        fm.graph_mut().insert(spatial_node(2, 20, 0, 10, 3, 1)); // spatially right
630        fm.graph_mut().insert(spatial_node(3, 40, 0, 10, 3, 2)); // further right
631
632        // Explicit edge overrides spatial: Right from 1 goes to 3, not 2.
633        fm.graph_mut().connect(1, NavDirection::Right, 3);
634
635        fm.focus(1);
636        assert!(fm.navigate(NavDirection::Right));
637        assert_eq!(fm.current(), Some(3));
638    }
639
640    #[test]
641    fn navigate_spatial_respects_trap() {
642        let mut fm = FocusManager::new();
643        fm.graph_mut().insert(spatial_node(1, 0, 0, 10, 3, 0));
644        fm.graph_mut().insert(spatial_node(2, 20, 0, 10, 3, 1));
645        fm.graph_mut().insert(spatial_node(3, 40, 0, 10, 3, 2));
646
647        // Trap to group containing only 1 and 2.
648        fm.create_group(1, vec![1, 2]);
649        fm.focus(2);
650        fm.push_trap(1);
651
652        // Spatial would find 3 to the right of 2, but trap blocks it.
653        assert!(!fm.navigate(NavDirection::Right));
654        assert_eq!(fm.current(), Some(2));
655    }
656
657    #[test]
658    fn navigate_spatial_grid_round_trip() {
659        let mut fm = FocusManager::new();
660        // 2x2 grid.
661        fm.graph_mut().insert(spatial_node(1, 0, 0, 10, 3, 0));
662        fm.graph_mut().insert(spatial_node(2, 20, 0, 10, 3, 1));
663        fm.graph_mut().insert(spatial_node(3, 0, 6, 10, 3, 2));
664        fm.graph_mut().insert(spatial_node(4, 20, 6, 10, 3, 3));
665
666        fm.focus(1);
667
668        // Navigate around the grid: right, down, left, up — back to start.
669        assert!(fm.navigate(NavDirection::Right));
670        assert_eq!(fm.current(), Some(2));
671
672        assert!(fm.navigate(NavDirection::Down));
673        assert_eq!(fm.current(), Some(4));
674
675        assert!(fm.navigate(NavDirection::Left));
676        assert_eq!(fm.current(), Some(3));
677
678        assert!(fm.navigate(NavDirection::Up));
679        assert_eq!(fm.current(), Some(1));
680    }
681
682    #[test]
683    fn navigate_spatial_no_candidate() {
684        let mut fm = FocusManager::new();
685        fm.graph_mut().insert(spatial_node(1, 0, 0, 10, 3, 0));
686        fm.focus(1);
687
688        // No other nodes, spatial should return false.
689        assert!(!fm.navigate(NavDirection::Right));
690        assert!(!fm.navigate(NavDirection::Up));
691        assert_eq!(fm.current(), Some(1));
692    }
693
694    // --- FocusManager construction ---
695
696    #[test]
697    fn new_manager_has_no_focus() {
698        let fm = FocusManager::new();
699        assert_eq!(fm.current(), None);
700        assert!(!fm.is_trapped());
701    }
702
703    #[test]
704    fn default_and_new_are_equivalent() {
705        let a = FocusManager::new();
706        let b = FocusManager::default();
707        assert_eq!(a.current(), b.current());
708        assert_eq!(a.is_trapped(), b.is_trapped());
709    }
710
711    // --- is_focused ---
712
713    #[test]
714    fn is_focused_returns_true_for_current() {
715        let mut fm = FocusManager::new();
716        fm.graph_mut().insert(node(1, 0));
717        fm.focus(1);
718        assert!(fm.is_focused(1));
719        assert!(!fm.is_focused(2));
720    }
721
722    #[test]
723    fn is_focused_returns_false_when_no_focus() {
724        let fm = FocusManager::new();
725        assert!(!fm.is_focused(1));
726    }
727
728    // --- focus edge cases ---
729
730    #[test]
731    fn focus_non_existent_node_returns_none() {
732        let mut fm = FocusManager::new();
733        assert!(fm.focus(999).is_none());
734        assert_eq!(fm.current(), None);
735    }
736
737    #[test]
738    fn focus_already_focused_returns_same_id() {
739        let mut fm = FocusManager::new();
740        fm.graph_mut().insert(node(1, 0));
741        fm.focus(1);
742        // Focusing same node returns current (early exit)
743        assert_eq!(fm.focus(1), Some(1));
744        assert_eq!(fm.current(), Some(1));
745    }
746
747    // --- blur ---
748
749    #[test]
750    fn blur_when_no_focus_returns_none() {
751        let mut fm = FocusManager::new();
752        assert_eq!(fm.blur(), None);
753    }
754
755    #[test]
756    fn blur_generates_focus_lost_event() {
757        let mut fm = FocusManager::new();
758        fm.graph_mut().insert(node(1, 0));
759        fm.focus(1);
760        let _ = fm.take_focus_event(); // clear
761        fm.blur();
762        assert_eq!(fm.take_focus_event(), Some(FocusEvent::FocusLost { id: 1 }));
763    }
764
765    // --- focus_first / focus_last ---
766
767    #[test]
768    fn focus_first_selects_lowest_tab_index() {
769        let mut fm = FocusManager::new();
770        fm.graph_mut().insert(node(3, 2));
771        fm.graph_mut().insert(node(1, 0));
772        fm.graph_mut().insert(node(2, 1));
773
774        assert!(fm.focus_first());
775        assert_eq!(fm.current(), Some(1));
776    }
777
778    #[test]
779    fn focus_last_selects_highest_tab_index() {
780        let mut fm = FocusManager::new();
781        fm.graph_mut().insert(node(1, 0));
782        fm.graph_mut().insert(node(2, 1));
783        fm.graph_mut().insert(node(3, 2));
784
785        assert!(fm.focus_last());
786        assert_eq!(fm.current(), Some(3));
787    }
788
789    #[test]
790    fn focus_first_on_empty_graph_returns_false() {
791        let mut fm = FocusManager::new();
792        assert!(!fm.focus_first());
793    }
794
795    #[test]
796    fn focus_last_on_empty_graph_returns_false() {
797        let mut fm = FocusManager::new();
798        assert!(!fm.focus_last());
799    }
800
801    // --- Tab wrapping ---
802
803    #[test]
804    fn focus_next_wraps_at_end() {
805        let mut fm = FocusManager::new();
806        fm.graph_mut().insert(node(1, 0));
807        fm.graph_mut().insert(node(2, 1));
808
809        fm.focus(2);
810        assert!(fm.focus_next()); // wraps
811        assert_eq!(fm.current(), Some(1));
812    }
813
814    #[test]
815    fn focus_prev_wraps_at_start() {
816        let mut fm = FocusManager::new();
817        fm.graph_mut().insert(node(1, 0));
818        fm.graph_mut().insert(node(2, 1));
819
820        fm.focus(1);
821        assert!(fm.focus_prev()); // wraps
822        assert_eq!(fm.current(), Some(2));
823    }
824
825    #[test]
826    fn focus_next_with_no_current_selects_first() {
827        let mut fm = FocusManager::new();
828        fm.graph_mut().insert(node(1, 0));
829        fm.graph_mut().insert(node(2, 1));
830
831        assert!(fm.focus_next());
832        assert_eq!(fm.current(), Some(1));
833    }
834
835    #[test]
836    fn focus_next_on_empty_returns_false() {
837        let mut fm = FocusManager::new();
838        assert!(!fm.focus_next());
839    }
840
841    // --- History ---
842
843    #[test]
844    fn focus_back_on_empty_history_returns_false() {
845        let mut fm = FocusManager::new();
846        fm.graph_mut().insert(node(1, 0));
847        fm.focus(1);
848        assert!(!fm.focus_back());
849    }
850
851    #[test]
852    fn clear_history_prevents_back() {
853        let mut fm = FocusManager::new();
854        fm.graph_mut().insert(node(1, 0));
855        fm.graph_mut().insert(node(2, 1));
856
857        fm.focus(1);
858        fm.focus(2);
859        fm.clear_history();
860        assert!(!fm.focus_back());
861        assert_eq!(fm.current(), Some(2));
862    }
863
864    #[test]
865    fn focus_back_skips_removed_nodes() {
866        let mut fm = FocusManager::new();
867        fm.graph_mut().insert(node(1, 0));
868        fm.graph_mut().insert(node(2, 1));
869        fm.graph_mut().insert(node(3, 2));
870
871        fm.focus(1);
872        fm.focus(2);
873        fm.focus(3);
874
875        // Remove node 2 from graph
876        let _ = fm.graph_mut().remove(2);
877
878        // focus_back should skip 2 and go to 1
879        assert!(fm.focus_back());
880        assert_eq!(fm.current(), Some(1));
881    }
882
883    // --- Groups ---
884
885    #[test]
886    fn create_group_filters_non_focusable() {
887        let mut fm = FocusManager::new();
888        fm.graph_mut().insert(node(1, 0));
889        // Node 999 doesn't exist in the graph
890        fm.create_group(1, vec![1, 999]);
891
892        let group = fm.groups.get(&1).unwrap();
893        assert_eq!(group.members.len(), 1);
894        assert!(group.contains(1));
895    }
896
897    #[test]
898    fn add_to_group_creates_group_if_needed() {
899        let mut fm = FocusManager::new();
900        fm.graph_mut().insert(node(1, 0));
901        fm.add_to_group(42, 1);
902        assert!(fm.groups.contains_key(&42));
903        assert!(fm.groups.get(&42).unwrap().contains(1));
904    }
905
906    #[test]
907    fn add_to_group_skips_unfocusable() {
908        let mut fm = FocusManager::new();
909        fm.add_to_group(1, 999); // 999 not in graph
910        // Group may or may not exist, but if it does, 999 is not in it
911        if let Some(group) = fm.groups.get(&1) {
912            assert!(!group.contains(999));
913        }
914    }
915
916    #[test]
917    fn add_to_group_no_duplicates() {
918        let mut fm = FocusManager::new();
919        fm.graph_mut().insert(node(1, 0));
920        fm.add_to_group(1, 1);
921        fm.add_to_group(1, 1);
922        assert_eq!(fm.groups.get(&1).unwrap().members.len(), 1);
923    }
924
925    #[test]
926    fn remove_from_group() {
927        let mut fm = FocusManager::new();
928        fm.graph_mut().insert(node(1, 0));
929        fm.graph_mut().insert(node(2, 1));
930        fm.create_group(1, vec![1, 2]);
931        fm.remove_from_group(1, 1);
932        assert!(!fm.groups.get(&1).unwrap().contains(1));
933        assert!(fm.groups.get(&1).unwrap().contains(2));
934    }
935
936    #[test]
937    fn remove_from_nonexistent_group_is_noop() {
938        let mut fm = FocusManager::new();
939        fm.remove_from_group(999, 1); // should not panic
940    }
941
942    // --- FocusGroup ---
943
944    #[test]
945    fn focus_group_with_wrap() {
946        let group = FocusGroup::new(1, vec![1, 2]).with_wrap(false);
947        assert!(!group.wrap);
948    }
949
950    #[test]
951    fn focus_group_with_exit_key() {
952        let group = FocusGroup::new(1, vec![]).with_exit_key(KeyCode::Escape);
953        assert_eq!(group.exit_key, Some(KeyCode::Escape));
954    }
955
956    #[test]
957    fn focus_group_default_wraps() {
958        let group = FocusGroup::new(1, vec![]);
959        assert!(group.wrap);
960        assert_eq!(group.exit_key, None);
961    }
962
963    // --- Trap stack ---
964
965    #[test]
966    fn nested_traps() {
967        let mut fm = FocusManager::new();
968        fm.graph_mut().insert(node(1, 0));
969        fm.graph_mut().insert(node(2, 1));
970        fm.graph_mut().insert(node(3, 2));
971        fm.graph_mut().insert(node(4, 3));
972
973        fm.create_group(10, vec![1, 2]);
974        fm.create_group(20, vec![3, 4]);
975
976        fm.focus(1);
977        fm.push_trap(10);
978        assert!(fm.is_trapped());
979
980        fm.push_trap(20);
981        // Should be in inner trap, focused on first of group 20
982        assert_eq!(fm.current(), Some(3));
983
984        // Pop inner trap
985        fm.pop_trap();
986        // Should still be trapped (in group 10)
987        assert!(fm.is_trapped());
988
989        // Pop outer trap
990        fm.pop_trap();
991        assert!(!fm.is_trapped());
992    }
993
994    #[test]
995    fn pop_trap_on_empty_returns_false() {
996        let mut fm = FocusManager::new();
997        assert!(!fm.pop_trap());
998    }
999
1000    // --- Focus events ---
1001
1002    #[test]
1003    fn take_focus_event_clears_it() {
1004        let mut fm = FocusManager::new();
1005        fm.graph_mut().insert(node(1, 0));
1006        fm.focus(1);
1007
1008        assert!(fm.take_focus_event().is_some());
1009        assert!(fm.take_focus_event().is_none());
1010    }
1011
1012    #[test]
1013    fn focus_event_accessor() {
1014        let mut fm = FocusManager::new();
1015        fm.graph_mut().insert(node(1, 0));
1016        fm.focus(1);
1017
1018        assert_eq!(fm.focus_event(), Some(&FocusEvent::FocusGained { id: 1 }));
1019    }
1020
1021    // --- Navigate with no current ---
1022
1023    #[test]
1024    fn navigate_direction_with_no_current_returns_false() {
1025        let mut fm = FocusManager::new();
1026        fm.graph_mut().insert(spatial_node(1, 0, 0, 10, 3, 0));
1027        assert!(!fm.navigate(NavDirection::Right));
1028    }
1029
1030    // --- graph accessors ---
1031
1032    #[test]
1033    fn graph_accessor_returns_reference() {
1034        let mut fm = FocusManager::new();
1035        fm.graph_mut().insert(node(1, 0));
1036        assert!(fm.graph().get(1).is_some());
1037    }
1038
1039    // --- Focus indicator ---
1040
1041    #[test]
1042    fn default_indicator_is_reverse() {
1043        let fm = FocusManager::new();
1044        assert!(fm.indicator().is_visible());
1045        assert_eq!(
1046            fm.indicator().kind(),
1047            crate::focus::FocusIndicatorKind::StyleOverlay
1048        );
1049    }
1050
1051    #[test]
1052    fn set_indicator() {
1053        let mut fm = FocusManager::new();
1054        fm.set_indicator(crate::focus::FocusIndicator::underline());
1055        assert_eq!(
1056            fm.indicator().kind(),
1057            crate::focus::FocusIndicatorKind::Underline
1058        );
1059    }
1060
1061    // --- Focus change count ---
1062
1063    #[test]
1064    fn focus_change_count_increments() {
1065        let mut fm = FocusManager::new();
1066        fm.graph_mut().insert(node(1, 0));
1067        fm.graph_mut().insert(node(2, 1));
1068
1069        assert_eq!(fm.focus_change_count(), 0);
1070
1071        fm.focus(1);
1072        assert_eq!(fm.focus_change_count(), 1);
1073
1074        fm.focus(2);
1075        assert_eq!(fm.focus_change_count(), 2);
1076
1077        fm.blur();
1078        assert_eq!(fm.focus_change_count(), 3);
1079    }
1080
1081    #[test]
1082    fn focus_change_count_zero_on_no_op() {
1083        let mut fm = FocusManager::new();
1084        fm.graph_mut().insert(node(1, 0));
1085        fm.focus(1);
1086        assert_eq!(fm.focus_change_count(), 1);
1087
1088        // Focusing the same widget is a no-op
1089        fm.focus(1);
1090        assert_eq!(fm.focus_change_count(), 1);
1091    }
1092}