use cranpose_core::{NodeId, collections::map::HashMap};
use cranpose_foundation::SemanticsWidgetRole;
use crate::{KeyCode, LayoutTree, focus_dispatch, focus_order::FocusEntry};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct KeyboardFocusTarget {
pub node_id: NodeId,
pub activate: bool,
}
struct Target {
entry: FocusEntry,
group: Option<NodeId>,
role: Option<SemanticsWidgetRole>,
selected: bool,
}
fn targets(tree: &LayoutTree) -> Vec<Target> {
let mut output = Vec::new();
collect_targets(
crate::focus_order::focus_root(tree.root()),
None,
&mut output,
);
output
}
fn collect_targets(node: &crate::LayoutBox, group: Option<NodeId>, output: &mut Vec<Target>) {
let config = node.node_data.semantics();
if config.is_some_and(|config| config.hidden) {
return;
}
if focus_dispatch::has_focus_target(node.node_id)
&& crate::focus_order::takes_space(node.rect)
&& config.is_none_or(|config| config.enabled)
{
output.push(Target {
entry: FocusEntry {
node_id: node.node_id,
rect: node.rect,
},
group,
role: config.and_then(|config| config.role),
selected: config.is_some_and(|config| config.selected == Some(true)),
});
}
let group = if config.is_some_and(crate::focus_order::is_selectable_group) {
Some(node.node_id)
} else {
group
};
for child in &node.children {
collect_targets(child, group, output);
}
}
fn is_composite_member(target: &Target) -> bool {
target.group.is_some()
&& matches!(
target.role,
Some(
SemanticsWidgetRole::RadioButton
| SemanticsWidgetRole::Tab
| SemanticsWidgetRole::MenuItem
)
)
}
fn tab_targets(targets: &[Target], current: Option<NodeId>) -> Vec<&Target> {
let mut stops: HashMap<NodeId, &Target> = HashMap::default();
for target in targets.iter().filter(|target| is_composite_member(target)) {
let group = target.group.expect("composite member has a group");
let stop = stops.entry(group).or_insert(target);
if Some(target.entry.node_id) == current
|| (Some(stop.entry.node_id) != current && target.selected && !stop.selected)
{
*stop = target;
}
}
targets
.iter()
.filter(|target| {
!is_composite_member(target)
|| target
.group
.is_some_and(|group| stops[&group].entry.node_id == target.entry.node_id)
})
.collect()
}
pub fn keyboard_focus_target(
tree: &LayoutTree,
current: Option<NodeId>,
key: KeyCode,
shift: bool,
) -> Option<KeyboardFocusTarget> {
let targets = targets(tree);
crate::set_focus_order(targets.iter().map(|target| target.entry).collect());
if key == KeyCode::Tab {
let stops = tab_targets(&targets, current);
let from = stops
.iter()
.position(|target| Some(target.entry.node_id) == current);
let index = stepped_index(stops.len(), from, !shift)?;
return Some(KeyboardFocusTarget {
node_id: stops[index].entry.node_id,
activate: false,
});
}
let current = targets
.iter()
.find(|target| Some(target.entry.node_id) == current)?;
if !is_composite_member(current) {
return None;
}
let members: Vec<_> = targets
.iter()
.filter(|target| target.group == current.group && target.role == current.role)
.collect();
let from = members
.iter()
.position(|target| target.entry.node_id == current.entry.node_id)?;
let radio = current.role == Some(SemanticsWidgetRole::RadioButton);
let index = group_key_index(&members, from, key, radio)?;
Some(KeyboardFocusTarget {
node_id: members[index].entry.node_id,
activate: radio,
})
}
fn group_key_index(members: &[&Target], from: usize, key: KeyCode, radio: bool) -> Option<usize> {
let horizontal = members.get(1).is_some_and(|second| {
let first = members[0].entry.rect;
(second.entry.rect.x - first.x).abs() >= (second.entry.rect.y - first.y).abs()
});
Some(match key {
KeyCode::Home => 0,
KeyCode::End => members.len() - 1,
KeyCode::ArrowLeft | KeyCode::ArrowRight if radio || horizontal => {
stepped_index(members.len(), Some(from), key == KeyCode::ArrowRight)?
}
KeyCode::ArrowUp | KeyCode::ArrowDown if radio || !horizontal => {
stepped_index(members.len(), Some(from), key == KeyCode::ArrowDown)?
}
_ => return None,
})
}
fn stepped_index(count: usize, current: Option<usize>, forward: bool) -> Option<usize> {
if count == 0 {
return None;
}
Some(match (current, forward) {
(Some(index), true) => (index + 1) % count,
(Some(index), false) => (index + count - 1) % count,
(None, true) => 0,
(None, false) => count - 1,
})
}
#[cfg(test)]
#[path = "tests/focus_navigation_tests.rs"]
mod tests;