Skip to main content

a2ui_base/
focus.rs

1//! Keyboard focus management — framework-agnostic.
2//!
3//! [`FocusManager`] maintains an ordered list of focusable component IDs
4//! (collected via depth-first traversal of the component tree) and provides
5//! Tab / Shift-Tab cycling. It depends only on core types, so every backend
6//! (ratatui, Slint, …) shares a single implementation.
7//!
8//! Backends with native focus (e.g. Slint) may still use this to reproduce the
9//! ratatui Tab order in tests, or to drive focus when native focus is disabled.
10
11use crate::model::component_model::ComponentModel;
12use crate::model::components_model::SurfaceComponentsModel;
13use crate::protocol::common_types::ChildList;
14
15/// Component types that can receive keyboard focus.
16pub const FOCUSABLE_TYPES: &[&str] = &[
17    "Button",
18    "TextField",
19    "CheckBox",
20    "Slider",
21    "ChoicePicker",
22    "DateTimeInput",
23    // Interactive only under the tui `audio` feature, but listing it always is
24    // harmless: without the feature its handle_event is the trait default
25    // (no-op), so focusing it simply does nothing.
26    "AudioPlayer",
27];
28
29/// Manages keyboard focus across interactive components.
30pub struct FocusManager {
31    /// Ordered list of focusable component IDs (depth-first traversal order).
32    pub focusable_ids: Vec<String>,
33    /// Current focus index into `focusable_ids`.
34    current_index: usize,
35}
36
37impl Default for FocusManager {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl FocusManager {
44    /// Create an empty focus manager.
45    pub fn new() -> Self {
46        Self {
47            focusable_ids: Vec::new(),
48            current_index: 0,
49        }
50    }
51
52    /// Rebuild the focus list by traversing the component tree depth-first.
53    ///
54    /// Call this whenever the component tree changes (e.g. surface update).
55    /// Components that are present in `focusable_ids` retain their focus if
56    /// the focused ID still exists after the rebuild.
57    pub fn rebuild_from_components(&mut self, components: &SurfaceComponentsModel) {
58        let previously_focused = self.focused_id().map(|s| s.to_string());
59
60        self.focusable_ids.clear();
61        self.current_index = 0;
62
63        // Find root components (those not referenced as any other component's child).
64        let child_ids = collect_all_child_ids(components);
65        let all = components.all();
66
67        let mut roots: Vec<&ComponentModel> = all
68            .values()
69            .filter(|c| !child_ids.contains(c.id.as_str()))
70            .collect();
71        // Sort roots by ID for deterministic ordering.
72        roots.sort_by(|a, b| a.id.cmp(&b.id));
73
74        for root in &roots {
75            self.collect_focusable_depth_first(root, components);
76        }
77
78        // Restore focus if the previously-focused ID still exists.
79        if let Some(ref prev_id) = previously_focused {
80            if let Some(idx) = self.focusable_ids.iter().position(|id| id == prev_id) {
81                self.current_index = idx;
82            }
83        }
84    }
85
86    /// Move focus to the next focusable component (Tab).
87    pub fn focus_next(&mut self) {
88        if self.focusable_ids.is_empty() {
89            return;
90        }
91        self.current_index = (self.current_index + 1) % self.focusable_ids.len();
92    }
93
94    /// Move focus to the previous focusable component (Shift+Tab).
95    pub fn focus_prev(&mut self) {
96        if self.focusable_ids.is_empty() {
97            return;
98        }
99        if self.current_index == 0 {
100            self.current_index = self.focusable_ids.len() - 1;
101        } else {
102            self.current_index -= 1;
103        }
104    }
105
106    /// Returns `true` if the component with the given ID currently has focus.
107    pub fn is_focused(&self, id: &str) -> bool {
108        self.focusable_ids
109            .get(self.current_index)
110            .is_some_and(|focused| focused == id)
111    }
112
113    /// Returns the ID of the currently focused component, if any.
114    pub fn focused_id(&self) -> Option<&str> {
115        self.focusable_ids.get(self.current_index).map(|s| s.as_str())
116    }
117
118    /// Clear all focus state.
119    pub fn reset(&mut self) {
120        self.focusable_ids.clear();
121        self.current_index = 0;
122    }
123
124    // -----------------------------------------------------------------------
125    // Internal
126    // -----------------------------------------------------------------------
127
128    /// Recursively collect focusable component IDs in depth-first order.
129    fn collect_focusable_depth_first(
130        &mut self,
131        component: &ComponentModel,
132        components: &SurfaceComponentsModel,
133    ) {
134        if FOCUSABLE_TYPES.contains(&component.component_type.as_str()) {
135            self.focusable_ids.push(component.id.clone());
136        }
137
138        // Visit children.
139        if let Some(child_ids) = component.children() {
140            match child_ids {
141                ChildList::Static(ids) => {
142                    for cid in &ids {
143                        if let Some(child) = components.get(cid) {
144                            self.collect_focusable_depth_first(child, components);
145                        }
146                    }
147                }
148                ChildList::Template { .. } => {
149                    // Template children are resolved at render time;
150                    // focus management for dynamic children is not supported
151                    // in this initial implementation.
152                }
153            }
154        }
155
156        // Single child (used by wrapper components like ScrollView).
157        if let Some(single_id) = component.child() {
158            if let Some(child) = components.get(&single_id) {
159                self.collect_focusable_depth_first(child, components);
160            }
161        }
162    }
163}
164
165/// Collect every component ID that appears as a child of another component.
166fn collect_all_child_ids(components: &SurfaceComponentsModel) -> std::collections::HashSet<String> {
167    let mut ids = std::collections::HashSet::new();
168    for component in components.all().values() {
169        if let Some(ChildList::Static(children)) = component.children() {
170            for cid in children {
171                ids.insert(cid.clone());
172            }
173        }
174        if let Some(single_id) = component.child() {
175            ids.insert(single_id);
176        }
177    }
178    ids
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use serde_json::json;
185
186    fn make_component(id: &str, component_type: &str) -> ComponentModel {
187        ComponentModel::from_json(&json!({
188            "id": id,
189            "component": component_type,
190        }))
191        .unwrap()
192    }
193
194    fn make_container(id: &str, component_type: &str, child_ids: &[&str]) -> ComponentModel {
195        ComponentModel::from_json(&json!({
196            "id": id,
197            "component": component_type,
198            "children": child_ids,
199        }))
200        .unwrap()
201    }
202
203    #[test]
204    fn collects_focusable_in_dfs_order() {
205        let mut surface = SurfaceComponentsModel::new();
206        // Tree: Column -> [Button1, Row -> [TextField, Button2]]
207        surface.upsert(make_container("col", "Column", &["btn1", "row"]));
208        surface.upsert(make_component("btn1", "Button"));
209        surface.upsert(make_container("row", "Row", &["tf1", "btn2"]));
210        surface.upsert(make_component("tf1", "TextField"));
211        surface.upsert(make_component("btn2", "Button"));
212
213        let mut fm = FocusManager::new();
214        fm.rebuild_from_components(&surface);
215
216        assert_eq!(fm.focusable_ids, vec!["btn1", "tf1", "btn2"]);
217    }
218
219    #[test]
220    fn focus_cycles_forward() {
221        let mut fm = FocusManager::new();
222        fm.focusable_ids = vec!["a".into(), "b".into(), "c".into()];
223
224        assert_eq!(fm.focused_id(), Some("a"));
225        fm.focus_next();
226        assert_eq!(fm.focused_id(), Some("b"));
227        fm.focus_next();
228        assert_eq!(fm.focused_id(), Some("c"));
229        fm.focus_next();
230        assert_eq!(fm.focused_id(), Some("a")); // wraps
231    }
232
233    #[test]
234    fn focus_cycles_backward() {
235        let mut fm = FocusManager::new();
236        fm.focusable_ids = vec!["a".into(), "b".into(), "c".into()];
237
238        fm.focus_prev();
239        assert_eq!(fm.focused_id(), Some("c")); // wraps
240        fm.focus_prev();
241        assert_eq!(fm.focused_id(), Some("b"));
242    }
243
244    #[test]
245    fn is_focused_checks_current() {
246        let mut fm = FocusManager::new();
247        fm.focusable_ids = vec!["a".into(), "b".into()];
248
249        assert!(fm.is_focused("a"));
250        assert!(!fm.is_focused("b"));
251        fm.focus_next();
252        assert!(!fm.is_focused("a"));
253        assert!(fm.is_focused("b"));
254    }
255
256    #[test]
257    fn reset_clears_everything() {
258        let mut fm = FocusManager::new();
259        fm.focusable_ids = vec!["a".into()];
260        fm.focus_next();
261        fm.reset();
262        assert!(fm.focused_id().is_none());
263        assert!(fm.focusable_ids.is_empty());
264    }
265
266    #[test]
267    fn empty_surface_yields_no_focus() {
268        let surface = SurfaceComponentsModel::new();
269        let mut fm = FocusManager::new();
270        fm.rebuild_from_components(&surface);
271        assert!(fm.focused_id().is_none());
272        fm.focus_next(); // no panic
273        assert!(fm.focused_id().is_none());
274    }
275
276    #[test]
277    fn rebuild_preserves_focus_if_still_present() {
278        let mut surface = SurfaceComponentsModel::new();
279        surface.upsert(make_container("col", "Column", &["btn1", "btn2"]));
280        surface.upsert(make_component("btn1", "Button"));
281        surface.upsert(make_component("btn2", "Button"));
282
283        let mut fm = FocusManager::new();
284        fm.rebuild_from_components(&surface);
285        fm.focus_next(); // focus btn2
286        assert_eq!(fm.focused_id(), Some("btn2"));
287
288        // Rebuild — btn2 should still be focused.
289        fm.rebuild_from_components(&surface);
290        assert_eq!(fm.focused_id(), Some("btn2"));
291    }
292
293    #[test]
294    fn non_focusable_types_are_skipped() {
295        let mut surface = SurfaceComponentsModel::new();
296        surface.upsert(make_component("txt", "Text"));
297        surface.upsert(make_component("btn", "Button"));
298
299        let mut fm = FocusManager::new();
300        fm.rebuild_from_components(&surface);
301        assert_eq!(fm.focusable_ids, vec!["btn"]);
302    }
303}