use crate::keynav::MenuFocus;
use crate::menu::{Item, Menu};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AxId(pub u64);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AxRole {
Menu,
MenuItem,
MenuItemCheckbox,
GroupLabel,
Separator,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AxNode {
pub id: AxId,
pub role: AxRole,
pub name: String,
pub enabled: bool,
pub checked: Option<bool>,
pub has_popup: bool,
pub expanded: Option<bool>,
pub pos_in_set: Option<usize>,
pub set_size: Option<usize>,
pub item_index: Option<usize>,
pub children: Vec<AxNode>,
}
impl AxNode {
pub fn find(&self, id: AxId) -> Option<&AxNode> {
if self.id == id {
return Some(self);
}
self.children.iter().find_map(|c| c.find(id))
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AxTree {
pub root: AxNode,
}
impl AxTree {
pub fn find(&self, id: AxId) -> Option<&AxNode> {
self.root.find(id)
}
pub fn node_count(&self) -> usize {
fn count(n: &AxNode) -> usize {
1 + n.children.iter().map(count).sum::<usize>()
}
count(&self.root)
}
}
pub fn build_tree(menu: &Menu) -> AxTree {
let mut next: u64 = 0;
let root_id = alloc(&mut next);
let children = build_level(menu, &mut next);
AxTree {
root: AxNode {
id: root_id,
role: AxRole::Menu,
name: String::new(),
enabled: true,
checked: None,
has_popup: false,
expanded: None,
pos_in_set: None,
set_size: None,
item_index: None,
children,
},
}
}
fn alloc(next: &mut u64) -> AxId {
let id = AxId(*next);
*next += 1;
id
}
fn build_level(menu: &Menu, next: &mut u64) -> Vec<AxNode> {
let set_size = menu.interactive_count();
let mut pos = 0usize;
let mut out = Vec::with_capacity(menu.items.len());
for (i, item) in menu.items.iter().enumerate() {
let id = alloc(next);
let interactive = item.is_interactive();
let (pos_in_set, set) = if interactive {
pos += 1;
(Some(pos), Some(set_size))
} else {
(None, None)
};
let node = match item {
Item::Separator => AxNode {
id,
role: AxRole::Separator,
name: String::new(),
enabled: false,
checked: None,
has_popup: false,
expanded: None,
pos_in_set: None,
set_size: None,
item_index: Some(i),
children: Vec::new(),
},
Item::SectionHeader(row) => AxNode {
id,
role: AxRole::GroupLabel,
name: row.accessible_name(),
enabled: false,
checked: None,
has_popup: false,
expanded: None,
pos_in_set: None,
set_size: None,
item_index: Some(i),
children: Vec::new(),
},
Item::Content(_) => AxNode {
id,
role: AxRole::GroupLabel,
name: String::new(),
enabled: false,
checked: None,
has_popup: false,
expanded: None,
pos_in_set: None,
set_size: None,
item_index: Some(i),
children: Vec::new(),
},
Item::Row(row) => AxNode {
id,
role: if row.checked.is_some() {
AxRole::MenuItemCheckbox
} else {
AxRole::MenuItem
},
name: row
.accessibility_label
.clone()
.unwrap_or_else(|| row.accessible_name()),
enabled: row.enabled,
checked: row.checked,
has_popup: false,
expanded: None,
pos_in_set,
set_size: set,
item_index: Some(i),
children: Vec::new(),
},
Item::Submenu { label, menu: child } => AxNode {
id,
role: if label.checked.is_some() {
AxRole::MenuItemCheckbox
} else {
AxRole::MenuItem
},
name: label
.accessibility_label
.clone()
.unwrap_or_else(|| label.accessible_name()),
enabled: label.enabled,
checked: label.checked,
has_popup: true,
expanded: Some(false),
pos_in_set,
set_size: set,
item_index: Some(i),
children: build_level(child, next),
},
};
out.push(node);
}
out
}
pub fn focused_id(tree: &AxTree, focus: &MenuFocus) -> Option<AxId> {
let Some(last) = focus.flyout.last() else {
let top = focus.top?;
return tree
.root
.children
.iter()
.find(|n| n.item_index == Some(top))
.map(|n| n.id);
};
let mut parent = &tree.root;
for frame in &focus.flyout {
parent = parent
.children
.iter()
.find(|n| n.item_index == Some(frame.parent))?;
}
match last.child {
Some(ci) => parent
.children
.iter()
.find(|n| n.item_index == Some(ci))
.map(|n| n.id)
.or(Some(parent.id)),
None => Some(parent.id),
}
}
pub fn locate(tree: &AxTree, id: AxId) -> Option<(usize, Option<usize>)> {
let path = locate_path(tree, id)?;
match path.as_slice() {
[top] => Some((*top, None)),
[top, child, ..] => Some((*top, Some(*child))),
[] => None,
}
}
pub fn locate_path(tree: &AxTree, id: AxId) -> Option<Vec<usize>> {
fn walk(node: &AxNode, id: AxId, path: &mut Vec<usize>) -> bool {
for child in &node.children {
let Some(idx) = child.item_index else {
continue;
};
path.push(idx);
if child.id == id || walk(child, id, path) {
return true;
}
path.pop();
}
false
}
let mut path = Vec::new();
if walk(&tree.root, id, &mut path) {
Some(path)
} else {
None
}
}
pub fn set_expanded(tree: &mut AxTree, path: &[usize], expanded: bool) {
let mut node = &mut tree.root;
for &idx in path {
let Some(next) = node.children.iter_mut().find(|n| n.item_index == Some(idx)) else {
return;
};
node = next;
}
if node.has_popup {
node.expanded = Some(expanded);
}
}
pub fn announcement(node: &AxNode) -> String {
let mut parts = Vec::new();
if !node.name.is_empty() {
parts.push(node.name.clone());
}
match node.checked {
Some(true) => parts.push("checked".to_string()),
Some(false) => parts.push("unchecked".to_string()),
None => {}
}
if node.has_popup {
parts.push("submenu".to_string());
match node.expanded {
Some(true) => parts.push("expanded".to_string()),
Some(false) => parts.push("collapsed".to_string()),
None => {}
}
}
if !node.enabled && node.role != AxRole::GroupLabel && node.role != AxRole::Separator {
parts.push("dimmed".to_string());
}
match node.role {
AxRole::MenuItem | AxRole::MenuItemCheckbox => parts.push("menu item".to_string()),
AxRole::GroupLabel => parts.push("heading".to_string()),
AxRole::Menu => parts.push("menu".to_string()),
AxRole::Separator => parts.push("separator".to_string()),
}
if let (Some(p), Some(s)) = (node.pos_in_set, node.set_size) {
parts.push(format!("{p} of {s}"));
}
parts.join(", ")
}
#[cfg(feature = "a11y")]
pub mod accesskit {
use super::{AxNode, AxRole, AxTree};
use accesskit::{HasPopup, Node, NodeId, Role, Toggled, Tree, TreeUpdate};
fn role_of(node: &AxNode) -> Role {
match node.role {
AxRole::Menu => Role::Menu,
AxRole::MenuItem => Role::MenuItem,
AxRole::MenuItemCheckbox => Role::MenuItemCheckBox,
AxRole::GroupLabel => Role::Label,
AxRole::Separator => Role::Splitter,
}
}
fn push(node: &AxNode, out: &mut Vec<(NodeId, Node)>) {
let mut n = Node::new(role_of(node));
if !node.name.is_empty() {
n.set_label(node.name.clone());
}
if !node.enabled {
n.set_disabled();
}
match node.checked {
Some(true) => n.set_toggled(Toggled::True),
Some(false) => n.set_toggled(Toggled::False),
None => {}
}
if node.has_popup {
n.set_has_popup(HasPopup::Menu);
n.set_expanded(node.expanded.unwrap_or(false));
}
if let (Some(p), Some(s)) = (node.pos_in_set, node.set_size) {
n.set_position_in_set(p);
n.set_size_of_set(s);
}
n.set_children(
node.children
.iter()
.map(|c| NodeId(c.id.0))
.collect::<Vec<_>>(),
);
out.push((NodeId(node.id.0), n));
for c in &node.children {
push(c, out);
}
}
pub fn tree_update(tree: &AxTree, focus: Option<super::AxId>) -> TreeUpdate {
let mut nodes = Vec::with_capacity(tree.node_count());
push(&tree.root, &mut nodes);
let root = NodeId(tree.root.id.0);
TreeUpdate {
nodes,
tree: Some(Tree::new(root)),
focus: focus.map(|f| NodeId(f.0)).unwrap_or(root),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keynav::FlyoutFocus;
use crate::menu::{Icon, Row};
fn menu() -> Menu {
Menu::new()
.section_header(Row::info().label("Claude"))
.row(
Row::new("switch:me")
.label("me@example.com")
.leading(Icon::Checkmark)
.checked(true),
)
.row(Row::new("switch:you").label("you@example.com"))
.separator()
.row(Row::new("locked").label("Locked").enabled(false))
.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"))
}
#[test]
fn root_is_a_menu_with_one_node_per_item() {
let tree = build_tree(&menu());
assert_eq!(tree.root.role, AxRole::Menu);
assert_eq!(tree.root.children.len(), 7);
}
#[test]
fn content_row_maps_to_a_noninteractive_group_label() {
use crate::menu::{Content, Stack, TextContent};
let menu = Menu::new()
.row(Row::new("a").label("Alpha"))
.content(Stack::vertical(2.0).child(Content::Text(TextContent::new("12"))));
let tree = build_tree(&menu);
let c = &tree.root.children;
assert_eq!(c.len(), 2);
assert_eq!(c[1].role, AxRole::GroupLabel);
assert_eq!(c[1].name, "");
assert!(!c[1].enabled);
assert!(c[1].children.is_empty());
assert_eq!(c[1].pos_in_set, None);
}
#[test]
fn roles_and_states_map_from_the_menu() {
let tree = build_tree(&menu());
let c = &tree.root.children;
assert_eq!(c[0].role, AxRole::GroupLabel); assert_eq!(c[0].name, "Claude");
assert_eq!(c[1].role, AxRole::MenuItemCheckbox); assert_eq!(c[1].checked, Some(true));
assert_eq!(c[2].role, AxRole::MenuItem);
assert_eq!(c[3].role, AxRole::Separator);
assert!(!c[4].enabled); assert!(c[5].has_popup);
assert_eq!(c[5].expanded, Some(false));
assert_eq!(c[5].children.len(), 2); assert_eq!(c[5].children[0].name, "One");
}
#[test]
fn set_position_counts_only_focusable_siblings() {
let tree = build_tree(&menu());
let c = &tree.root.children;
assert_eq!(c[1].pos_in_set, Some(1));
assert_eq!(c[1].set_size, Some(4));
assert_eq!(c[2].pos_in_set, Some(2));
assert_eq!(c[4].pos_in_set, None); assert_eq!(c[5].pos_in_set, Some(3));
assert_eq!(c[6].pos_in_set, Some(4));
}
#[test]
fn ids_are_unique_and_findable() {
let tree = build_tree(&menu());
let n = tree.node_count();
assert_eq!(n, 10);
for id in 0..n as u64 {
assert!(tree.find(AxId(id)).is_some(), "id {id} should exist");
}
}
#[test]
fn focused_id_tracks_top_level_selection() {
let tree = build_tree(&menu());
let focus = MenuFocus {
top: Some(2),
flyout: Vec::new(),
};
let id = focused_id(&tree, &focus).unwrap();
assert_eq!(tree.find(id).unwrap().name, "you@example.com");
}
#[test]
fn focused_id_tracks_flyout_child() {
let tree = build_tree(&menu());
let focus = MenuFocus {
top: Some(5),
flyout: vec![FlyoutFocus {
parent: 5,
child: Some(1),
}],
};
let id = focused_id(&tree, &focus).unwrap();
assert_eq!(tree.find(id).unwrap().name, "Two");
}
#[test]
fn focused_id_on_open_flyout_without_child_is_the_parent() {
let tree = build_tree(&menu());
let focus = MenuFocus {
top: Some(5),
flyout: vec![FlyoutFocus {
parent: 5,
child: None,
}],
};
let id = focused_id(&tree, &focus).unwrap();
assert_eq!(tree.find(id).unwrap().name, "Settings");
}
#[test]
fn locate_maps_ids_back_to_menu_positions() {
let tree = build_tree(&menu());
let top_id = tree.root.children[2].id;
assert_eq!(locate(&tree, top_id), Some((2, None)));
let child_id = tree.root.children[5].children[1].id;
assert_eq!(locate(&tree, child_id), Some((5, Some(1))));
let parent_id = tree.root.children[5].id;
assert_eq!(locate(&tree, parent_id), Some((5, None)));
assert_eq!(locate(&tree, AxId(9999)), None);
}
#[test]
fn locate_path_resolves_full_nesting() {
let m = Menu::new()
.submenu(
Row::new("outer").label("Outer"),
Menu::new().submenu(
Row::new("inner").label("Inner"),
Menu::new()
.row(Row::new("d0").label("D0"))
.row(Row::new("deep").label("Deep")),
),
)
.row(Row::new("quit").label("Quit"));
let tree = build_tree(&m);
let deep = tree.root.children[0].children[0].children[1].id;
assert_eq!(locate_path(&tree, deep), Some(vec![0, 0, 1]));
let inner = tree.root.children[0].children[0].id;
assert_eq!(locate_path(&tree, inner), Some(vec![0, 0]));
let quit = tree.root.children[1].id;
assert_eq!(locate_path(&tree, quit), Some(vec![1]));
assert_eq!(locate_path(&tree, AxId(9999)), None);
assert_eq!(locate(&tree, deep), Some((0, Some(0))));
}
#[test]
fn set_expanded_flips_the_submenu_state() {
let mut tree = build_tree(&menu());
set_expanded(&mut tree, &[5], true);
assert_eq!(tree.root.children[5].expanded, Some(true));
set_expanded(&mut tree, &[5], false);
assert_eq!(tree.root.children[5].expanded, Some(false));
}
#[test]
fn set_expanded_reaches_nested_submenus() {
let m = Menu::new().submenu(
Row::new("outer").label("Outer"),
Menu::new().submenu(
Row::new("inner").label("Inner"),
Menu::new().row(Row::new("leaf").label("Leaf")),
),
);
let mut tree = build_tree(&m);
set_expanded(&mut tree, &[0, 0], true);
assert_eq!(tree.root.children[0].children[0].expanded, Some(true));
assert_eq!(tree.root.children[0].expanded, Some(false));
}
#[test]
fn focused_id_tracks_deeply_nested_selection() {
let m = Menu::new().submenu(
Row::new("outer").label("Outer"),
Menu::new().submenu(
Row::new("inner").label("Inner"),
Menu::new()
.row(Row::new("d0").label("D0"))
.row(Row::new("deep").label("Deep")),
),
);
let tree = build_tree(&m);
let focus = MenuFocus {
top: Some(0),
flyout: vec![
FlyoutFocus {
parent: 0,
child: Some(0),
},
FlyoutFocus {
parent: 0,
child: Some(1),
},
],
};
let id = focused_id(&tree, &focus).unwrap();
assert_eq!(tree.find(id).unwrap().name, "Deep");
}
#[cfg(feature = "a11y")]
#[test]
fn accesskit_update_carries_every_node_and_the_focus() {
use crate::keynav::FlyoutFocus;
let tree = build_tree(&menu());
let focus = MenuFocus {
top: Some(5),
flyout: vec![FlyoutFocus {
parent: 5,
child: Some(0),
}],
};
let fid = focused_id(&tree, &focus).unwrap();
let update = super::accesskit::tree_update(&tree, Some(fid));
assert_eq!(update.nodes.len(), tree.node_count());
assert_eq!(update.focus, ::accesskit::NodeId(fid.0));
assert!(update.tree.is_some());
}
#[cfg(feature = "a11y")]
#[test]
fn accesskit_node_carries_has_popup_for_a_submenu() {
let tree = build_tree(&menu());
let update = super::accesskit::tree_update(&tree, None);
let submenu_id = ::accesskit::NodeId(tree.root.children[5].id.0);
let (_, submenu_node) = update
.nodes
.iter()
.find(|(id, _)| *id == submenu_id)
.expect("submenu node present in the update");
assert_eq!(submenu_node.has_popup(), Some(::accesskit::HasPopup::Menu));
let plain_id = ::accesskit::NodeId(tree.root.children[2].id.0);
let (_, plain_node) = update
.nodes
.iter()
.find(|(id, _)| *id == plain_id)
.expect("plain row node present in the update");
assert_eq!(plain_node.has_popup(), None);
}
#[test]
fn accessibility_label_overrides_the_ax_name_but_not_accessible_name() {
let row = Row::new("mute")
.leading(Icon::Symbol("bell.slash"))
.accessibility_label("Mute notifications");
assert_eq!(row.accessible_name(), "");
let tree = build_tree(&Menu::new().row(row));
assert_eq!(tree.root.children[0].name, "Mute notifications");
}
#[test]
fn icon_only_row_without_label_has_empty_ax_name() {
let row = Row::new("mute").leading(Icon::Symbol("bell.slash"));
let tree = build_tree(&Menu::new().row(row));
assert_eq!(tree.root.children[0].name, "");
}
#[test]
fn default_rows_still_use_accessible_name() {
let tree = build_tree(&menu());
assert_eq!(tree.root.children[2].name, "you@example.com");
}
#[test]
fn announcement_reads_name_state_role_and_position() {
let tree = build_tree(&menu());
let c = &tree.root.children;
assert_eq!(
announcement(&c[1]),
"me@example.com, checked, menu item, 1 of 4"
);
assert_eq!(announcement(&c[0]), "Claude, heading");
assert_eq!(
announcement(&c[4]),
"Locked, dimmed, menu item" );
assert_eq!(
announcement(&c[5]),
"Settings, submenu, collapsed, menu item, 3 of 4"
);
}
}