Skip to main content

ftui_widgets/focus/
manager.rs

1#![forbid(unsafe_code)]
2
3//! Focus manager coordinating focus traversal, history, and traps.
4
5use std::collections::HashMap;
6
7use ftui_core::event::KeyCode;
8
9use super::spatial;
10use super::{FocusGraph, FocusId, NavDirection};
11
12/// Focus change events emitted by the manager.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum FocusEvent {
15    FocusGained { id: FocusId },
16    FocusLost { id: FocusId },
17    FocusMoved { from: FocusId, to: FocusId },
18}
19
20/// Group of focusable widgets for tab traversal.
21#[derive(Debug, Clone)]
22pub struct FocusGroup {
23    pub id: u32,
24    pub members: Vec<FocusId>,
25    pub wrap: bool,
26    pub exit_key: Option<KeyCode>,
27}
28
29impl FocusGroup {
30    #[must_use]
31    pub fn new(id: u32, members: Vec<FocusId>) -> Self {
32        Self {
33            id,
34            members,
35            wrap: true,
36            exit_key: None,
37        }
38    }
39
40    #[must_use]
41    pub fn with_wrap(mut self, wrap: bool) -> Self {
42        self.wrap = wrap;
43        self
44    }
45
46    #[must_use]
47    pub fn with_exit_key(mut self, key: KeyCode) -> Self {
48        self.exit_key = Some(key);
49        self
50    }
51
52    fn contains(&self, id: FocusId) -> bool {
53        self.members.contains(&id)
54    }
55}
56
57/// Active focus trap (e.g., modal).
58#[derive(Debug, Clone, Copy)]
59pub struct FocusTrap {
60    pub group_id: u32,
61    pub return_focus: Option<FocusId>,
62}
63
64/// Central focus coordinator.
65#[derive(Debug, Default)]
66pub struct FocusManager {
67    graph: FocusGraph,
68    current: Option<FocusId>,
69    history: Vec<FocusId>,
70    trap_stack: Vec<FocusTrap>,
71    groups: HashMap<u32, FocusGroup>,
72    last_event: Option<FocusEvent>,
73}
74
75impl FocusManager {
76    /// Create a new focus manager.
77    #[must_use]
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Access the underlying focus graph.
83    #[must_use]
84    pub fn graph(&self) -> &FocusGraph {
85        &self.graph
86    }
87
88    /// Mutably access the underlying focus graph.
89    pub fn graph_mut(&mut self) -> &mut FocusGraph {
90        &mut self.graph
91    }
92
93    /// Get currently focused widget.
94    #[must_use]
95    pub fn current(&self) -> Option<FocusId> {
96        self.current
97    }
98
99    /// Check if a widget is focused.
100    #[must_use]
101    pub fn is_focused(&self, id: FocusId) -> bool {
102        self.current == Some(id)
103    }
104
105    /// Set focus to widget, returns previous focus.
106    pub fn focus(&mut self, id: FocusId) -> Option<FocusId> {
107        if !self.can_focus(id) || !self.allowed_by_trap(id) {
108            return None;
109        }
110        let prev = self.current;
111        if prev == Some(id) {
112            return prev;
113        }
114        self.set_focus(id);
115        prev
116    }
117
118    /// Remove focus from current widget.
119    pub fn blur(&mut self) -> Option<FocusId> {
120        let prev = self.current.take();
121        if let Some(id) = prev {
122            self.last_event = Some(FocusEvent::FocusLost { id });
123        }
124        prev
125    }
126
127    /// Move focus in direction.
128    pub fn navigate(&mut self, dir: NavDirection) -> bool {
129        match dir {
130            NavDirection::Next => self.focus_next(),
131            NavDirection::Prev => self.focus_prev(),
132            _ => {
133                let Some(current) = self.current else {
134                    return false;
135                };
136                // Explicit edges take precedence; fall back to spatial navigation.
137                let target = self
138                    .graph
139                    .navigate(current, dir)
140                    .or_else(|| spatial::spatial_navigate(&self.graph, current, dir));
141                let Some(target) = target else {
142                    return false;
143                };
144                if !self.allowed_by_trap(target) {
145                    return false;
146                }
147                self.set_focus(target)
148            }
149        }
150    }
151
152    /// Move to next in tab order.
153    pub fn focus_next(&mut self) -> bool {
154        self.move_in_tab_order(true)
155    }
156
157    /// Move to previous in tab order.
158    pub fn focus_prev(&mut self) -> bool {
159        self.move_in_tab_order(false)
160    }
161
162    /// Focus first focusable widget.
163    pub fn focus_first(&mut self) -> bool {
164        let order = self.active_tab_order();
165        let Some(first) = order.first().copied() else {
166            return false;
167        };
168        self.set_focus(first)
169    }
170
171    /// Focus last focusable widget.
172    pub fn focus_last(&mut self) -> bool {
173        let order = self.active_tab_order();
174        let Some(last) = order.last().copied() else {
175            return false;
176        };
177        self.set_focus(last)
178    }
179
180    /// Go back to previous focus.
181    pub fn focus_back(&mut self) -> bool {
182        while let Some(id) = self.history.pop() {
183            if self.can_focus(id) && self.allowed_by_trap(id) {
184                // Set focus directly without pushing current to history
185                // (going back shouldn't create a forward entry).
186                let prev = self.current;
187                self.current = Some(id);
188                self.last_event = Some(match prev {
189                    Some(from) => FocusEvent::FocusMoved { from, to: id },
190                    None => FocusEvent::FocusGained { id },
191                });
192                return true;
193            }
194        }
195        false
196    }
197
198    /// Clear focus history.
199    pub fn clear_history(&mut self) {
200        self.history.clear();
201    }
202
203    /// Push focus trap (for modals).
204    pub fn push_trap(&mut self, group_id: u32) {
205        let return_focus = self.current;
206        self.trap_stack.push(FocusTrap {
207            group_id,
208            return_focus,
209        });
210
211        if !self.is_current_in_group(group_id) {
212            self.focus_first_in_group(group_id);
213        }
214    }
215
216    /// Pop focus trap, restore previous focus.
217    pub fn pop_trap(&mut self) -> bool {
218        let Some(trap) = self.trap_stack.pop() else {
219            return false;
220        };
221
222        if let Some(id) = trap.return_focus
223            && self.can_focus(id)
224            && self.allowed_by_trap(id)
225        {
226            return self.set_focus(id);
227        }
228
229        if let Some(active) = self.active_trap_group() {
230            return self.focus_first_in_group(active);
231        }
232
233        self.focus_first()
234    }
235
236    /// Check if focus is currently trapped.
237    #[must_use]
238    pub fn is_trapped(&self) -> bool {
239        !self.trap_stack.is_empty()
240    }
241
242    /// Create focus group.
243    pub fn create_group(&mut self, id: u32, members: Vec<FocusId>) {
244        let members = self.filter_focusable(members);
245        self.groups.insert(id, FocusGroup::new(id, members));
246    }
247
248    /// Add widget to group.
249    pub fn add_to_group(&mut self, group_id: u32, widget_id: FocusId) {
250        if !self.can_focus(widget_id) {
251            return;
252        }
253        let group = self
254            .groups
255            .entry(group_id)
256            .or_insert_with(|| FocusGroup::new(group_id, Vec::new()));
257        if !group.contains(widget_id) {
258            group.members.push(widget_id);
259        }
260    }
261
262    /// Remove widget from group.
263    pub fn remove_from_group(&mut self, group_id: u32, widget_id: FocusId) {
264        let Some(group) = self.groups.get_mut(&group_id) else {
265            return;
266        };
267        group.members.retain(|id| *id != widget_id);
268    }
269
270    /// Get the last focus event.
271    #[must_use]
272    pub fn focus_event(&self) -> Option<&FocusEvent> {
273        self.last_event.as_ref()
274    }
275
276    /// Take and clear the last focus event.
277    pub fn take_focus_event(&mut self) -> Option<FocusEvent> {
278        self.last_event.take()
279    }
280
281    fn set_focus(&mut self, id: FocusId) -> bool {
282        if !self.can_focus(id) || !self.allowed_by_trap(id) {
283            return false;
284        }
285        if self.current == Some(id) {
286            return false;
287        }
288
289        let prev = self.current;
290        if let Some(prev_id) = prev {
291            if Some(prev_id) != self.history.last().copied() {
292                self.history.push(prev_id);
293            }
294            self.last_event = Some(FocusEvent::FocusMoved {
295                from: prev_id,
296                to: id,
297            });
298        } else {
299            self.last_event = Some(FocusEvent::FocusGained { id });
300        }
301
302        self.current = Some(id);
303        true
304    }
305
306    fn can_focus(&self, id: FocusId) -> bool {
307        self.graph.get(id).map(|n| n.is_focusable).unwrap_or(false)
308    }
309
310    fn active_trap_group(&self) -> Option<u32> {
311        self.trap_stack.last().map(|t| t.group_id)
312    }
313
314    fn allowed_by_trap(&self, id: FocusId) -> bool {
315        let Some(group_id) = self.active_trap_group() else {
316            return true;
317        };
318        self.groups
319            .get(&group_id)
320            .map(|g| g.contains(id))
321            .unwrap_or(false)
322    }
323
324    fn is_current_in_group(&self, group_id: u32) -> bool {
325        let Some(current) = self.current else {
326            return false;
327        };
328        self.groups
329            .get(&group_id)
330            .map(|g| g.contains(current))
331            .unwrap_or(false)
332    }
333
334    fn active_tab_order(&self) -> Vec<FocusId> {
335        if let Some(group_id) = self.active_trap_group() {
336            return self.group_tab_order(group_id);
337        }
338        self.graph.tab_order()
339    }
340
341    fn group_tab_order(&self, group_id: u32) -> Vec<FocusId> {
342        let Some(group) = self.groups.get(&group_id) else {
343            return Vec::new();
344        };
345        let order = self.graph.tab_order();
346        order.into_iter().filter(|id| group.contains(*id)).collect()
347    }
348
349    fn focus_first_in_group(&mut self, group_id: u32) -> bool {
350        let order = self.group_tab_order(group_id);
351        let Some(first) = order.first().copied() else {
352            return false;
353        };
354        self.set_focus(first)
355    }
356
357    fn move_in_tab_order(&mut self, forward: bool) -> bool {
358        let order = self.active_tab_order();
359        if order.is_empty() {
360            return false;
361        }
362
363        let wrap = self
364            .active_trap_group()
365            .and_then(|id| self.groups.get(&id).map(|g| g.wrap))
366            .unwrap_or(true);
367
368        let next = match self.current {
369            None => order[0],
370            Some(current) => {
371                let pos = order.iter().position(|id| *id == current);
372                match pos {
373                    None => order[0],
374                    Some(idx) if forward => {
375                        if idx + 1 < order.len() {
376                            order[idx + 1]
377                        } else if wrap {
378                            order[0]
379                        } else {
380                            return false;
381                        }
382                    }
383                    Some(idx) => {
384                        if idx > 0 {
385                            order[idx - 1]
386                        } else if wrap {
387                            *order.last().unwrap()
388                        } else {
389                            return false;
390                        }
391                    }
392                }
393            }
394        };
395
396        self.set_focus(next)
397    }
398
399    fn filter_focusable(&self, ids: Vec<FocusId>) -> Vec<FocusId> {
400        let mut out = Vec::new();
401        for id in ids {
402            if self.can_focus(id) && !out.contains(&id) {
403                out.push(id);
404            }
405        }
406        out
407    }
408}
409
410// =========================================================================
411// Tests
412// =========================================================================
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::focus::FocusNode;
418    use ftui_core::geometry::Rect;
419
420    fn node(id: FocusId, tab: i32) -> FocusNode {
421        FocusNode::new(id, Rect::new(0, 0, 1, 1)).with_tab_index(tab)
422    }
423
424    #[test]
425    fn focus_basic() {
426        let mut fm = FocusManager::new();
427        fm.graph_mut().insert(node(1, 0));
428        fm.graph_mut().insert(node(2, 1));
429
430        assert!(fm.focus(1).is_none());
431        assert_eq!(fm.current(), Some(1));
432
433        assert_eq!(fm.focus(2), Some(1));
434        assert_eq!(fm.current(), Some(2));
435
436        assert_eq!(fm.blur(), Some(2));
437        assert_eq!(fm.current(), None);
438    }
439
440    #[test]
441    fn focus_history_back() {
442        let mut fm = FocusManager::new();
443        fm.graph_mut().insert(node(1, 0));
444        fm.graph_mut().insert(node(2, 1));
445        fm.graph_mut().insert(node(3, 2));
446
447        fm.focus(1);
448        fm.focus(2);
449        fm.focus(3);
450
451        assert!(fm.focus_back());
452        assert_eq!(fm.current(), Some(2));
453
454        assert!(fm.focus_back());
455        assert_eq!(fm.current(), Some(1));
456    }
457
458    #[test]
459    fn focus_next_prev() {
460        let mut fm = FocusManager::new();
461        fm.graph_mut().insert(node(1, 0));
462        fm.graph_mut().insert(node(2, 1));
463        fm.graph_mut().insert(node(3, 2));
464
465        assert!(fm.focus_next());
466        assert_eq!(fm.current(), Some(1));
467
468        assert!(fm.focus_next());
469        assert_eq!(fm.current(), Some(2));
470
471        assert!(fm.focus_prev());
472        assert_eq!(fm.current(), Some(1));
473    }
474
475    #[test]
476    fn focus_trap_push_pop() {
477        let mut fm = FocusManager::new();
478        fm.graph_mut().insert(node(1, 0));
479        fm.graph_mut().insert(node(2, 1));
480        fm.graph_mut().insert(node(3, 2));
481
482        fm.focus(3);
483        fm.create_group(7, vec![1, 2]);
484
485        fm.push_trap(7);
486        assert!(fm.is_trapped());
487        assert_eq!(fm.current(), Some(1));
488
489        fm.pop_trap();
490        assert!(!fm.is_trapped());
491        assert_eq!(fm.current(), Some(3));
492    }
493
494    #[test]
495    fn focus_group_wrap_respected() {
496        let mut fm = FocusManager::new();
497        fm.graph_mut().insert(node(1, 0));
498        fm.graph_mut().insert(node(2, 1));
499        fm.create_group(9, vec![1, 2]);
500        fm.groups.get_mut(&9).unwrap().wrap = false;
501
502        fm.push_trap(9);
503        fm.focus(2);
504        assert!(!fm.focus_next());
505        assert_eq!(fm.current(), Some(2));
506    }
507
508    #[test]
509    fn focus_event_generation() {
510        let mut fm = FocusManager::new();
511        fm.graph_mut().insert(node(1, 0));
512        fm.graph_mut().insert(node(2, 1));
513
514        fm.focus(1);
515        assert_eq!(
516            fm.take_focus_event(),
517            Some(FocusEvent::FocusGained { id: 1 })
518        );
519
520        fm.focus(2);
521        assert_eq!(
522            fm.take_focus_event(),
523            Some(FocusEvent::FocusMoved { from: 1, to: 2 })
524        );
525
526        fm.blur();
527        assert_eq!(fm.take_focus_event(), Some(FocusEvent::FocusLost { id: 2 }));
528    }
529
530    #[test]
531    fn trap_prevents_focus_outside_group() {
532        let mut fm = FocusManager::new();
533        fm.graph_mut().insert(node(1, 0));
534        fm.graph_mut().insert(node(2, 1));
535        fm.graph_mut().insert(node(3, 2));
536        fm.create_group(5, vec![1, 2]);
537
538        fm.push_trap(5);
539        assert_eq!(fm.current(), Some(1));
540
541        // Attempt to focus outside trap should fail.
542        assert!(fm.focus(3).is_none());
543        assert_ne!(fm.current(), Some(3));
544    }
545
546    // --- Spatial navigation integration ---
547
548    fn spatial_node(id: FocusId, x: u16, y: u16, w: u16, h: u16, tab: i32) -> FocusNode {
549        FocusNode::new(id, Rect::new(x, y, w, h)).with_tab_index(tab)
550    }
551
552    #[test]
553    fn navigate_spatial_fallback() {
554        let mut fm = FocusManager::new();
555        // Two nodes side by side — no explicit edges.
556        fm.graph_mut().insert(spatial_node(1, 0, 0, 10, 3, 0));
557        fm.graph_mut().insert(spatial_node(2, 20, 0, 10, 3, 1));
558
559        fm.focus(1);
560        assert!(fm.navigate(NavDirection::Right));
561        assert_eq!(fm.current(), Some(2));
562
563        assert!(fm.navigate(NavDirection::Left));
564        assert_eq!(fm.current(), Some(1));
565    }
566
567    #[test]
568    fn navigate_explicit_edge_overrides_spatial() {
569        let mut fm = FocusManager::new();
570        fm.graph_mut().insert(spatial_node(1, 0, 0, 10, 3, 0));
571        fm.graph_mut().insert(spatial_node(2, 20, 0, 10, 3, 1)); // spatially right
572        fm.graph_mut().insert(spatial_node(3, 40, 0, 10, 3, 2)); // further right
573
574        // Explicit edge overrides spatial: Right from 1 goes to 3, not 2.
575        fm.graph_mut().connect(1, NavDirection::Right, 3);
576
577        fm.focus(1);
578        assert!(fm.navigate(NavDirection::Right));
579        assert_eq!(fm.current(), Some(3));
580    }
581
582    #[test]
583    fn navigate_spatial_respects_trap() {
584        let mut fm = FocusManager::new();
585        fm.graph_mut().insert(spatial_node(1, 0, 0, 10, 3, 0));
586        fm.graph_mut().insert(spatial_node(2, 20, 0, 10, 3, 1));
587        fm.graph_mut().insert(spatial_node(3, 40, 0, 10, 3, 2));
588
589        // Trap to group containing only 1 and 2.
590        fm.create_group(1, vec![1, 2]);
591        fm.focus(2);
592        fm.push_trap(1);
593
594        // Spatial would find 3 to the right of 2, but trap blocks it.
595        assert!(!fm.navigate(NavDirection::Right));
596        assert_eq!(fm.current(), Some(2));
597    }
598
599    #[test]
600    fn navigate_spatial_grid_round_trip() {
601        let mut fm = FocusManager::new();
602        // 2x2 grid.
603        fm.graph_mut().insert(spatial_node(1, 0, 0, 10, 3, 0));
604        fm.graph_mut().insert(spatial_node(2, 20, 0, 10, 3, 1));
605        fm.graph_mut().insert(spatial_node(3, 0, 6, 10, 3, 2));
606        fm.graph_mut().insert(spatial_node(4, 20, 6, 10, 3, 3));
607
608        fm.focus(1);
609
610        // Navigate around the grid: right, down, left, up — back to start.
611        assert!(fm.navigate(NavDirection::Right));
612        assert_eq!(fm.current(), Some(2));
613
614        assert!(fm.navigate(NavDirection::Down));
615        assert_eq!(fm.current(), Some(4));
616
617        assert!(fm.navigate(NavDirection::Left));
618        assert_eq!(fm.current(), Some(3));
619
620        assert!(fm.navigate(NavDirection::Up));
621        assert_eq!(fm.current(), Some(1));
622    }
623
624    #[test]
625    fn navigate_spatial_no_candidate() {
626        let mut fm = FocusManager::new();
627        fm.graph_mut().insert(spatial_node(1, 0, 0, 10, 3, 0));
628        fm.focus(1);
629
630        // No other nodes, spatial should return false.
631        assert!(!fm.navigate(NavDirection::Right));
632        assert!(!fm.navigate(NavDirection::Up));
633        assert_eq!(fm.current(), Some(1));
634    }
635}