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