1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3pub mod action;
34pub mod builder;
35pub mod dirty;
36pub mod focus;
37pub mod nav;
38pub mod pool;
39pub mod props;
40pub mod text_a11y;
41#[cfg(feature = "text-bridge")]
42pub mod text_bridge;
43pub mod tree;
44pub mod widget_bridge;
45
46pub use action::{map_action, A11yAction, ActionDispatcher};
47pub use builder::A11yNodeBuilder;
48pub use dirty::{DirtyTracker, Lazy};
49pub use focus::{FocusIndicator, FocusRing};
50pub use nav::{tab_next, tab_prev, TabOrder};
51pub use pool::NodePool;
52pub use props::{
53 byte_offset_to_char_index, character_lengths_utf8, A11yNodeProps, CheckedState, LiveSetting,
54 TextCaret, TextRunChild, TextSelection, Toggled3,
55};
56pub use tree::{
57 build_table_a11y, column_header_node, synthesize_text_run_children, table_cell_node,
58 table_row_node, A11yNode, A11yTree, WidgetRole,
59};
60pub use widget_bridge::{
61 build_a11y_tree, core_role_to_widget_role, widget_to_a11y_node, A11yWidgetNode, NodeIdAllocator,
62};
63
64#[derive(Debug, Clone, Default)]
72pub struct OsA11yPrefs {
73 pub high_contrast: bool,
78 pub reduced_motion: bool,
83}
84
85impl OsA11yPrefs {
86 pub fn query() -> Self {
92 Self::query_from(|name| std::env::var(name).ok())
93 }
94
95 pub fn query_from<F>(lookup: F) -> Self
101 where
102 F: Fn(&str) -> Option<String>,
103 {
104 let high_contrast = lookup("OXIUI_HIGH_CONTRAST")
105 .map(|v| !v.is_empty())
106 .unwrap_or(false);
107 let reduced_motion = lookup("OXIUI_REDUCED_MOTION")
108 .map(|v| !v.is_empty())
109 .unwrap_or(false);
110 Self {
111 high_contrast,
112 reduced_motion,
113 }
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123pub struct WindowA11yId(pub u64);
124
125#[derive(Default)]
130pub struct A11yForest {
131 trees: std::collections::HashMap<WindowA11yId, A11yTree>,
132}
133
134impl A11yForest {
135 pub fn new() -> Self {
137 Self {
138 trees: std::collections::HashMap::new(),
139 }
140 }
141
142 pub fn insert(&mut self, id: WindowA11yId, tree: A11yTree) {
144 self.trees.insert(id, tree);
145 }
146
147 pub fn get(&self, id: WindowA11yId) -> Option<&A11yTree> {
149 self.trees.get(&id)
150 }
151
152 pub fn get_mut(&mut self, id: WindowA11yId) -> Option<&mut A11yTree> {
154 self.trees.get_mut(&id)
155 }
156
157 pub fn remove(&mut self, id: WindowA11yId) -> Option<A11yTree> {
159 self.trees.remove(&id)
160 }
161
162 pub fn iter(&self) -> impl Iterator<Item = (WindowA11yId, &A11yTree)> {
164 self.trees.iter().map(|(k, v)| (*k, v))
165 }
166
167 pub fn register(&mut self, id: WindowA11yId, tree: A11yTree) {
173 self.trees.insert(id, tree);
174 }
175
176 pub fn unregister(&mut self, id: WindowA11yId) {
180 self.trees.remove(&id);
181 }
182
183 pub fn windows(&self) -> impl Iterator<Item = WindowA11yId> + '_ {
185 self.trees.keys().copied()
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use accesskit::{NodeId, Role};
193 use std::time::Instant;
194
195 fn nid(n: u64) -> NodeId {
196 NodeId(n)
197 }
198
199 #[test]
202 fn widget_role_to_accesskit_role_all_variants() {
203 use WidgetRole::*;
204
205 let cases: &[(WidgetRole, Role)] = &[
206 (Window, Role::Window),
207 (Group, Role::Group),
208 (Button, Role::Button),
209 (Label, Role::Label),
210 (TextInput, Role::TextInput),
211 (TableRow, Role::Row),
212 (TableCell, Role::Cell),
213 (ScrollView, Role::ScrollView),
214 (Image, Role::Image),
215 (Unknown, Role::Unknown),
216 (Checkbox, Role::CheckBox),
217 (Slider, Role::Slider),
218 (ProgressBar, Role::ProgressIndicator),
219 (Tab, Role::Tab),
220 (TabPanel, Role::TabPanel),
221 (Menu, Role::Menu),
222 (MenuItem, Role::MenuItem),
223 (Dialog, Role::Dialog),
224 (Alert, Role::Alert),
225 (Tooltip, Role::Tooltip),
226 (Tree, Role::Tree),
227 (TreeItem, Role::TreeItem),
228 (ListItem, Role::ListItem),
229 (Link, Role::Link),
230 (Banner, Role::Banner),
231 (Navigation, Role::Navigation),
232 (Main, Role::Main),
233 (Complementary, Role::Complementary),
234 (ContentInfo, Role::ContentInfo),
235 ];
236
237 for (widget_role, expected_ak_role) in cases {
238 let got = Role::from(*widget_role);
239 assert_eq!(
240 got, *expected_ak_role,
241 "WidgetRole::{widget_role:?} should map to {expected_ak_role:?}, got {got:?}"
242 );
243 }
244 }
245
246 #[test]
249 fn node_property_description_survives_roundtrip() {
250 let node = A11yNodeBuilder::new(nid(1), WidgetRole::Button)
251 .label("OK")
252 .description("Confirm the dialog")
253 .build();
254
255 let update = A11yTree::build(&node);
256 assert_eq!(update.nodes.len(), 1);
257 let (_, ref ak_node) = update.nodes[0];
258 assert_eq!(ak_node.description(), Some("Confirm the dialog"));
259 }
260
261 #[test]
264 fn node_property_range_survives_roundtrip() {
265 let node = A11yNodeBuilder::new(nid(2), WidgetRole::Slider)
266 .value(50.0, 0.0, 100.0, 1.0)
267 .build();
268
269 let update = A11yTree::build(&node);
270 let (_, ref ak_node) = update.nodes[0];
271 assert_eq!(ak_node.numeric_value(), Some(50.0));
272 assert_eq!(ak_node.min_numeric_value(), Some(0.0));
273 assert_eq!(ak_node.max_numeric_value(), Some(100.0));
274 assert_eq!(ak_node.numeric_value_step(), Some(1.0));
275 }
276
277 #[test]
280 fn relationship_labelled_by_propagated() {
281 let label_id = nid(10);
282 let button_id = nid(11);
283
284 let node = A11yNodeBuilder::new(button_id, WidgetRole::Button)
285 .labelled_by([label_id])
286 .build();
287
288 let update = A11yTree::build(&node);
289 let (_, ref ak_node) = update.nodes[0];
290 assert!(
291 ak_node.labelled_by().contains(&label_id),
292 "labelled_by should contain the label node id"
293 );
294 }
295
296 #[test]
299 fn tree_diff_add_child_produces_new_node() {
300 let mut old_tree = A11yTree::default();
301 let root_only = A11yNode::simple(nid(100), WidgetRole::Window, None);
302 old_tree.build_and_store(&root_only);
303
304 let mut new_tree = A11yTree::default();
305 let mut root_with_child = A11yNode::simple(nid(100), WidgetRole::Window, None);
306 root_with_child.children.push(A11yNode::simple(
307 nid(101),
308 WidgetRole::Button,
309 Some("X".into()),
310 ));
311 new_tree.build_and_store(&root_with_child);
312
313 let delta = A11yTree::diff(&old_tree, &new_tree);
314 let ids: Vec<NodeId> = delta.nodes.iter().map(|(id, _)| *id).collect();
316 assert!(
317 ids.contains(&nid(101)),
318 "diff should include the new child node"
319 );
320 }
321
322 #[test]
325 fn tree_diff_no_change_empty_delta() {
326 let mut tree_a = A11yTree::default();
327 let root = A11yNode::simple(nid(200), WidgetRole::Window, Some("App".into()));
328 tree_a.build_and_store(&root);
329
330 let mut tree_b = A11yTree::default();
331 let root2 = A11yNode::simple(nid(200), WidgetRole::Window, Some("App".into()));
332 tree_b.build_and_store(&root2);
333
334 let delta = A11yTree::diff(&tree_a, &tree_b);
335 assert!(
336 delta.nodes.is_empty(),
337 "identical trees should produce an empty delta"
338 );
339 }
340
341 #[test]
344 fn tree_diff_changed_prop_includes_modified_node() {
345 let mut tree_a = A11yTree::default();
346 let root_a = A11yNode::simple(nid(300), WidgetRole::Button, Some("Old".into()));
347 tree_a.build_and_store(&root_a);
348
349 let mut tree_b = A11yTree::default();
350 let root_b = A11yNode::simple(nid(300), WidgetRole::Button, Some("New".into()));
351 tree_b.build_and_store(&root_b);
352
353 let delta = A11yTree::diff(&tree_a, &tree_b);
354 assert_eq!(
355 delta.nodes.len(),
356 1,
357 "only the changed node should appear in the delta"
358 );
359 assert_eq!(delta.nodes[0].0, nid(300));
360 }
361
362 #[test]
365 fn focus_set_get_roundtrip() {
366 let mut tree = A11yTree::default();
367 assert_eq!(tree.focus(), None);
368
369 tree.set_focus(Some(nid(42)));
370 assert_eq!(tree.focus(), Some(nid(42)));
371
372 tree.set_focus(None);
373 assert_eq!(tree.focus(), None);
374 }
375
376 #[test]
379 fn focus_in_update_reflects_set_focus() {
380 let mut tree = A11yTree::default();
381 tree.set_focus(Some(nid(77)));
382
383 let upd = tree.focus_update();
384 assert_eq!(upd.focus, nid(77));
385 assert!(upd.nodes.is_empty());
386 }
387
388 #[test]
391 fn live_region_announce_id_in_tree() {
392 let mut tree = A11yTree::default();
393 let root = A11yNode::simple(nid(500), WidgetRole::Window, None);
394 tree.build_and_store(&root);
395
396 let ann_id = tree.announce("File saved", LiveSetting::Polite);
397
398 let ids: Vec<NodeId> = tree.snapshot.iter().map(|(id, _)| *id).collect();
400 assert!(
401 ids.contains(&ann_id),
402 "announced id must be in the snapshot"
403 );
404 }
405
406 #[test]
409 fn widget_role_display_non_empty() {
410 use WidgetRole::*;
411 let roles = [
412 Window,
413 Group,
414 Button,
415 Label,
416 TextInput,
417 TableRow,
418 TableCell,
419 ScrollView,
420 Image,
421 Unknown,
422 Checkbox,
423 Slider,
424 ProgressBar,
425 Tab,
426 TabPanel,
427 Menu,
428 MenuItem,
429 Dialog,
430 Alert,
431 Tooltip,
432 Tree,
433 TreeItem,
434 ListItem,
435 Link,
436 Banner,
437 Navigation,
438 Main,
439 Complementary,
440 ContentInfo,
441 ];
442 for role in roles {
443 let s = role.to_string();
444 assert!(
445 !s.is_empty(),
446 "WidgetRole::{role:?} Display must not be empty"
447 );
448 }
449 }
450
451 #[test]
454 fn builder_roundtrip_description() {
455 let node = A11yNodeBuilder::new(nid(1000), WidgetRole::Button)
456 .description("click me")
457 .build();
458
459 assert_eq!(node.props.description, Some("click me".to_string()));
460 }
461
462 #[test]
463 fn builder_roundtrip_placeholder() {
464 let node = A11yNodeBuilder::new(nid(1001), WidgetRole::TextInput)
465 .placeholder("Enter text")
466 .build();
467 assert_eq!(node.props.placeholder, Some("Enter text".to_string()));
468 }
469
470 #[test]
471 fn builder_roundtrip_key_shortcut() {
472 let node = A11yNodeBuilder::new(nid(1002), WidgetRole::Button)
473 .key_shortcut("Ctrl+S")
474 .build();
475 assert_eq!(node.props.key_shortcut, Some("Ctrl+S".to_string()));
476 }
477
478 #[test]
479 fn builder_roundtrip_disabled() {
480 let node = A11yNodeBuilder::new(nid(1003), WidgetRole::Button)
481 .disabled()
482 .build();
483 assert!(node.props.disabled);
484 }
485
486 #[test]
487 fn builder_roundtrip_expanded() {
488 let node = A11yNodeBuilder::new(nid(1004), WidgetRole::TreeItem)
489 .expanded(true)
490 .build();
491 assert_eq!(node.props.expanded, Some(true));
492 }
493
494 #[test]
495 fn builder_roundtrip_selected() {
496 let node = A11yNodeBuilder::new(nid(1005), WidgetRole::ListItem)
497 .selected(true)
498 .build();
499 assert_eq!(node.props.selected, Some(true));
500 }
501
502 #[test]
503 fn builder_roundtrip_checked() {
504 let node = A11yNodeBuilder::new(nid(1006), WidgetRole::Checkbox)
505 .checked(CheckedState::Mixed)
506 .build();
507 assert_eq!(node.props.checked, Some(CheckedState::Mixed));
508 }
509
510 #[test]
511 fn builder_roundtrip_value() {
512 let node = A11yNodeBuilder::new(nid(1007), WidgetRole::Slider)
513 .value(25.0, 0.0, 50.0, 0.5)
514 .build();
515 assert_eq!(node.props.value_now, Some(25.0));
516 assert_eq!(node.props.value_min, Some(0.0));
517 assert_eq!(node.props.value_max, Some(50.0));
518 assert_eq!(node.props.value_step, Some(0.5));
519 }
520
521 #[test]
522 fn builder_roundtrip_text() {
523 let node = A11yNodeBuilder::new(nid(1008), WidgetRole::TextInput)
524 .text("hello world")
525 .build();
526 assert_eq!(node.text_content, Some("hello world".to_string()));
527 }
528
529 #[test]
530 fn builder_roundtrip_labelled_by() {
531 let node = A11yNodeBuilder::new(nid(1009), WidgetRole::Button)
532 .labelled_by([nid(2000), nid(2001)])
533 .build();
534 assert_eq!(node.props.labelled_by, vec![nid(2000), nid(2001)]);
535 }
536
537 #[test]
540 fn large_tree_smoke_under_100ms() {
541 const N: u64 = 1_000;
542
543 let mut root = A11yNode::simple(nid(0), WidgetRole::Window, None);
545 for i in 1..N {
546 root.children.push(A11yNode::simple(
547 nid(i),
548 WidgetRole::Button,
549 Some(format!("Button {i}")),
550 ));
551 }
552
553 let start = Instant::now();
554 let update = A11yTree::build(&root);
555 let elapsed = start.elapsed();
556
557 assert_eq!(update.nodes.len(), N as usize);
558 assert!(
559 elapsed.as_millis() < 100,
560 "1000-node tree build took {}ms (limit 100ms)",
561 elapsed.as_millis()
562 );
563 }
564
565 #[test]
568 fn node_property_placeholder_propagated() {
569 let node = A11yNodeBuilder::new(nid(3000), WidgetRole::TextInput)
570 .placeholder("Type here…")
571 .build();
572
573 let update = A11yTree::build(&node);
574 let (_, ref ak_node) = update.nodes[0];
575 assert_eq!(ak_node.placeholder(), Some("Type here…"));
576 }
577
578 #[test]
581 fn node_property_disabled_propagated() {
582 let node = A11yNodeBuilder::new(nid(3001), WidgetRole::Button)
583 .disabled()
584 .build();
585
586 let update = A11yTree::build(&node);
587 let (_, ref ak_node) = update.nodes[0];
588 assert!(ak_node.is_disabled());
589 }
590
591 #[test]
596 fn test_text_run_no_selection_one_child() {
597 let children = synthesize_text_run_children("hello", None);
598 assert_eq!(children.len(), 1);
599 assert_eq!(children[0].text, "hello");
600 assert_eq!(children[0].char_offset, 0);
601 assert_eq!(children[0].byte_offset, 0);
602 assert!(!children[0].is_selected);
603 }
604
605 #[test]
606 fn test_text_run_with_selection_three_children() {
607 use crate::props::TextSelection;
608 let sel = TextSelection {
610 anchor: 1,
611 focus: 3,
612 };
613 let children = synthesize_text_run_children("hello", Some(&sel));
614 assert_eq!(children.len(), 3, "expected 3 segments, got: {children:?}");
616 assert_eq!(children[0].text, "h");
617 assert!(!children[0].is_selected);
618 assert_eq!(children[1].text, "el");
619 assert!(children[1].is_selected);
620 assert_eq!(children[2].text, "lo");
621 assert!(!children[2].is_selected);
622 }
623
624 #[test]
627 fn test_table_cell_carries_row_col() {
628 let cell = table_cell_node(nid(1), 2, 4, "data");
629 assert_eq!(cell.text_content.as_deref(), Some("data"));
630 let desc = cell.props.description.as_deref().unwrap_or("");
631 assert!(
632 desc.contains("Row 3"),
633 "description should contain Row 3, got: {desc}"
634 );
635 assert!(
636 desc.contains("Column 5"),
637 "description should contain Column 5, got: {desc}"
638 );
639 }
640
641 #[test]
642 fn test_table_row_node_description() {
643 let row = table_row_node(nid(10), 0);
644 let desc = row.props.description.as_deref().unwrap_or("");
645 assert!(desc.contains("Row 1"), "expected 'Row 1', got: {desc}");
646 }
647
648 #[test]
649 fn test_column_header_label() {
650 let hdr = column_header_node(nid(20), 2, "Name");
651 assert_eq!(hdr.label.as_deref(), Some("Name"));
652 let desc = hdr.props.description.as_deref().unwrap_or("");
653 assert!(
654 desc.contains("Column 3"),
655 "expected 'Column 3', got: {desc}"
656 );
657 }
658
659 #[test]
662 fn test_os_prefs_default_false() {
663 let prefs = OsA11yPrefs::query();
670 let default_prefs = OsA11yPrefs::default();
672 assert!(!default_prefs.high_contrast);
673 assert!(!default_prefs.reduced_motion);
674 let _ = prefs;
676 }
677
678 #[test]
679 fn test_os_prefs_reads_env_var() {
680 let prefs = OsA11yPrefs::query_from(|name| {
682 if name == "OXIUI_HIGH_CONTRAST" {
683 Some("1".to_string())
684 } else {
685 None
686 }
687 });
688 assert!(
689 prefs.high_contrast,
690 "OXIUI_HIGH_CONTRAST=1 should set high_contrast=true"
691 );
692 assert!(!prefs.reduced_motion);
693 }
694
695 #[test]
698 fn test_forest_two_trees_isolated() {
699 let id_a = WindowA11yId(1);
700 let id_b = WindowA11yId(2);
701
702 let mut forest = A11yForest::new();
703
704 let mut tree_a = A11yTree::default();
705 let root_a = A11yNode::simple(nid(100), WidgetRole::Window, Some("Window A".into()));
706 tree_a.build_and_store(&root_a);
707
708 let mut tree_b = A11yTree::default();
709 let root_b = A11yNode::simple(nid(200), WidgetRole::Window, Some("Window B".into()));
710 tree_b.build_and_store(&root_b);
711
712 forest.insert(id_a, tree_a);
713 forest.insert(id_b, tree_b);
714
715 let a_root = forest.get(id_a).and_then(|t| t.root_id);
716 let b_root = forest.get(id_b).and_then(|t| t.root_id);
717
718 assert_eq!(a_root, Some(nid(100)));
719 assert_eq!(b_root, Some(nid(200)));
720 assert_ne!(a_root, b_root, "two windows must have independent root ids");
721
722 forest.remove(id_a);
724 assert!(forest.get(id_a).is_none());
725 assert!(forest.get(id_b).is_some());
726 }
727
728 #[test]
731 fn test_builder_tab_index() {
732 let node = A11yNodeBuilder::new(nid(9000), WidgetRole::Button)
733 .label("Submit")
734 .tab_index(2)
735 .build();
736 assert_eq!(node.props.tab_index, Some(2));
737 }
738
739 #[test]
742 fn test_os_a11y_prefs_still_works() {
743 let prefs = OsA11yPrefs::default();
745 let _hc: bool = prefs.high_contrast;
748 let _rm: bool = prefs.reduced_motion;
749 }
750
751 #[test]
752 fn test_build_table_a11y_structure() {
753 let table = build_table_a11y(2, 3, &["Col A", "Col B", "Col C"]);
754
755 assert_eq!(
757 table.children.len(),
758 5,
759 "expected 3 column-headers + 2 rows = 5 children, got {}",
760 table.children.len()
761 );
762
763 for (i, child) in table.children.iter().take(3).enumerate() {
765 assert_eq!(
766 child.role,
767 WidgetRole::ColumnHeader,
768 "child[{i}] should be ColumnHeader"
769 );
770 }
771
772 for (i, child) in table.children.iter().skip(3).enumerate() {
774 assert_eq!(
775 child.role,
776 WidgetRole::TableRow,
777 "row child[{i}] should be TableRow"
778 );
779 assert_eq!(child.children.len(), 3, "row {i} should have 3 cells");
781 for (j, cell) in child.children.iter().enumerate() {
782 assert_eq!(
783 cell.role,
784 WidgetRole::TableCell,
785 "row {i} cell {j} should be TableCell"
786 );
787 }
788 }
789 }
790
791 #[test]
792 fn test_a11y_forest_multi_window() {
793 let id1 = WindowA11yId(1);
794 let id2 = WindowA11yId(2);
795
796 let mut forest = A11yForest::default();
797
798 forest.register(id1, A11yTree::default());
799 forest.register(id2, A11yTree::default());
800
801 assert!(forest.get(id1).is_some(), "id1 should be present");
802 assert!(forest.get(id2).is_some(), "id2 should be present");
803
804 forest.unregister(id1);
805 assert!(forest.get(id1).is_none(), "id1 should be removed");
806 assert!(forest.get(id2).is_some(), "id2 should remain");
807
808 assert_eq!(forest.windows().count(), 1, "one window should remain");
809 }
810
811 #[test]
812 fn test_a11y_forest_windows_iter() {
813 let mut forest = A11yForest::default();
814 forest.register(WindowA11yId(10), A11yTree::default());
815 forest.register(WindowA11yId(20), A11yTree::default());
816 forest.register(WindowA11yId(30), A11yTree::default());
817
818 assert_eq!(
819 forest.windows().count(),
820 3,
821 "windows() should yield all 3 registered ids"
822 );
823 }
824}