cranpose_ui/
focus_navigation.rs1use cranpose_core::{NodeId, collections::map::HashMap};
2use cranpose_foundation::SemanticsWidgetRole;
3
4use crate::{KeyCode, LayoutTree, focus_dispatch, focus_order::FocusEntry};
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub struct KeyboardFocusTarget {
9 pub node_id: NodeId,
11 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
95pub 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;