use crate::menu::{Item, Menu, MenuId};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NavKey {
Down,
Up,
Right,
Left,
Activate,
Escape,
Home,
End,
Char(char),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FlyoutFocus {
pub parent: usize,
pub child: Option<usize>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MenuFocus {
pub top: Option<usize>,
pub flyout: Vec<FlyoutFocus>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NavAction {
None,
Redraw,
OpenFlyout(usize),
CloseFlyout,
Activate(MenuId),
CloseAll,
}
pub fn handle_key(menu: &Menu, focus: &mut MenuFocus, key: NavKey) -> NavAction {
if focus.flyout.is_empty() {
handle_in_top(menu, focus, key)
} else {
handle_in_flyout(menu, focus, key)
}
}
fn deepest_level<'a>(
menu: &'a Menu,
stack: &[FlyoutFocus],
) -> std::result::Result<&'a Menu, usize> {
let mut level = menu;
for (depth, frame) in stack.iter().enumerate() {
match submenu_child(level, frame.parent) {
Some(child) => level = child,
None => return Err(depth),
}
}
Ok(level)
}
fn handle_in_top(menu: &Menu, focus: &mut MenuFocus, key: NavKey) -> NavAction {
match key {
NavKey::Down => {
move_selection(menu, &mut focus.top, Dir::Next);
NavAction::Redraw
}
NavKey::Up => {
move_selection(menu, &mut focus.top, Dir::Prev);
NavAction::Redraw
}
NavKey::Home => {
focus.top = first_focusable(menu);
NavAction::Redraw
}
NavKey::End => {
focus.top = last_focusable(menu);
NavAction::Redraw
}
NavKey::Char(c) => match type_ahead(menu, focus.top, c) {
Some(i) => {
focus.top = Some(i);
NavAction::Redraw
}
None => NavAction::None,
},
NavKey::Right => match focus.top {
Some(i) if is_submenu_at(menu, i) => open_flyout(menu, focus, i),
_ => NavAction::None,
},
NavKey::Activate => match focus.top {
Some(i) if is_submenu_at(menu, i) => open_flyout(menu, focus, i),
Some(i) => match interactive_id_at(menu, i) {
Some(id) => NavAction::Activate(id),
None => NavAction::None,
},
None => NavAction::None,
},
NavKey::Left => NavAction::None,
NavKey::Escape => NavAction::CloseAll,
}
}
fn handle_in_flyout(menu: &Menu, focus: &mut MenuFocus, key: NavKey) -> NavAction {
let level = match deepest_level(menu, &focus.flyout) {
Ok(level) => level,
Err(valid) => {
let top_parent = focus.flyout.first().map(|f| f.parent);
focus.flyout.truncate(valid);
if focus.flyout.is_empty() {
focus.top = top_parent;
}
return NavAction::CloseFlyout;
}
};
let fly = *focus.flyout.last().expect("stack is non-empty here");
let mut sel = fly.child;
match key {
NavKey::Down => {
move_selection(level, &mut sel, Dir::Next);
commit_deepest(focus, sel);
NavAction::Redraw
}
NavKey::Up => {
move_selection(level, &mut sel, Dir::Prev);
commit_deepest(focus, sel);
NavAction::Redraw
}
NavKey::Home => {
commit_deepest(focus, first_focusable(level));
NavAction::Redraw
}
NavKey::End => {
commit_deepest(focus, last_focusable(level));
NavAction::Redraw
}
NavKey::Char(c) => match type_ahead(level, sel, c) {
Some(i) => {
commit_deepest(focus, Some(i));
NavAction::Redraw
}
None => NavAction::None,
},
NavKey::Left | NavKey::Escape => {
let popped = focus.flyout.pop();
if focus.flyout.is_empty() {
focus.top = popped.map(|f| f.parent);
}
NavAction::CloseFlyout
}
NavKey::Right => match sel {
Some(ci) if is_submenu_at(level, ci) => descend(level, focus, ci),
_ => NavAction::None,
},
NavKey::Activate => match sel {
Some(ci) if is_submenu_at(level, ci) => descend(level, focus, ci),
Some(ci) => match interactive_id_at(level, ci) {
Some(id) => NavAction::Activate(id),
None => NavAction::None,
},
None => NavAction::None,
},
}
}
fn open_flyout(menu: &Menu, focus: &mut MenuFocus, parent: usize) -> NavAction {
let child = submenu_child(menu, parent).and_then(first_focusable);
focus.top = Some(parent);
focus.flyout = vec![FlyoutFocus { parent, child }];
NavAction::OpenFlyout(parent)
}
fn descend(level: &Menu, focus: &mut MenuFocus, child_index: usize) -> NavAction {
let grand = submenu_child(level, child_index).and_then(first_focusable);
focus.flyout.push(FlyoutFocus {
parent: child_index,
child: grand,
});
NavAction::OpenFlyout(child_index)
}
fn commit_deepest(focus: &mut MenuFocus, child: Option<usize>) {
if let Some(frame) = focus.flyout.last_mut() {
frame.child = child;
}
}
#[derive(Clone, Copy)]
enum Dir {
Next,
Prev,
}
fn move_selection(menu: &Menu, sel: &mut Option<usize>, dir: Dir) {
let f = focusable_indices(menu);
if f.is_empty() {
*sel = None;
return;
}
let new = match sel.and_then(|cur| f.iter().position(|&x| x == cur)) {
Some(pos) => match dir {
Dir::Next => f[(pos + 1) % f.len()],
Dir::Prev => f[(pos + f.len() - 1) % f.len()],
},
None => match dir {
Dir::Next => f[0],
Dir::Prev => f[f.len() - 1],
},
};
*sel = Some(new);
}
fn first_focusable(menu: &Menu) -> Option<usize> {
focusable_indices(menu).first().copied()
}
fn last_focusable(menu: &Menu) -> Option<usize> {
focusable_indices(menu).last().copied()
}
fn type_ahead(menu: &Menu, current: Option<usize>, c: char) -> Option<usize> {
let needle = c.to_ascii_lowercase();
let f = focusable_indices(menu);
if f.is_empty() {
return None;
}
let start = current
.and_then(|cur| f.iter().position(|&x| x == cur))
.map(|p| p + 1)
.unwrap_or(0);
for k in 0..f.len() {
let idx = f[(start + k) % f.len()];
if item_name(menu, idx)
.chars()
.next()
.map(|ch| ch.to_ascii_lowercase() == needle)
.unwrap_or(false)
{
return Some(idx);
}
}
None
}
fn focusable_indices(menu: &Menu) -> Vec<usize> {
menu.items
.iter()
.enumerate()
.filter(|(_, it)| it.is_interactive())
.map(|(i, _)| i)
.collect()
}
fn submenu_child(menu: &Menu, i: usize) -> Option<&Menu> {
match menu.items.get(i) {
Some(Item::Submenu { menu, .. }) => Some(menu),
_ => None,
}
}
fn is_submenu_at(menu: &Menu, i: usize) -> bool {
matches!(menu.items.get(i), Some(Item::Submenu { .. }))
}
fn interactive_id_at(menu: &Menu, i: usize) -> Option<MenuId> {
match menu.items.get(i) {
Some(Item::Row(r)) if r.enabled && !r.id.is_none() => Some(r.id.clone()),
_ => None,
}
}
fn item_name(menu: &Menu, i: usize) -> String {
match menu.items.get(i) {
Some(Item::Row(r)) | Some(Item::SectionHeader(r)) => r.accessible_name(),
Some(Item::Submenu { label, .. }) => label.accessible_name(),
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::menu::Row;
fn menu() -> Menu {
Menu::new()
.section_header(Row::label_only("Claude"))
.row(Row::new("a").label("Apple"))
.row(Row::new("b").label("Banana"))
.separator()
.submenu(
Row::new("settings").label("Settings"),
Menu::new()
.row(Row::new("s1").label("One"))
.row(Row::new("s2").label("Two")),
)
.row(Row::new("quit").label("Quit"))
}
fn press(m: &Menu, f: &mut MenuFocus, k: NavKey) -> NavAction {
handle_key(m, f, k)
}
#[test]
fn down_from_nothing_selects_first_focusable_skipping_header() {
let m = menu();
let mut f = MenuFocus::default();
assert_eq!(press(&m, &mut f, NavKey::Down), NavAction::Redraw);
assert_eq!(f.top, Some(1)); }
#[test]
fn up_from_nothing_selects_last_focusable() {
let m = menu();
let mut f = MenuFocus::default();
press(&m, &mut f, NavKey::Up);
assert_eq!(f.top, Some(5));
}
#[test]
fn down_skips_separator_and_wraps() {
let m = menu();
let mut f = MenuFocus {
top: Some(1),
flyout: Vec::new(),
};
press(&m, &mut f, NavKey::Down);
assert_eq!(f.top, Some(2));
press(&m, &mut f, NavKey::Down);
assert_eq!(f.top, Some(4)); press(&m, &mut f, NavKey::Down);
assert_eq!(f.top, Some(5));
press(&m, &mut f, NavKey::Down);
assert_eq!(f.top, Some(1)); }
#[test]
fn up_wraps_to_last() {
let m = menu();
let mut f = MenuFocus {
top: Some(1),
flyout: Vec::new(),
};
press(&m, &mut f, NavKey::Up);
assert_eq!(f.top, Some(5));
}
#[test]
fn home_and_end_jump_to_bounds() {
let m = menu();
let mut f = MenuFocus {
top: Some(4),
flyout: Vec::new(),
};
press(&m, &mut f, NavKey::Home);
assert_eq!(f.top, Some(1));
press(&m, &mut f, NavKey::End);
assert_eq!(f.top, Some(5));
}
#[test]
fn right_on_submenu_opens_flyout_and_focuses_first_child() {
let m = menu();
let mut f = MenuFocus {
top: Some(4),
flyout: Vec::new(),
};
assert_eq!(press(&m, &mut f, NavKey::Right), NavAction::OpenFlyout(4));
assert_eq!(
f.flyout,
vec![FlyoutFocus {
parent: 4,
child: Some(0),
}]
);
}
#[test]
fn right_on_leaf_does_nothing() {
let m = menu();
let mut f = MenuFocus {
top: Some(1),
flyout: Vec::new(),
};
assert_eq!(press(&m, &mut f, NavKey::Right), NavAction::None);
assert!(f.flyout.is_empty());
}
#[test]
fn activate_leaf_returns_its_id() {
let m = menu();
let mut f = MenuFocus {
top: Some(5),
flyout: Vec::new(),
};
assert_eq!(
press(&m, &mut f, NavKey::Activate),
NavAction::Activate(MenuId::from("quit"))
);
}
#[test]
fn activate_submenu_opens_it() {
let m = menu();
let mut f = MenuFocus {
top: Some(4),
flyout: Vec::new(),
};
assert_eq!(
press(&m, &mut f, NavKey::Activate),
NavAction::OpenFlyout(4)
);
}
#[test]
fn navigation_within_flyout_then_left_closes() {
let m = menu();
let mut f = MenuFocus {
top: Some(4),
flyout: vec![FlyoutFocus {
parent: 4,
child: Some(0),
}],
};
assert_eq!(press(&m, &mut f, NavKey::Down), NavAction::Redraw);
assert_eq!(f.flyout.last().unwrap().child, Some(1));
press(&m, &mut f, NavKey::Down);
assert_eq!(f.flyout.last().unwrap().child, Some(0));
assert_eq!(press(&m, &mut f, NavKey::Left), NavAction::CloseFlyout);
assert!(f.flyout.is_empty());
assert_eq!(f.top, Some(4));
}
#[test]
fn activate_in_flyout_returns_child_id() {
let m = menu();
let mut f = MenuFocus {
top: Some(4),
flyout: vec![FlyoutFocus {
parent: 4,
child: Some(1),
}],
};
assert_eq!(
press(&m, &mut f, NavKey::Activate),
NavAction::Activate(MenuId::from("s2"))
);
}
#[test]
fn escape_pops_one_level_then_closes_all() {
let m = menu();
let mut f = MenuFocus {
top: Some(4),
flyout: vec![FlyoutFocus {
parent: 4,
child: Some(0),
}],
};
assert_eq!(press(&m, &mut f, NavKey::Escape), NavAction::CloseFlyout);
assert!(f.flyout.is_empty());
assert_eq!(f.top, Some(4));
assert_eq!(press(&m, &mut f, NavKey::Escape), NavAction::CloseAll);
}
#[test]
fn type_ahead_jumps_and_wraps() {
let m = menu();
let mut f = MenuFocus {
top: Some(1),
flyout: Vec::new(),
};
assert_eq!(press(&m, &mut f, NavKey::Char('q')), NavAction::Redraw);
assert_eq!(f.top, Some(5));
press(&m, &mut f, NavKey::Char('b'));
assert_eq!(f.top, Some(2));
assert_eq!(press(&m, &mut f, NavKey::Char('z')), NavAction::None);
assert_eq!(f.top, Some(2));
}
#[test]
fn empty_menu_has_no_selection() {
let m = Menu::new();
let mut f = MenuFocus::default();
press(&m, &mut f, NavKey::Down);
assert_eq!(f.top, None);
}
fn nested_menu() -> Menu {
Menu::new()
.submenu(
Row::new("more").label("More"),
Menu::new()
.submenu(
Row::new("deep").label("Deep"),
Menu::new()
.row(Row::new("leaf1").label("Leaf1"))
.row(Row::new("leaf2").label("Leaf2")),
)
.row(Row::new("end").label("End")),
)
.row(Row::new("quit").label("Quit"))
}
#[test]
fn right_within_flyout_descends_into_nested_submenu() {
let m = nested_menu();
let mut f = MenuFocus {
top: Some(0),
flyout: Vec::new(),
};
assert_eq!(press(&m, &mut f, NavKey::Right), NavAction::OpenFlyout(0));
assert_eq!(
f.flyout,
vec![FlyoutFocus {
parent: 0,
child: Some(0)
}]
);
assert_eq!(press(&m, &mut f, NavKey::Right), NavAction::OpenFlyout(0));
assert_eq!(
f.flyout,
vec![
FlyoutFocus {
parent: 0,
child: Some(0)
},
FlyoutFocus {
parent: 0,
child: Some(0)
},
]
);
assert_eq!(press(&m, &mut f, NavKey::Down), NavAction::Redraw);
assert_eq!(f.flyout.last().unwrap().child, Some(1)); assert_eq!(f.flyout[0].child, Some(0)); }
#[test]
fn activate_deep_leaf_returns_its_id() {
let m = nested_menu();
let mut f = MenuFocus {
top: Some(0),
flyout: vec![
FlyoutFocus {
parent: 0,
child: Some(0),
},
FlyoutFocus {
parent: 0,
child: Some(1),
},
],
};
assert_eq!(
press(&m, &mut f, NavKey::Activate),
NavAction::Activate(MenuId::from("leaf2"))
);
}
#[test]
fn left_and_escape_pop_one_level_at_a_time() {
let m = nested_menu();
let mut f = MenuFocus {
top: Some(0),
flyout: vec![
FlyoutFocus {
parent: 0,
child: Some(0),
},
FlyoutFocus {
parent: 0,
child: Some(0),
},
],
};
assert_eq!(press(&m, &mut f, NavKey::Left), NavAction::CloseFlyout);
assert_eq!(
f.flyout,
vec![FlyoutFocus {
parent: 0,
child: Some(0)
}]
);
assert_eq!(f.top, Some(0));
assert_eq!(press(&m, &mut f, NavKey::Escape), NavAction::CloseFlyout);
assert!(f.flyout.is_empty());
assert_eq!(f.top, Some(0));
assert_eq!(press(&m, &mut f, NavKey::Escape), NavAction::CloseAll);
}
#[test]
fn activate_on_nested_submenu_child_descends() {
let m = nested_menu();
let mut f = MenuFocus {
top: Some(0),
flyout: vec![FlyoutFocus {
parent: 0,
child: Some(0),
}],
};
assert_eq!(
press(&m, &mut f, NavKey::Activate),
NavAction::OpenFlyout(0)
);
assert_eq!(f.flyout.len(), 2);
assert_eq!(f.flyout.last().unwrap().child, Some(0));
}
#[test]
fn stale_nested_stack_collapses_to_live_parent() {
let m = menu(); let mut f = MenuFocus {
top: Some(4),
flyout: vec![
FlyoutFocus {
parent: 4,
child: Some(0),
},
FlyoutFocus {
parent: 0,
child: Some(0),
}, ],
};
assert_eq!(press(&m, &mut f, NavKey::Down), NavAction::CloseFlyout);
assert_eq!(
f.flyout,
vec![FlyoutFocus {
parent: 4,
child: Some(0)
}]
);
}
}