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 = 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
100pub 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}