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)]
174#[path = "tests/focus_navigation_tests.rs"]
175mod tests;