Skip to main content

cranpose_ui/
focus_navigation.rs

1use cranpose_core::{NodeId, collections::map::HashMap};
2use cranpose_foundation::SemanticsWidgetRole;
3
4use crate::{KeyCode, LayoutTree, focus_dispatch, focus_order::FocusEntry};
5
6/// A keyboard navigation destination and whether moving there selects it.
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub struct KeyboardFocusTarget {
9    /// The enabled, visible focus target to receive focus.
10    pub node_id: NodeId,
11    /// Whether navigation should activate this target, as radio navigation does.
12    pub activate: bool,
13}
14
15struct Target {
16    entry: FocusEntry,
17    group: Option<NodeId>,
18    role: Option<SemanticsWidgetRole>,
19    selected: bool,
20}
21
22fn targets(tree: &LayoutTree) -> Vec<Target> {
23    let mut output = Vec::new();
24    collect_targets(
25        crate::focus_order::focus_root(tree.root()),
26        None,
27        &mut output,
28    );
29    output
30}
31
32fn collect_targets(node: &crate::LayoutBox, group: Option<NodeId>, output: &mut Vec<Target>) {
33    let config = crate::modifier::collect_semantics_from_modifier(&node.node_data.modifier);
34    if config.as_ref().is_some_and(|config| config.hidden) {
35        return;
36    }
37    if focus_dispatch::has_focus_target(node.node_id)
38        && crate::focus_order::takes_space(node.rect)
39        && config.as_ref().is_none_or(|config| config.enabled)
40    {
41        output.push(Target {
42            entry: FocusEntry {
43                node_id: node.node_id,
44                rect: node.rect,
45            },
46            group,
47            role: config.as_ref().and_then(|config| config.role),
48            selected: config
49                .as_ref()
50                .is_some_and(|config| config.selected == Some(true)),
51        });
52    }
53    let group = if config
54        .as_ref()
55        .is_some_and(crate::focus_order::is_selectable_group)
56    {
57        Some(node.node_id)
58    } else {
59        group
60    };
61    for child in &node.children {
62        collect_targets(child, group, output);
63    }
64}
65
66fn is_composite_member(target: &Target) -> bool {
67    target.group.is_some()
68        && matches!(
69            target.role,
70            Some(
71                SemanticsWidgetRole::RadioButton
72                    | SemanticsWidgetRole::Tab
73                    | SemanticsWidgetRole::MenuItem
74            )
75        )
76}
77
78fn tab_targets(targets: &[Target], current: Option<NodeId>) -> Vec<&Target> {
79    let mut stops: HashMap<NodeId, &Target> = HashMap::default();
80    for target in targets.iter().filter(|target| is_composite_member(target)) {
81        let group = target.group.expect("composite member has a group");
82        let stop = stops.entry(group).or_insert(target);
83        if Some(target.entry.node_id) == current
84            || (Some(stop.entry.node_id) != current && target.selected && !stop.selected)
85        {
86            *stop = target;
87        }
88    }
89    targets
90        .iter()
91        .filter(|target| {
92            !is_composite_member(target)
93                || target
94                    .group
95                    .is_some_and(|group| stops[&group].entry.node_id == target.entry.node_id)
96        })
97        .collect()
98}
99
100/// Resolves Tab, arrows, Home and End using desktop keyboard conventions.
101/// Tab enters a radio group or tab list at its focused or selected member and
102/// leaves it in one step. Arrows wrap within the group; radio navigation also
103/// selects the destination. Tabs use manual activation with Enter or Space.
104/// Hidden, disabled and background targets outside the top modal are excluded.
105/// Publishes this layout's focus order in the current [`crate::AppContext`]
106/// for subsequent programmatic [`crate::FocusManager`] moves.
107/// Unhandled keys return `None` so a widget can apply its own keyboard behavior.
108pub fn keyboard_focus_target(
109    tree: &LayoutTree,
110    current: Option<NodeId>,
111    key: KeyCode,
112    shift: bool,
113) -> Option<KeyboardFocusTarget> {
114    let targets = targets(tree);
115    crate::set_focus_order(targets.iter().map(|target| target.entry).collect());
116    if key == KeyCode::Tab {
117        let stops = tab_targets(&targets, current);
118        let from = stops
119            .iter()
120            .position(|target| Some(target.entry.node_id) == current);
121        let index = stepped_index(stops.len(), from, !shift)?;
122        return Some(KeyboardFocusTarget {
123            node_id: stops[index].entry.node_id,
124            activate: false,
125        });
126    }
127    let current = targets
128        .iter()
129        .find(|target| Some(target.entry.node_id) == current)?;
130    if !is_composite_member(current) {
131        return None;
132    }
133    let members: Vec<_> = targets
134        .iter()
135        .filter(|target| target.group == current.group && target.role == current.role)
136        .collect();
137    let from = members
138        .iter()
139        .position(|target| target.entry.node_id == current.entry.node_id)?;
140    let radio = current.role == Some(SemanticsWidgetRole::RadioButton);
141    let index = group_key_index(&members, from, key, radio)?;
142    Some(KeyboardFocusTarget {
143        node_id: members[index].entry.node_id,
144        activate: radio,
145    })
146}
147
148fn group_key_index(members: &[&Target], from: usize, key: KeyCode, radio: bool) -> Option<usize> {
149    let horizontal = members.get(1).is_some_and(|second| {
150        let first = members[0].entry.rect;
151        (second.entry.rect.x - first.x).abs() >= (second.entry.rect.y - first.y).abs()
152    });
153    Some(match key {
154        KeyCode::Home => 0,
155        KeyCode::End => members.len() - 1,
156        KeyCode::ArrowLeft | KeyCode::ArrowRight if radio || horizontal => {
157            stepped_index(members.len(), Some(from), key == KeyCode::ArrowRight)?
158        }
159        KeyCode::ArrowUp | KeyCode::ArrowDown if radio || !horizontal => {
160            stepped_index(members.len(), Some(from), key == KeyCode::ArrowDown)?
161        }
162        _ => return None,
163    })
164}
165
166fn stepped_index(count: usize, current: Option<usize>, forward: bool) -> Option<usize> {
167    if count == 0 {
168        return None;
169    }
170    Some(match (current, forward) {
171        (Some(index), true) => (index + 1) % count,
172        (Some(index), false) => (index + count - 1) % count,
173        (None, true) => 0,
174        (None, false) => count - 1,
175    })
176}
177
178#[cfg(test)]
179mod tests {
180    use std::rc::Rc;
181
182    use super::*;
183    use crate::{
184        LayoutBox, Modifier, Point, Rect, Size,
185        layout::{LayoutNodeData, LayoutNodeKind},
186    };
187
188    struct Handle;
189
190    impl crate::focus_dispatch::FocusTargetHandle for Handle {
191        fn set_focus_state(&self, _state: cranpose_foundation::FocusState) {}
192    }
193
194    fn node(id: NodeId, modifier: Modifier, children: Vec<LayoutBox>) -> LayoutBox {
195        if children.is_empty() {
196            focus_dispatch::register_focus_target(id, Rc::new(Handle));
197        }
198        LayoutBox::new(
199            id,
200            Rect {
201                x: id as f32 * 50.0,
202                y: 0.0,
203                width: 48.0,
204                height: 48.0,
205            },
206            Point::default(),
207            LayoutNodeData::new(
208                modifier,
209                Default::default(),
210                Rc::default(),
211                LayoutNodeKind::Layout,
212            ),
213            children,
214        )
215    }
216
217    fn tab(id: NodeId, selected: bool, enabled: bool) -> LayoutBox {
218        node(
219            id,
220            Modifier::empty().semantics(move |config| {
221                config.role = Some(SemanticsWidgetRole::Tab);
222                config.selected = Some(selected);
223                config.enabled = enabled;
224            }),
225            Vec::new(),
226        )
227    }
228
229    #[test]
230    fn tabs_keep_selection_when_arrows_wrap_and_leave_in_one_step() {
231        let _scope = crate::render_state::app_context_test_scope();
232        let tree = LayoutTree::new(node(
233            1,
234            Modifier::empty(),
235            vec![
236                node(
237                    2,
238                    Modifier::empty().selectable_group(),
239                    vec![
240                        tab(3, false, true),
241                        tab(4, true, true),
242                        tab(5, false, false),
243                    ],
244                ),
245                node(6, Modifier::empty(), Vec::new()),
246            ],
247        ));
248        for (current, key, shift, expected) in [
249            (None, KeyCode::Tab, false, 4),
250            (Some(4), KeyCode::ArrowRight, false, 3),
251            (Some(3), KeyCode::ArrowLeft, false, 4),
252            (Some(3), KeyCode::End, false, 4),
253            (Some(4), KeyCode::Home, false, 3),
254            (Some(3), KeyCode::Tab, false, 6),
255            (Some(6), KeyCode::Tab, true, 4),
256        ] {
257            assert_eq!(
258                keyboard_focus_target(&tree, current, key, shift),
259                Some(KeyboardFocusTarget {
260                    node_id: expected,
261                    activate: false
262                })
263            );
264        }
265        assert_eq!(
266            keyboard_focus_target(&tree, Some(4), KeyCode::ArrowDown, false),
267            None
268        );
269        assert_eq!(
270            keyboard_focus_target(&tree, Some(6), KeyCode::Home, false),
271            None
272        );
273    }
274
275    #[test]
276    fn nested_groups_hidden_content_and_modals_have_separate_focus_scopes() {
277        let _scope = crate::render_state::app_context_test_scope();
278        let tree = LayoutTree::new(node(
279            1,
280            Modifier::empty(),
281            vec![
282                node(2, Modifier::empty(), Vec::new()),
283                node(
284                    3,
285                    Modifier::empty().semantics(|config| config.is_modal = true),
286                    vec![
287                        node(
288                            4,
289                            Modifier::empty().selectable_group(),
290                            vec![
291                                tab(5, false, true),
292                                node(
293                                    6,
294                                    Modifier::empty().selectable_group(),
295                                    vec![tab(7, true, true), tab(8, false, true)],
296                                ),
297                                tab(9, true, true),
298                            ],
299                        ),
300                        node(
301                            10,
302                            Modifier::empty().hide_from_accessibility(),
303                            vec![node(11, Modifier::empty(), Vec::new())],
304                        ),
305                    ],
306                ),
307            ],
308        ));
309        assert_eq!(
310            keyboard_focus_target(&tree, None, KeyCode::Tab, false)
311                .unwrap()
312                .node_id,
313            7
314        );
315        assert_eq!(
316            keyboard_focus_target(&tree, Some(7), KeyCode::End, false)
317                .unwrap()
318                .node_id,
319            8
320        );
321        assert_eq!(
322            keyboard_focus_target(&tree, Some(9), KeyCode::ArrowLeft, false)
323                .unwrap()
324                .node_id,
325            5
326        );
327        assert_eq!(
328            keyboard_focus_target(&tree, Some(9), KeyCode::Tab, false)
329                .unwrap()
330                .node_id,
331            7
332        );
333    }
334
335    #[test]
336    fn keyboard_navigation_publishes_the_order_for_programmatic_focus() {
337        let _scope = crate::render_state::app_context_test_scope();
338        crate::set_focus_order(Vec::new());
339        let tree = LayoutTree::new(node(
340            1,
341            Modifier::empty(),
342            vec![
343                node(2, Modifier::empty(), Vec::new()),
344                node(3, Modifier::empty(), Vec::new()),
345            ],
346        ));
347        assert!(keyboard_focus_target(&tree, None, KeyCode::Tab, false).is_some());
348        assert_eq!(crate::focus_order_len(), 2);
349        assert!(crate::FocusManager.move_focus(crate::FocusDirection::Next));
350        assert_eq!(crate::active_focus_target(), Some(2));
351    }
352
353    #[test]
354    fn focus_order_is_owned_by_its_application() {
355        let first = crate::render_state::AppContext::new();
356        let second = crate::render_state::AppContext::new();
357        let entry = FocusEntry {
358            node_id: 1,
359            rect: Rect::from_size(Size::new(10.0, 10.0)),
360        };
361        first.enter(|| crate::set_focus_order(vec![entry]));
362        second.enter(|| {
363            assert_eq!(crate::focus_order_len(), 0);
364            crate::set_focus_order(Vec::new());
365        });
366        first.enter(|| assert_eq!(crate::focus_order_len(), 1));
367    }
368}