1use 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)]
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}