Skip to main content

jag_ui/
focus.rs

1//! Generic focus manager using opaque `FocusId` identifiers.
2//!
3//! Provides centralized focus tracking and Tab / Shift-Tab navigation
4//! with tabindex ordering.  Elements with tabindex -1 are registered
5//! but excluded from keyboard navigation.
6
7use std::collections::HashSet;
8
9/// Opaque identifier for a focusable element.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub struct FocusId(pub u64);
12
13/// Direction for keyboard-based focus navigation.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum FocusDirection {
16    /// Tab — move to next focusable element.
17    Forward,
18    /// Shift-Tab — move to previous focusable element.
19    Backward,
20}
21
22/// Outcome of a [`FocusManager::navigate`] call.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum FocusResult {
25    /// Focus moved to a new element.
26    Moved(FocusId),
27    /// Focus wrapped around to the beginning / end.
28    Wrapped(FocusId),
29    /// No focusable elements available.
30    NoFocusableElements,
31    /// Only one focusable element and it is already focused.
32    Unchanged,
33}
34
35/// Centralized focus manager.
36///
37/// Call [`register`](Self::register) for each focusable element, then
38/// [`rebuild_order`](Self::rebuild_order) to sort by tabindex before
39/// navigating.
40#[derive(Debug)]
41pub struct FocusManager {
42    /// Currently focused element, if any.
43    current: Option<FocusId>,
44    /// Whether the focus ring should be drawn (keyboard navigation mode).
45    focus_visible: bool,
46    /// Registered elements with their tabindex, in insertion order.
47    registered: Vec<(FocusId, i32)>,
48    /// Sorted navigable order (excludes tabindex -1).
49    order: Vec<FocusId>,
50    /// Set of registered ids for quick lookup.
51    id_set: HashSet<FocusId>,
52}
53
54impl Default for FocusManager {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl FocusManager {
61    /// Create an empty focus manager with no focused element.
62    pub fn new() -> Self {
63        Self {
64            current: None,
65            focus_visible: false,
66            registered: Vec::new(),
67            order: Vec::new(),
68            id_set: HashSet::new(),
69        }
70    }
71
72    /// The currently focused element, if any.
73    pub fn current(&self) -> Option<FocusId> {
74        self.current
75    }
76
77    /// Whether the focus ring should be visible (set by keyboard navigation).
78    pub fn is_focus_visible(&self) -> bool {
79        self.focus_visible
80    }
81
82    /// Explicitly set focus-ring visibility.
83    pub fn set_focus_visible(&mut self, visible: bool) {
84        self.focus_visible = visible;
85    }
86
87    /// Programmatically focus a specific element.
88    pub fn set_focus(&mut self, id: FocusId) {
89        self.current = Some(id);
90    }
91
92    /// Clear focus from all elements.
93    pub fn clear_focus(&mut self) {
94        self.current = None;
95    }
96
97    /// Register a focusable element with a tabindex.
98    ///
99    /// * tabindex >= 0: participates in keyboard navigation.
100    /// * tabindex  < 0: focusable via `set_focus` only, skipped by `navigate`.
101    ///
102    /// If the element is already registered, its tabindex is updated.
103    pub fn register(&mut self, id: FocusId, tabindex: i32) {
104        if self.id_set.contains(&id) {
105            // Update tabindex for existing entry.
106            if let Some(entry) = self.registered.iter_mut().find(|(eid, _)| *eid == id) {
107                entry.1 = tabindex;
108            }
109        } else {
110            self.registered.push((id, tabindex));
111            self.id_set.insert(id);
112        }
113    }
114
115    /// Remove a focusable element.  If it was focused, focus is cleared.
116    pub fn unregister(&mut self, id: FocusId) {
117        self.registered.retain(|(eid, _)| *eid != id);
118        self.id_set.remove(&id);
119        self.order.retain(|eid| *eid != id);
120        if self.current == Some(id) {
121            self.current = None;
122        }
123    }
124
125    /// Rebuild the internal navigation order from registered elements.
126    ///
127    /// Must be called after registering or unregistering elements, and
128    /// before calling [`navigate`](Self::navigate).
129    ///
130    /// Ordering rules:
131    /// 1. Elements with tabindex < 0 are excluded.
132    /// 2. Elements with tabindex > 0 come first, sorted ascending.
133    /// 3. Elements with tabindex == 0 follow, in registration (document) order.
134    pub fn rebuild_order(&mut self) {
135        // Collect navigable entries preserving registration order index.
136        let mut navigable: Vec<(usize, FocusId, i32)> = self
137            .registered
138            .iter()
139            .enumerate()
140            .filter(|(_, (_, ti))| *ti >= 0)
141            .map(|(idx, (id, ti))| (idx, *id, *ti))
142            .collect();
143
144        // Stable sort: positive tabindex first (ascending), then tabindex-0
145        // in document (registration) order.
146        navigable.sort_by(|a, b| {
147            match (a.2, b.2) {
148                (ta, tb) if ta > 0 && tb > 0 => ta.cmp(&tb),
149                (ta, _) if ta > 0 => std::cmp::Ordering::Less,
150                (_, tb) if tb > 0 => std::cmp::Ordering::Greater,
151                // Both tabindex 0 — preserve registration order.
152                _ => a.0.cmp(&b.0),
153            }
154        });
155
156        self.order = navigable.into_iter().map(|(_, id, _)| id).collect();
157    }
158
159    /// Navigate focus forward or backward.
160    ///
161    /// Automatically enables `focus_visible`.
162    pub fn navigate(&mut self, direction: FocusDirection) -> FocusResult {
163        if self.order.is_empty() {
164            return FocusResult::NoFocusableElements;
165        }
166
167        self.focus_visible = true;
168
169        let len = self.order.len();
170        let current_pos = self
171            .current
172            .and_then(|id| self.order.iter().position(|eid| *eid == id));
173
174        let (new_pos, wrapped) = match (current_pos, direction) {
175            (None, FocusDirection::Forward) => (0, false),
176            (None, FocusDirection::Backward) => (len - 1, false),
177            (Some(pos), FocusDirection::Forward) => {
178                if pos + 1 >= len {
179                    (0, true)
180                } else {
181                    (pos + 1, false)
182                }
183            }
184            (Some(pos), FocusDirection::Backward) => {
185                if pos == 0 {
186                    (len - 1, true)
187                } else {
188                    (pos - 1, false)
189                }
190            }
191        };
192
193        let new_id = self.order[new_pos];
194
195        // Single element that is already focused.
196        if Some(new_id) == self.current && len == 1 {
197            return FocusResult::Unchanged;
198        }
199
200        self.current = Some(new_id);
201
202        if wrapped {
203            FocusResult::Wrapped(new_id)
204        } else {
205            FocusResult::Moved(new_id)
206        }
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Tests
212// ---------------------------------------------------------------------------
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn new_manager_has_no_focus() {
220        let fm = FocusManager::new();
221        assert!(fm.current().is_none());
222        assert!(!fm.is_focus_visible());
223    }
224
225    #[test]
226    fn register_and_navigate_forward() {
227        let mut fm = FocusManager::new();
228        fm.register(FocusId(1), 0);
229        fm.register(FocusId(2), 0);
230        fm.register(FocusId(3), 0);
231        fm.rebuild_order();
232
233        let r1 = fm.navigate(FocusDirection::Forward);
234        assert_eq!(r1, FocusResult::Moved(FocusId(1)));
235        assert_eq!(fm.current(), Some(FocusId(1)));
236
237        let r2 = fm.navigate(FocusDirection::Forward);
238        assert_eq!(r2, FocusResult::Moved(FocusId(2)));
239
240        let r3 = fm.navigate(FocusDirection::Forward);
241        assert_eq!(r3, FocusResult::Moved(FocusId(3)));
242    }
243
244    #[test]
245    fn navigate_backward_wraps() {
246        let mut fm = FocusManager::new();
247        fm.register(FocusId(1), 0);
248        fm.register(FocusId(2), 0);
249        fm.rebuild_order();
250
251        // Focus the first element.
252        fm.set_focus(FocusId(1));
253
254        let r = fm.navigate(FocusDirection::Backward);
255        assert_eq!(r, FocusResult::Wrapped(FocusId(2)));
256        assert_eq!(fm.current(), Some(FocusId(2)));
257    }
258
259    #[test]
260    fn tabindex_negative_skipped() {
261        let mut fm = FocusManager::new();
262        fm.register(FocusId(1), 0);
263        fm.register(FocusId(2), -1); // should be skipped
264        fm.register(FocusId(3), 0);
265        fm.rebuild_order();
266
267        let r1 = fm.navigate(FocusDirection::Forward);
268        assert_eq!(r1, FocusResult::Moved(FocusId(1)));
269
270        let r2 = fm.navigate(FocusDirection::Forward);
271        assert_eq!(r2, FocusResult::Moved(FocusId(3)));
272
273        // Wrap back to first.
274        let r3 = fm.navigate(FocusDirection::Forward);
275        assert_eq!(r3, FocusResult::Wrapped(FocusId(1)));
276    }
277
278    #[test]
279    fn set_and_clear_focus() {
280        let mut fm = FocusManager::new();
281        fm.register(FocusId(7), 0);
282        fm.rebuild_order();
283
284        fm.set_focus(FocusId(7));
285        assert_eq!(fm.current(), Some(FocusId(7)));
286
287        fm.clear_focus();
288        assert!(fm.current().is_none());
289    }
290
291    #[test]
292    fn unregister_removes_from_order() {
293        let mut fm = FocusManager::new();
294        fm.register(FocusId(1), 0);
295        fm.register(FocusId(2), 0);
296        fm.register(FocusId(3), 0);
297        fm.rebuild_order();
298
299        fm.set_focus(FocusId(2));
300        fm.unregister(FocusId(2));
301
302        // Focus should be cleared.
303        assert!(fm.current().is_none());
304
305        // Navigation should skip the removed element.
306        let r1 = fm.navigate(FocusDirection::Forward);
307        assert_eq!(r1, FocusResult::Moved(FocusId(1)));
308
309        let r2 = fm.navigate(FocusDirection::Forward);
310        assert_eq!(r2, FocusResult::Moved(FocusId(3)));
311    }
312
313    #[test]
314    fn navigate_empty_returns_no_focusable() {
315        let mut fm = FocusManager::new();
316        assert_eq!(
317            fm.navigate(FocusDirection::Forward),
318            FocusResult::NoFocusableElements,
319        );
320    }
321
322    #[test]
323    fn single_element_unchanged() {
324        let mut fm = FocusManager::new();
325        fm.register(FocusId(1), 0);
326        fm.rebuild_order();
327
328        fm.set_focus(FocusId(1));
329        let r = fm.navigate(FocusDirection::Forward);
330        assert_eq!(r, FocusResult::Unchanged);
331    }
332
333    #[test]
334    fn positive_tabindex_ordered_first() {
335        let mut fm = FocusManager::new();
336        fm.register(FocusId(10), 0);
337        fm.register(FocusId(20), 2);
338        fm.register(FocusId(30), 1);
339        fm.rebuild_order();
340
341        // Positive tabindex elements first (sorted ascending), then tabindex-0.
342        let r1 = fm.navigate(FocusDirection::Forward);
343        assert_eq!(r1, FocusResult::Moved(FocusId(30))); // tabindex 1
344
345        let r2 = fm.navigate(FocusDirection::Forward);
346        assert_eq!(r2, FocusResult::Moved(FocusId(20))); // tabindex 2
347
348        let r3 = fm.navigate(FocusDirection::Forward);
349        assert_eq!(r3, FocusResult::Moved(FocusId(10))); // tabindex 0
350    }
351}