#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod action;
pub mod builder;
pub mod dirty;
pub mod focus;
pub mod nav;
pub mod pool;
pub mod props;
pub mod text_a11y;
#[cfg(feature = "text-bridge")]
pub mod text_bridge;
pub mod tree;
pub mod widget_bridge;
pub use action::{map_action, A11yAction, ActionDispatcher};
pub use builder::A11yNodeBuilder;
pub use dirty::{DirtyTracker, Lazy};
pub use focus::{FocusIndicator, FocusRing};
pub use nav::{tab_next, tab_prev, TabOrder};
pub use pool::NodePool;
pub use props::{
byte_offset_to_char_index, character_lengths_utf8, A11yNodeProps, CheckedState, LiveSetting,
TextCaret, TextRunChild, TextSelection, Toggled3,
};
pub use tree::{
build_table_a11y, column_header_node, synthesize_text_run_children, table_cell_node,
table_row_node, A11yNode, A11yTree, WidgetRole,
};
pub use widget_bridge::{
build_a11y_tree, core_role_to_widget_role, widget_to_a11y_node, A11yWidgetNode, NodeIdAllocator,
};
#[derive(Debug, Clone, Default)]
pub struct OsA11yPrefs {
pub high_contrast: bool,
pub reduced_motion: bool,
}
impl OsA11yPrefs {
pub fn query() -> Self {
Self::query_from(|name| std::env::var(name).ok())
}
pub fn query_from<F>(lookup: F) -> Self
where
F: Fn(&str) -> Option<String>,
{
let high_contrast = lookup("OXIUI_HIGH_CONTRAST")
.map(|v| !v.is_empty())
.unwrap_or(false);
let reduced_motion = lookup("OXIUI_REDUCED_MOTION")
.map(|v| !v.is_empty())
.unwrap_or(false);
Self {
high_contrast,
reduced_motion,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WindowA11yId(pub u64);
#[derive(Default)]
pub struct A11yForest {
trees: std::collections::HashMap<WindowA11yId, A11yTree>,
}
impl A11yForest {
pub fn new() -> Self {
Self {
trees: std::collections::HashMap::new(),
}
}
pub fn insert(&mut self, id: WindowA11yId, tree: A11yTree) {
self.trees.insert(id, tree);
}
pub fn get(&self, id: WindowA11yId) -> Option<&A11yTree> {
self.trees.get(&id)
}
pub fn get_mut(&mut self, id: WindowA11yId) -> Option<&mut A11yTree> {
self.trees.get_mut(&id)
}
pub fn remove(&mut self, id: WindowA11yId) -> Option<A11yTree> {
self.trees.remove(&id)
}
pub fn iter(&self) -> impl Iterator<Item = (WindowA11yId, &A11yTree)> {
self.trees.iter().map(|(k, v)| (*k, v))
}
pub fn register(&mut self, id: WindowA11yId, tree: A11yTree) {
self.trees.insert(id, tree);
}
pub fn unregister(&mut self, id: WindowA11yId) {
self.trees.remove(&id);
}
pub fn windows(&self) -> impl Iterator<Item = WindowA11yId> + '_ {
self.trees.keys().copied()
}
}
#[cfg(test)]
mod tests {
use super::*;
use accesskit::{NodeId, Role};
use std::time::Instant;
fn nid(n: u64) -> NodeId {
NodeId(n)
}
#[test]
fn widget_role_to_accesskit_role_all_variants() {
use WidgetRole::*;
let cases: &[(WidgetRole, Role)] = &[
(Window, Role::Window),
(Group, Role::Group),
(Button, Role::Button),
(Label, Role::Label),
(TextInput, Role::TextInput),
(TableRow, Role::Row),
(TableCell, Role::Cell),
(ScrollView, Role::ScrollView),
(Image, Role::Image),
(Unknown, Role::Unknown),
(Checkbox, Role::CheckBox),
(Slider, Role::Slider),
(ProgressBar, Role::ProgressIndicator),
(Tab, Role::Tab),
(TabPanel, Role::TabPanel),
(Menu, Role::Menu),
(MenuItem, Role::MenuItem),
(Dialog, Role::Dialog),
(Alert, Role::Alert),
(Tooltip, Role::Tooltip),
(Tree, Role::Tree),
(TreeItem, Role::TreeItem),
(ListItem, Role::ListItem),
(Link, Role::Link),
(Banner, Role::Banner),
(Navigation, Role::Navigation),
(Main, Role::Main),
(Complementary, Role::Complementary),
(ContentInfo, Role::ContentInfo),
];
for (widget_role, expected_ak_role) in cases {
let got = Role::from(*widget_role);
assert_eq!(
got, *expected_ak_role,
"WidgetRole::{widget_role:?} should map to {expected_ak_role:?}, got {got:?}"
);
}
}
#[test]
fn node_property_description_survives_roundtrip() {
let node = A11yNodeBuilder::new(nid(1), WidgetRole::Button)
.label("OK")
.description("Confirm the dialog")
.build();
let update = A11yTree::build(&node);
assert_eq!(update.nodes.len(), 1);
let (_, ref ak_node) = update.nodes[0];
assert_eq!(ak_node.description(), Some("Confirm the dialog"));
}
#[test]
fn node_property_range_survives_roundtrip() {
let node = A11yNodeBuilder::new(nid(2), WidgetRole::Slider)
.value(50.0, 0.0, 100.0, 1.0)
.build();
let update = A11yTree::build(&node);
let (_, ref ak_node) = update.nodes[0];
assert_eq!(ak_node.numeric_value(), Some(50.0));
assert_eq!(ak_node.min_numeric_value(), Some(0.0));
assert_eq!(ak_node.max_numeric_value(), Some(100.0));
assert_eq!(ak_node.numeric_value_step(), Some(1.0));
}
#[test]
fn relationship_labelled_by_propagated() {
let label_id = nid(10);
let button_id = nid(11);
let node = A11yNodeBuilder::new(button_id, WidgetRole::Button)
.labelled_by([label_id])
.build();
let update = A11yTree::build(&node);
let (_, ref ak_node) = update.nodes[0];
assert!(
ak_node.labelled_by().contains(&label_id),
"labelled_by should contain the label node id"
);
}
#[test]
fn tree_diff_add_child_produces_new_node() {
let mut old_tree = A11yTree::default();
let root_only = A11yNode::simple(nid(100), WidgetRole::Window, None);
old_tree.build_and_store(&root_only);
let mut new_tree = A11yTree::default();
let mut root_with_child = A11yNode::simple(nid(100), WidgetRole::Window, None);
root_with_child.children.push(A11yNode::simple(
nid(101),
WidgetRole::Button,
Some("X".into()),
));
new_tree.build_and_store(&root_with_child);
let delta = A11yTree::diff(&old_tree, &new_tree);
let ids: Vec<NodeId> = delta.nodes.iter().map(|(id, _)| *id).collect();
assert!(
ids.contains(&nid(101)),
"diff should include the new child node"
);
}
#[test]
fn tree_diff_no_change_empty_delta() {
let mut tree_a = A11yTree::default();
let root = A11yNode::simple(nid(200), WidgetRole::Window, Some("App".into()));
tree_a.build_and_store(&root);
let mut tree_b = A11yTree::default();
let root2 = A11yNode::simple(nid(200), WidgetRole::Window, Some("App".into()));
tree_b.build_and_store(&root2);
let delta = A11yTree::diff(&tree_a, &tree_b);
assert!(
delta.nodes.is_empty(),
"identical trees should produce an empty delta"
);
}
#[test]
fn tree_diff_changed_prop_includes_modified_node() {
let mut tree_a = A11yTree::default();
let root_a = A11yNode::simple(nid(300), WidgetRole::Button, Some("Old".into()));
tree_a.build_and_store(&root_a);
let mut tree_b = A11yTree::default();
let root_b = A11yNode::simple(nid(300), WidgetRole::Button, Some("New".into()));
tree_b.build_and_store(&root_b);
let delta = A11yTree::diff(&tree_a, &tree_b);
assert_eq!(
delta.nodes.len(),
1,
"only the changed node should appear in the delta"
);
assert_eq!(delta.nodes[0].0, nid(300));
}
#[test]
fn focus_set_get_roundtrip() {
let mut tree = A11yTree::default();
assert_eq!(tree.focus(), None);
tree.set_focus(Some(nid(42)));
assert_eq!(tree.focus(), Some(nid(42)));
tree.set_focus(None);
assert_eq!(tree.focus(), None);
}
#[test]
fn focus_in_update_reflects_set_focus() {
let mut tree = A11yTree::default();
tree.set_focus(Some(nid(77)));
let upd = tree.focus_update();
assert_eq!(upd.focus, nid(77));
assert!(upd.nodes.is_empty());
}
#[test]
fn live_region_announce_id_in_tree() {
let mut tree = A11yTree::default();
let root = A11yNode::simple(nid(500), WidgetRole::Window, None);
tree.build_and_store(&root);
let ann_id = tree.announce("File saved", LiveSetting::Polite);
let ids: Vec<NodeId> = tree.snapshot.iter().map(|(id, _)| *id).collect();
assert!(
ids.contains(&ann_id),
"announced id must be in the snapshot"
);
}
#[test]
fn widget_role_display_non_empty() {
use WidgetRole::*;
let roles = [
Window,
Group,
Button,
Label,
TextInput,
TableRow,
TableCell,
ScrollView,
Image,
Unknown,
Checkbox,
Slider,
ProgressBar,
Tab,
TabPanel,
Menu,
MenuItem,
Dialog,
Alert,
Tooltip,
Tree,
TreeItem,
ListItem,
Link,
Banner,
Navigation,
Main,
Complementary,
ContentInfo,
];
for role in roles {
let s = role.to_string();
assert!(
!s.is_empty(),
"WidgetRole::{role:?} Display must not be empty"
);
}
}
#[test]
fn builder_roundtrip_description() {
let node = A11yNodeBuilder::new(nid(1000), WidgetRole::Button)
.description("click me")
.build();
assert_eq!(node.props.description, Some("click me".to_string()));
}
#[test]
fn builder_roundtrip_placeholder() {
let node = A11yNodeBuilder::new(nid(1001), WidgetRole::TextInput)
.placeholder("Enter text")
.build();
assert_eq!(node.props.placeholder, Some("Enter text".to_string()));
}
#[test]
fn builder_roundtrip_key_shortcut() {
let node = A11yNodeBuilder::new(nid(1002), WidgetRole::Button)
.key_shortcut("Ctrl+S")
.build();
assert_eq!(node.props.key_shortcut, Some("Ctrl+S".to_string()));
}
#[test]
fn builder_roundtrip_disabled() {
let node = A11yNodeBuilder::new(nid(1003), WidgetRole::Button)
.disabled()
.build();
assert!(node.props.disabled);
}
#[test]
fn builder_roundtrip_expanded() {
let node = A11yNodeBuilder::new(nid(1004), WidgetRole::TreeItem)
.expanded(true)
.build();
assert_eq!(node.props.expanded, Some(true));
}
#[test]
fn builder_roundtrip_selected() {
let node = A11yNodeBuilder::new(nid(1005), WidgetRole::ListItem)
.selected(true)
.build();
assert_eq!(node.props.selected, Some(true));
}
#[test]
fn builder_roundtrip_checked() {
let node = A11yNodeBuilder::new(nid(1006), WidgetRole::Checkbox)
.checked(CheckedState::Mixed)
.build();
assert_eq!(node.props.checked, Some(CheckedState::Mixed));
}
#[test]
fn builder_roundtrip_value() {
let node = A11yNodeBuilder::new(nid(1007), WidgetRole::Slider)
.value(25.0, 0.0, 50.0, 0.5)
.build();
assert_eq!(node.props.value_now, Some(25.0));
assert_eq!(node.props.value_min, Some(0.0));
assert_eq!(node.props.value_max, Some(50.0));
assert_eq!(node.props.value_step, Some(0.5));
}
#[test]
fn builder_roundtrip_text() {
let node = A11yNodeBuilder::new(nid(1008), WidgetRole::TextInput)
.text("hello world")
.build();
assert_eq!(node.text_content, Some("hello world".to_string()));
}
#[test]
fn builder_roundtrip_labelled_by() {
let node = A11yNodeBuilder::new(nid(1009), WidgetRole::Button)
.labelled_by([nid(2000), nid(2001)])
.build();
assert_eq!(node.props.labelled_by, vec![nid(2000), nid(2001)]);
}
#[test]
fn large_tree_smoke_under_100ms() {
const N: u64 = 1_000;
let mut root = A11yNode::simple(nid(0), WidgetRole::Window, None);
for i in 1..N {
root.children.push(A11yNode::simple(
nid(i),
WidgetRole::Button,
Some(format!("Button {i}")),
));
}
let start = Instant::now();
let update = A11yTree::build(&root);
let elapsed = start.elapsed();
assert_eq!(update.nodes.len(), N as usize);
assert!(
elapsed.as_millis() < 100,
"1000-node tree build took {}ms (limit 100ms)",
elapsed.as_millis()
);
}
#[test]
fn node_property_placeholder_propagated() {
let node = A11yNodeBuilder::new(nid(3000), WidgetRole::TextInput)
.placeholder("Type here…")
.build();
let update = A11yTree::build(&node);
let (_, ref ak_node) = update.nodes[0];
assert_eq!(ak_node.placeholder(), Some("Type here…"));
}
#[test]
fn node_property_disabled_propagated() {
let node = A11yNodeBuilder::new(nid(3001), WidgetRole::Button)
.disabled()
.build();
let update = A11yTree::build(&node);
let (_, ref ak_node) = update.nodes[0];
assert!(ak_node.is_disabled());
}
#[test]
fn test_text_run_no_selection_one_child() {
let children = synthesize_text_run_children("hello", None);
assert_eq!(children.len(), 1);
assert_eq!(children[0].text, "hello");
assert_eq!(children[0].char_offset, 0);
assert_eq!(children[0].byte_offset, 0);
assert!(!children[0].is_selected);
}
#[test]
fn test_text_run_with_selection_three_children() {
use crate::props::TextSelection;
let sel = TextSelection {
anchor: 1,
focus: 3,
};
let children = synthesize_text_run_children("hello", Some(&sel));
assert_eq!(children.len(), 3, "expected 3 segments, got: {children:?}");
assert_eq!(children[0].text, "h");
assert!(!children[0].is_selected);
assert_eq!(children[1].text, "el");
assert!(children[1].is_selected);
assert_eq!(children[2].text, "lo");
assert!(!children[2].is_selected);
}
#[test]
fn test_table_cell_carries_row_col() {
let cell = table_cell_node(nid(1), 2, 4, "data");
assert_eq!(cell.text_content.as_deref(), Some("data"));
let desc = cell.props.description.as_deref().unwrap_or("");
assert!(
desc.contains("Row 3"),
"description should contain Row 3, got: {desc}"
);
assert!(
desc.contains("Column 5"),
"description should contain Column 5, got: {desc}"
);
}
#[test]
fn test_table_row_node_description() {
let row = table_row_node(nid(10), 0);
let desc = row.props.description.as_deref().unwrap_or("");
assert!(desc.contains("Row 1"), "expected 'Row 1', got: {desc}");
}
#[test]
fn test_column_header_label() {
let hdr = column_header_node(nid(20), 2, "Name");
assert_eq!(hdr.label.as_deref(), Some("Name"));
let desc = hdr.props.description.as_deref().unwrap_or("");
assert!(
desc.contains("Column 3"),
"expected 'Column 3', got: {desc}"
);
}
#[test]
fn test_os_prefs_default_false() {
let prefs = OsA11yPrefs::query();
let default_prefs = OsA11yPrefs::default();
assert!(!default_prefs.high_contrast);
assert!(!default_prefs.reduced_motion);
let _ = prefs;
}
#[test]
fn test_os_prefs_reads_env_var() {
let prefs = OsA11yPrefs::query_from(|name| {
if name == "OXIUI_HIGH_CONTRAST" {
Some("1".to_string())
} else {
None
}
});
assert!(
prefs.high_contrast,
"OXIUI_HIGH_CONTRAST=1 should set high_contrast=true"
);
assert!(!prefs.reduced_motion);
}
#[test]
fn test_forest_two_trees_isolated() {
let id_a = WindowA11yId(1);
let id_b = WindowA11yId(2);
let mut forest = A11yForest::new();
let mut tree_a = A11yTree::default();
let root_a = A11yNode::simple(nid(100), WidgetRole::Window, Some("Window A".into()));
tree_a.build_and_store(&root_a);
let mut tree_b = A11yTree::default();
let root_b = A11yNode::simple(nid(200), WidgetRole::Window, Some("Window B".into()));
tree_b.build_and_store(&root_b);
forest.insert(id_a, tree_a);
forest.insert(id_b, tree_b);
let a_root = forest.get(id_a).and_then(|t| t.root_id);
let b_root = forest.get(id_b).and_then(|t| t.root_id);
assert_eq!(a_root, Some(nid(100)));
assert_eq!(b_root, Some(nid(200)));
assert_ne!(a_root, b_root, "two windows must have independent root ids");
forest.remove(id_a);
assert!(forest.get(id_a).is_none());
assert!(forest.get(id_b).is_some());
}
#[test]
fn test_builder_tab_index() {
let node = A11yNodeBuilder::new(nid(9000), WidgetRole::Button)
.label("Submit")
.tab_index(2)
.build();
assert_eq!(node.props.tab_index, Some(2));
}
#[test]
fn test_os_a11y_prefs_still_works() {
let prefs = OsA11yPrefs::default();
let _hc: bool = prefs.high_contrast;
let _rm: bool = prefs.reduced_motion;
}
#[test]
fn test_build_table_a11y_structure() {
let table = build_table_a11y(2, 3, &["Col A", "Col B", "Col C"]);
assert_eq!(
table.children.len(),
5,
"expected 3 column-headers + 2 rows = 5 children, got {}",
table.children.len()
);
for (i, child) in table.children.iter().take(3).enumerate() {
assert_eq!(
child.role,
WidgetRole::ColumnHeader,
"child[{i}] should be ColumnHeader"
);
}
for (i, child) in table.children.iter().skip(3).enumerate() {
assert_eq!(
child.role,
WidgetRole::TableRow,
"row child[{i}] should be TableRow"
);
assert_eq!(child.children.len(), 3, "row {i} should have 3 cells");
for (j, cell) in child.children.iter().enumerate() {
assert_eq!(
cell.role,
WidgetRole::TableCell,
"row {i} cell {j} should be TableCell"
);
}
}
}
#[test]
fn test_a11y_forest_multi_window() {
let id1 = WindowA11yId(1);
let id2 = WindowA11yId(2);
let mut forest = A11yForest::default();
forest.register(id1, A11yTree::default());
forest.register(id2, A11yTree::default());
assert!(forest.get(id1).is_some(), "id1 should be present");
assert!(forest.get(id2).is_some(), "id2 should be present");
forest.unregister(id1);
assert!(forest.get(id1).is_none(), "id1 should be removed");
assert!(forest.get(id2).is_some(), "id2 should remain");
assert_eq!(forest.windows().count(), 1, "one window should remain");
}
#[test]
fn test_a11y_forest_windows_iter() {
let mut forest = A11yForest::default();
forest.register(WindowA11yId(10), A11yTree::default());
forest.register(WindowA11yId(20), A11yTree::default());
forest.register(WindowA11yId(30), A11yTree::default());
assert_eq!(
forest.windows().count(),
3,
"windows() should yield all 3 registered ids"
);
}
}