Skip to main content

oxiui_core/
focus.rs

1//! Keyboard focus management over a [`WidgetTree`].
2//!
3//! [`FocusManager`] tracks which node currently holds focus and moves focus in
4//! tab order (the DFS order returned by [`WidgetTree::focus_order`]). It
5//! supports:
6//!
7//! - **Tab / Shift-Tab cycling** with wrap-around.
8//! - **Programmatic** `focus(id)` / `blur()`.
9//! - **Focus traps** — while a trap is active, focus is confined to the subtree
10//!   rooted at the trap node (used for modal dialogs/popups so Tab cannot escape
11//!   behind the modal).
12//! - **Autofocus** — focus the first node flagged for autofocus on activation.
13//!
14//! The manager stores only a [`WidgetId`]; it borrows the tree per call, so the
15//! caller is free to rebuild the tree between focus operations. After structural
16//! changes call [`FocusManager::reconcile`] to drop focus if the focused node
17//! disappeared.
18
19use crate::tree::{WidgetId, WidgetTree};
20
21/// Tracks and moves keyboard focus within a [`WidgetTree`].
22#[derive(Debug, Default, Clone)]
23pub struct FocusManager {
24    focused: Option<WidgetId>,
25    /// When set, focus is confined to the subtree rooted here.
26    trap: Option<WidgetId>,
27}
28
29impl FocusManager {
30    /// Create a manager with nothing focused and no active trap.
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// The currently focused node, if any.
36    pub fn focused(&self) -> Option<WidgetId> {
37        self.focused
38    }
39
40    /// The active focus-trap root, if any.
41    pub fn trap(&self) -> Option<WidgetId> {
42        self.trap
43    }
44
45    /// The ordered list of focusable nodes that focus may currently move
46    /// between, honouring any active trap (only the trap's focusable
47    /// descendants — and the trap node itself if focusable — are included).
48    pub fn focusable_set(&self, tree: &WidgetTree) -> Vec<WidgetId> {
49        let order = tree.focus_order();
50        match self.trap {
51            None => order,
52            Some(trap) => order
53                .into_iter()
54                .filter(|&id| id == trap || tree.is_descendant(id, trap))
55                .collect(),
56        }
57    }
58
59    /// Focus `id` if it is currently focusable (and inside the trap, if any).
60    /// Returns `true` on success.
61    pub fn focus(&mut self, tree: &WidgetTree, id: WidgetId) -> bool {
62        if self.focusable_set(tree).contains(&id) {
63            self.focused = Some(id);
64            true
65        } else {
66            false
67        }
68    }
69
70    /// Clear focus.
71    pub fn blur(&mut self) {
72        self.focused = None;
73    }
74
75    /// Activate a focus trap rooted at `trap`. If the current focus falls
76    /// outside the trap, focus moves to the first focusable node within it.
77    /// Returns the node focused after activation, if any.
78    pub fn push_trap(&mut self, tree: &WidgetTree, trap: WidgetId) -> Option<WidgetId> {
79        self.trap = Some(trap);
80        let inside = self.focusable_set(tree);
81        let still_valid = self.focused.map(|f| inside.contains(&f)).unwrap_or(false);
82        if !still_valid {
83            self.focused = inside.first().copied();
84        }
85        self.focused
86    }
87
88    /// Release the active focus trap. Focus is left where it is.
89    pub fn pop_trap(&mut self) {
90        self.trap = None;
91    }
92
93    /// Move focus to the next focusable node in tab order (wraps around).
94    /// Returns the newly focused node, or `None` if nothing is focusable.
95    pub fn focus_next(&mut self, tree: &WidgetTree) -> Option<WidgetId> {
96        self.step(tree, true)
97    }
98
99    /// Move focus to the previous focusable node in tab order (wraps around).
100    pub fn focus_prev(&mut self, tree: &WidgetTree) -> Option<WidgetId> {
101        self.step(tree, false)
102    }
103
104    fn step(&mut self, tree: &WidgetTree, forward: bool) -> Option<WidgetId> {
105        let order = self.focusable_set(tree);
106        if order.is_empty() {
107            self.focused = None;
108            return None;
109        }
110        let next = match self
111            .focused
112            .and_then(|f| order.iter().position(|&id| id == f))
113        {
114            Some(idx) => {
115                let n = order.len();
116                if forward {
117                    (idx + 1) % n
118                } else {
119                    (idx + n - 1) % n
120                }
121            }
122            // Nothing currently focused (or focus not in set): land on the first
123            // node going forward, the last going backward.
124            None => {
125                if forward {
126                    0
127                } else {
128                    order.len() - 1
129                }
130            }
131        };
132        self.focused = order.get(next).copied();
133        self.focused
134    }
135
136    /// Focus the first node in tab order whose `autofocus` flag (as supplied by
137    /// `is_autofocus`) is `true`. Returns the focused node, if one matched.
138    ///
139    /// The tree node has no dedicated `autofocus` field, so the predicate lets
140    /// callers decide (e.g. by inspecting a node's `label` or an external map).
141    pub fn autofocus(
142        &mut self,
143        tree: &WidgetTree,
144        is_autofocus: impl Fn(WidgetId) -> bool,
145    ) -> Option<WidgetId> {
146        let target = self
147            .focusable_set(tree)
148            .into_iter()
149            .find(|&id| is_autofocus(id));
150        if let Some(id) = target {
151            self.focused = Some(id);
152        }
153        self.focused
154    }
155
156    /// Drop focus if the focused node is no longer present or no longer
157    /// focusable (call after removing/reparenting nodes). Returns `true` if
158    /// focus was cleared as a result.
159    pub fn reconcile(&mut self, tree: &WidgetTree) -> bool {
160        if let Some(f) = self.focused {
161            if !self.focusable_set(tree).contains(&f) {
162                self.focused = None;
163                return true;
164            }
165        }
166        false
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::geometry::Rect;
174
175    /// root → {a, b, modal → {m1, m2}}; everything focusable except root.
176    fn focus_tree() -> (WidgetTree, [WidgetId; 5]) {
177        let mut t = WidgetTree::new(Rect::ZERO);
178        let a = t.insert(WidgetId::ROOT, Rect::ZERO).expect("root");
179        let b = t.insert(WidgetId::ROOT, Rect::ZERO).expect("root");
180        let modal = t.insert(WidgetId::ROOT, Rect::ZERO).expect("root");
181        let m1 = t.insert(modal, Rect::ZERO).expect("modal");
182        let m2 = t.insert(modal, Rect::ZERO).expect("modal");
183        for id in [a, b, m1, m2] {
184            if let Some(n) = t.get_mut(id) {
185                n.focusable = true;
186            }
187        }
188        // modal container itself is not focusable (only its contents are).
189        (t, [a, b, modal, m1, m2])
190    }
191
192    #[test]
193    fn tab_cycles_with_wraparound() {
194        let (tree, [a, b, _modal, m1, m2]) = focus_tree();
195        let mut fm = FocusManager::new();
196        // Forward from nothing -> first (a).
197        assert_eq!(fm.focus_next(&tree), Some(a));
198        assert_eq!(fm.focus_next(&tree), Some(b));
199        assert_eq!(fm.focus_next(&tree), Some(m1));
200        assert_eq!(fm.focus_next(&tree), Some(m2));
201        // Wrap back to a.
202        assert_eq!(fm.focus_next(&tree), Some(a));
203    }
204
205    #[test]
206    fn shift_tab_goes_backward_and_wraps() {
207        let (tree, [a, _b, _modal, _m1, m2]) = focus_tree();
208        let mut fm = FocusManager::new();
209        // Backward from nothing -> last (m2).
210        assert_eq!(fm.focus_prev(&tree), Some(m2));
211        fm.focus(&tree, a);
212        // Backward from a wraps to m2.
213        assert_eq!(fm.focus_prev(&tree), Some(m2));
214    }
215
216    #[test]
217    fn focus_trap_confines_tabbing() {
218        let (tree, [a, _b, modal, m1, m2]) = focus_tree();
219        let mut fm = FocusManager::new();
220        fm.focus(&tree, a);
221        // Activate the modal trap. Focus moves into the modal (a is outside).
222        let landed = fm.push_trap(&tree, modal);
223        assert_eq!(landed, Some(m1));
224        // Tabbing now only cycles m1 <-> m2.
225        assert_eq!(fm.focus_next(&tree), Some(m2));
226        assert_eq!(fm.focus_next(&tree), Some(m1)); // wraps within trap
227                                                    // Cannot focus an outside node while trapped.
228        assert!(!fm.focus(&tree, a));
229        // Release the trap; outside nodes are reachable again.
230        fm.pop_trap();
231        assert!(fm.focus(&tree, a));
232    }
233
234    #[test]
235    fn autofocus_picks_first_match() {
236        let (tree, [_a, b, _modal, _m1, _m2]) = focus_tree();
237        let mut fm = FocusManager::new();
238        let focused = fm.autofocus(&tree, |id| id == b);
239        assert_eq!(focused, Some(b));
240    }
241
242    #[test]
243    fn reconcile_drops_removed_focus() {
244        let (mut tree, [a, _b, _modal, _m1, _m2]) = focus_tree();
245        let mut fm = FocusManager::new();
246        fm.focus(&tree, a);
247        assert_eq!(fm.focused(), Some(a));
248        tree.remove(a);
249        assert!(fm.reconcile(&tree));
250        assert_eq!(fm.focused(), None);
251    }
252
253    #[test]
254    fn blur_clears_focus() {
255        let (tree, [a, ..]) = focus_tree();
256        let mut fm = FocusManager::new();
257        fm.focus(&tree, a);
258        fm.blur();
259        assert_eq!(fm.focused(), None);
260    }
261}