1use super::*;
5
6use crate::accessibility::{AccessNodeBuilder, AccessibilityInfo};
7
8pub(super) fn to_accesskit_rect(rect: teksilo_canvas::Rect) -> accesskit::Rect {
11 accesskit::Rect {
12 x0: rect.x as f64,
13 y0: rect.y as f64,
14 x1: (rect.x + rect.width) as f64,
15 y1: (rect.y + rect.height) as f64,
16 }
17}
18
19impl WidgetTree {
20 pub fn sync_accessibility(&mut self) -> accesskit::TreeUpdate {
31 if self.a11y_update_requested.replace(false) {
36 self.a11y_dirty = true;
37 }
38 let current_shortcut_version = self.shortcut_registry.version().get();
46 if current_shortcut_version != self.last_synced_shortcut_version {
47 self.a11y_dirty = true;
48 self.last_synced_shortcut_version = current_shortcut_version;
49 }
50
51 let current_locale = self.locale_signal.get();
60 if current_locale != self.last_synced_locale {
61 self.a11y_dirty = true;
62 self.last_synced_locale = current_locale;
63 }
64
65 let announcers_busy = self.step_announcers();
73
74 if !self.a11y_dirty
75 && let Some(cached) = &self.cached_a11y
76 {
77 let _ = cached;
90 self.patch_accessibility_bounds();
91 return self
92 .cached_a11y
93 .as_ref()
94 .expect("the cache was present a line ago")
95 .clone();
96 }
97
98 let (update, parents, local_bounds) = self.build_accessibility_tree();
99 self.collect_announcements(&update);
105 let content_changed = self.cached_a11y.as_ref() != Some(&update);
112 self.cached_a11y = Some(update.clone());
113 self.synthetic_parent_map = parents;
114 self.synthetic_local_bounds = local_bounds;
115 self.a11y_dirty = false;
116 self.arena.take_a11y_moved();
119 self.arena.take_a11y_resized();
120 self.a11y_walk_generation = self.a11y_walk_generation.saturating_add(1);
121 if content_changed {
122 self.at_version.set(self.at_version.get().saturating_add(1));
125 }
126 if announcers_busy {
127 self.request_accessibility_update();
128 self.request_frame();
129 }
130 update
131 }
132
133 pub fn a11y_walk_generation(&self) -> u64 {
139 self.a11y_walk_generation
140 }
141
142 fn patch_accessibility_bounds(&mut self) {
154 let moved = self.arena.take_a11y_moved();
155 if moved.is_empty() {
156 return;
157 }
158 let Some(cached) = self.cached_a11y.as_mut() else {
159 return;
160 };
161 let index: std::collections::HashMap<accesskit::NodeId, usize> = cached
162 .nodes
163 .iter()
164 .enumerate()
165 .map(|(i, (id, _))| (*id, i))
166 .collect();
167
168 for &id in moved.keys() {
169 let bounds = self.arena.bounds(id);
170 if let Some(&slot) = index.get(&crate::accessibility::widget_id_to_node_id(id)) {
171 cached.nodes[slot].1.set_bounds(to_accesskit_rect(bounds));
172 }
173 }
174
175 for (&syn_id, &owner) in &self.synthetic_parent_map {
181 let Some(delta) = moved.get(&owner) else {
182 continue;
183 };
184 let Some(&slot) = index.get(&syn_id) else {
185 continue;
186 };
187 let node = &mut cached.nodes[slot].1;
188 if let Some(local) = self.synthetic_local_bounds.get(&syn_id) {
189 let origin = self.arena.bounds(owner).origin();
190 node.set_bounds(to_accesskit_rect(teksilo_canvas::Rect::new(
191 origin.x + local.x,
192 origin.y + local.y,
193 local.width,
194 local.height,
195 )));
196 } else if let Some(rect) = node.bounds() {
197 node.set_bounds(accesskit::Rect {
198 x0: rect.x0 + delta.x as f64,
199 y0: rect.y0 + delta.y as f64,
200 x1: rect.x1 + delta.x as f64,
201 y1: rect.y1 + delta.y as f64,
202 });
203 }
204 }
205 }
206
207 pub fn accessibility_tree_snapshot(&self) -> accesskit::TreeUpdate {
221 self.build_accessibility_tree().0
222 }
223
224 fn step_announcers(&mut self) -> bool {
227 let polite = self.announcer_polite.step();
231 let assertive = self.announcer_assertive.step();
232 if polite || assertive {
233 self.a11y_dirty = true;
234 }
235 polite || assertive
236 }
237
238 fn collect_announcements(&mut self, update: &accesskit::TreeUpdate) {
244 use accesskit::Live;
245 let mut seen: std::collections::HashSet<accesskit::NodeId> =
246 std::collections::HashSet::with_capacity(self.automation_last_text.len());
247 for (node_id, node) in &update.nodes {
248 let assertive = match node.live() {
249 Some(Live::Polite) => false,
250 Some(Live::Assertive) => true,
251 _ => continue,
253 };
254 let text = node
257 .value()
258 .or_else(|| node.label())
259 .unwrap_or("")
260 .trim()
261 .to_string();
262 if text.is_empty() {
263 self.automation_last_text.remove(node_id);
268 continue;
269 }
270 seen.insert(*node_id);
271 let changed = self
272 .automation_last_text
273 .get(node_id)
274 .map(|prev| prev != &text)
275 .unwrap_or(true);
276 if !changed {
277 continue;
278 }
279 self.automation_last_text.insert(*node_id, text.clone());
280 self.automation_announce_seq = self.automation_announce_seq.saturating_add(1);
281 if self.automation_announcements.len() >= super::AUTOMATION_ANNOUNCE_CAP {
282 self.automation_announcements.pop_front();
283 }
284 self.automation_announcements
285 .push_back(crate::accessibility::Announcement {
286 seq: self.automation_announce_seq,
287 text,
288 assertive,
289 });
290 }
291 self.automation_last_text.retain(|k, _| seen.contains(k));
294 }
295
296 pub fn dispatch_access_action(
319 &mut self,
320 node_id: accesskit::NodeId,
321 action: accesskit::Action,
322 data: Option<accesskit::ActionData>,
323 ops: &mut dyn crate::window::WindowOps,
324 ) -> bool {
325 let target = crate::accessibility::node_id_to_widget_id_maybe(node_id)
326 .or_else(|| self.widget_for_synthetic(node_id));
327 let event = crate::event::WidgetEvent::AccessAction {
328 action,
329 target,
330 target_node: node_id,
331 data,
332 };
333 self.access_action_handled = false;
334 self.dispatch_event_with_ops(event, ops);
335 self.access_action_handled
336 }
337
338 pub fn widget_for_synthetic(&self, node_id: accesskit::NodeId) -> Option<WidgetId> {
346 self.synthetic_parent_map.get(&node_id).copied()
347 }
348
349 pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
367 self.arena
368 .get(content_id)
369 .and_then(|node| node.widget.as_any())
370 .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
371 .and_then(|d| d.materialized_child())
372 .unwrap_or(content_id)
373 }
374
375 pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
376 let content_id = self.tooltip_content_node(content_id);
377 let node = self.arena.get(content_id)?;
378 let mut probe = AccessNodeBuilder::for_name_probe(content_id);
379 node.widget.accessibility(&mut probe);
380 if let Some(ov) = node.access_overrides.as_deref() {
381 ov.apply(&mut probe);
382 }
383 probe
384 .name()
385 .map(str::trim)
386 .filter(|name| !name.is_empty())
387 .map(str::to_owned)
388 }
389
390 pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
391 let node = self.arena.get(id).expect("widget id is active in arena");
392 let builder = self.build_overridden_builder(id);
393 let role = builder.role();
394 let name = builder.name().map(|s| s.to_string());
395 let actions = builder.actions().to_vec();
396 let mut info = AccessibilityInfo::new(role, name, actions);
397 if let Some(toggled) = builder.toggled() {
398 info = info.with_toggled(toggled);
399 }
400 if let Some(expanded) = builder.expanded() {
401 info = info.with_expanded(expanded);
402 }
403 if let Some(selected) = builder.selected() {
404 info = info.with_selected(selected);
405 }
406 let force_clear_disabled =
410 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
411 let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
412 let disabled_override =
417 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(true);
418 if disabled_arena || disabled_override {
419 info = info.with_disabled(true);
420 }
421 if builder.is_hidden() {
422 info = info.with_hidden(true);
423 }
424 info
425 }
426
427 pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
428 self.arena
429 .active_ids_iter()
430 .find(|&id| self.build_overridden_builder(id).role() == role)
431 }
432
433 pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
434 self.arena
435 .active_ids_iter()
436 .find(|&id| self.build_overridden_builder(id).name() == Some(label))
437 }
438
439 pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
440 self.arena.active_ids_iter().find(|&id| {
441 self.build_overridden_builder(id)
442 .actions()
443 .contains(&action)
444 })
445 }
446
447 pub fn text_content(&self, id: WidgetId) -> Option<String> {
451 self.arena.get(id)?;
452 self.build_overridden_builder(id)
453 .name()
454 .map(|s| s.to_string())
455 }
456
457 pub fn text_value(&self, id: WidgetId) -> Option<String> {
461 self.arena.get(id)?;
462 self.build_overridden_builder(id)
463 .value()
464 .map(|s| s.to_string())
465 }
466}
467
468pub(crate) fn emit_root_transform(root: &mut accesskit::Node, device_scale_factor: f32) {
497 root.set_transform(accesskit::Affine::scale(device_scale_factor as f64));
498}
499
500#[cfg(test)]
501pub(crate) mod test_helpers {
502 pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
508 accesskit_consumer::Tree::new(update.clone(), false);
509 }
510
511 pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
520 let emitted: std::collections::HashSet<accesskit::NodeId> =
521 update.nodes.iter().map(|(id, _)| *id).collect();
522 for (parent_id, node) in &update.nodes {
523 for &target in node.controls() {
524 assert!(
525 emitted.contains(&target),
526 "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
527 );
528 }
529 for &target in node.described_by() {
530 assert!(
531 emitted.contains(&target),
532 "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
533 );
534 }
535 for &target in node.labelled_by() {
536 assert!(
537 emitted.contains(&target),
538 "node {parent_id:?} has labelled_by() → {target:?} which is absent from the tree"
539 );
540 }
541 if let Some(target) = node.next_on_line() {
542 assert!(
543 emitted.contains(&target),
544 "node {parent_id:?} has next_on_line() → {target:?} which is absent from the tree"
545 );
546 }
547 if let Some(target) = node.previous_on_line() {
548 assert!(
549 emitted.contains(&target),
550 "node {parent_id:?} has previous_on_line() → {target:?} which is absent from the tree"
551 );
552 }
553 }
554 }
555
556 #[allow(dead_code)]
558 pub(crate) fn nodes_with_role(
559 update: &accesskit::TreeUpdate,
560 role: accesskit::Role,
561 ) -> Vec<accesskit::NodeId> {
562 update
563 .nodes
564 .iter()
565 .filter(|(_, node)| node.role() == role)
566 .map(|(id, _)| *id)
567 .collect()
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::test_helpers::*;
574 use super::*;
575 use crate::test_widgets::{FillWidget, StackWidget};
576
577 const ANNOUNCER_NODES: usize = 2;
581
582 fn announced_by_consumer(update: &accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
598 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
599 let state = consumer.state();
600 let mut found = Vec::new();
601 let mut stack = vec![state.root()];
602 while let Some(node) = stack.pop() {
603 if node.live() != accesskit::Live::Off
604 && let Some(label) = node.label()
605 {
606 found.push((label, node.live()));
607 }
608 for child in node.filtered_children(&accesskit_consumer::common_filter) {
609 stack.push(child);
610 }
611 }
612 found
613 }
614
615 fn live_nodes_in_filtered_tree(update: &accesskit::TreeUpdate) -> Vec<String> {
624 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
625 let state = consumer.state();
626 let mut found = Vec::new();
627 let mut stack = vec![state.root()];
628 while let Some(node) = stack.pop() {
629 if node.live() != accesskit::Live::Off {
630 found.push(format!("{:?}", node.id()));
631 }
632 for child in node.filtered_children(&accesskit_consumer::common_filter) {
633 stack.push(child);
634 }
635 }
636 found.sort();
637 found
638 }
639
640 fn tree_with_one_widget() -> WidgetTree {
641 let mut tree = WidgetTree::new();
642 tree.add(FillWidget::new().label("content"));
643 tree.layout(SizeProposal::exact(200.0, 100.0));
644 tree
645 }
646
647 #[test]
650 fn an_announcement_enters_the_filtered_tree_then_leaves_it() {
651 let mut tree = tree_with_one_widget();
652 let update = tree.sync_accessibility();
655 assert_eq!(announced_by_consumer(&update), Vec::new());
656 assert_eq!(
657 live_nodes_in_filtered_tree(&update),
658 Vec::<String>::new(),
659 "an idle announcer must be outside the filtered tree"
660 );
661 assert_a11y_tree_valid(&update);
662
663 tree.announce("Event added");
664
665 let exposed = tree.sync_accessibility();
666 assert_eq!(
667 announced_by_consumer(&exposed),
668 vec![("Event added".to_string(), accesskit::Live::Polite)],
669 "the message must be a label on a live node inside the filtered tree"
670 );
671 assert_eq!(
672 live_nodes_in_filtered_tree(&exposed).len(),
673 1,
674 "exactly the polite announcer must have entered the filtered tree"
675 );
676 assert_a11y_tree_valid(&exposed);
677
678 let retracted = tree.sync_accessibility();
679 assert_eq!(
680 announced_by_consumer(&retracted),
681 Vec::new(),
682 "the node must stop carrying the message"
683 );
684 assert_eq!(
690 live_nodes_in_filtered_tree(&retracted),
691 Vec::<String>::new(),
692 "the live node must leave the filtered tree entirely, not merely \
693 lose its label"
694 );
695 assert_a11y_tree_valid(&retracted);
696 }
697
698 #[test]
703 fn the_same_message_announced_twice_is_exposed_twice() {
704 let mut tree = tree_with_one_widget();
705 tree.announce("Saved");
706 tree.announce("Saved");
707
708 let mut exposures = 0;
709 for _ in 0..6 {
710 let update = tree.sync_accessibility();
711 if announced_by_consumer(&update)
712 .iter()
713 .any(|(text, _)| text == "Saved")
714 {
715 exposures += 1;
716 }
717 }
718 assert_eq!(exposures, 2, "each announcement needs its own exposure");
719 }
720
721 #[test]
722 fn an_assertive_announcement_uses_the_assertive_live_setting() {
723 let mut tree = tree_with_one_widget();
724 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
725 let update = tree.sync_accessibility();
726 assert_eq!(
727 announced_by_consumer(&update),
728 vec![("Could not save".to_string(), accesskit::Live::Assertive)]
729 );
730 }
731
732 #[test]
735 fn the_two_politeness_levels_announce_independently() {
736 let mut tree = tree_with_one_widget();
737 tree.announce("Event added");
738 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
739
740 let mut spoken = announced_by_consumer(&tree.sync_accessibility());
741 spoken.sort_by(|a, b| a.0.cmp(&b.0));
742 assert_eq!(
743 spoken,
744 vec![
745 ("Could not save".to_string(), accesskit::Live::Assertive),
746 ("Event added".to_string(), accesskit::Live::Polite),
747 ],
748 "both levels must be exposed on the same update"
749 );
750 }
751
752 #[test]
756 fn announcing_requests_the_syncs_it_needs() {
757 let mut tree = tree_with_one_widget();
758 let _ = tree.sync_accessibility();
759 tree.a11y_update_requested.set(false);
760 tree.frame_tick_requested.set(false);
761
762 tree.announce("Event added");
763 assert!(
764 tree.a11y_update_requested.get(),
765 "an announcement must dirty the accessibility tree"
766 );
767 assert!(
768 tree.frame_requested(),
769 "an announcement must ask for the frame that carries it"
770 );
771
772 tree.a11y_update_requested.set(false);
774 tree.frame_tick_requested.set(false);
775 let _ = tree.sync_accessibility();
776 assert!(tree.a11y_update_requested.get());
777 assert!(tree.frame_requested());
778
779 tree.a11y_update_requested.set(false);
781 tree.frame_tick_requested.set(false);
782 let _ = tree.sync_accessibility();
783 assert!(!tree.a11y_update_requested.get());
784 assert!(!tree.frame_requested());
785 }
786
787 #[test]
790 fn an_announcement_reaches_the_automation_ring() {
791 let mut tree = tree_with_one_widget();
792 let before = tree.announcements_since(0).len() as u64;
793 tree.announce("Event added");
794 let _ = tree.sync_accessibility();
795 let got = tree.announcements_since(before);
796 assert_eq!(got.len(), 1);
797 assert_eq!(got[0].text, "Event added");
798 assert!(!got[0].assertive);
799 }
800
801 #[test]
805 fn a_tree_snapshot_does_not_consume_a_pending_announcement() {
806 let mut tree = tree_with_one_widget();
807 tree.announce("Event added");
808
809 let snapshot = tree.accessibility_tree_snapshot();
810 assert_eq!(
811 announced_by_consumer(&snapshot),
812 Vec::new(),
813 "a snapshot must not advance the announcer"
814 );
815
816 let delivered = tree.sync_accessibility();
817 assert_eq!(
818 announced_by_consumer(&delivered),
819 vec![("Event added".to_string(), accesskit::Live::Polite)],
820 "the message must still be there to deliver"
821 );
822 }
823
824 #[test]
827 fn an_empty_announcement_is_not_exposed() {
828 let mut tree = tree_with_one_widget();
829 let _ = tree.sync_accessibility();
830 tree.announce(" ");
831 let update = tree.sync_accessibility();
832 assert_eq!(announced_by_consumer(&update), Vec::new());
833 }
834
835 #[derive(Debug)]
836 struct ActionWidget;
837
838 impl Widget for ActionWidget {
839 fn layout_response(
840 &self,
841 proposal: SizeProposal,
842 _ctx: &LayoutContext,
843 ) -> crate::widget::LayoutResponse {
844 proposal.resolve(0.0, 0.0).into()
845 }
846
847 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
848 builder.set_role(accesskit::Role::Button);
849 builder.set_name("Save");
850 builder.add_action(accesskit::Action::Click);
851 builder.add_action(accesskit::Action::Focus);
852 }
853 }
854
855 #[derive(Debug)]
856 struct ClickableWidget;
857
858 impl Widget for ClickableWidget {
859 fn layout_response(
860 &self,
861 proposal: SizeProposal,
862 _ctx: &LayoutContext,
863 ) -> crate::widget::LayoutResponse {
864 proposal.resolve(0.0, 0.0).into()
865 }
866
867 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
868 builder.set_role(accesskit::Role::Button);
869 builder.set_name("Click Me");
870 builder.add_action(accesskit::Action::Click);
871 }
872 }
873
874 #[test]
879 fn a_focusable_node_advertises_focus_without_the_widget_saying_so() {
880 let mut tree = WidgetTree::new();
881 let id = tree.add(ClickableWidget.focusable(true));
883 tree.layout(SizeProposal::exact(100.0, 100.0));
884
885 let info = tree.accessibility_node(id);
886 assert!(
887 info.actions().contains(&accesskit::Action::Focus),
888 "the arena knows the node is focusable; the AT node must say so"
889 );
890
891 let mut ops = crate::window::NoopWindowOps;
893 assert!(tree.dispatch_access_action(
894 crate::accessibility::widget_id_to_node_id(id),
895 accesskit::Action::Focus,
896 None,
897 &mut ops,
898 ));
899 assert_eq!(tree.focused(), Some(id));
900 }
901
902 #[derive(Debug)]
908 struct CompositeWidget {
909 child_ids: Vec<WidgetId>,
910 }
911
912 impl Widget for CompositeWidget {
913 fn layout_response(
914 &self,
915 proposal: SizeProposal,
916 _ctx: &LayoutContext,
917 ) -> crate::widget::LayoutResponse {
918 proposal.resolve(0.0, 0.0).into()
919 }
920
921 fn place_children(
922 &self,
923 bounds: Rect,
924 _proposal: SizeProposal,
925 children: &mut [crate::widget::WidgetPlacement],
926 _ctx: &LayoutContext,
927 ) {
928 for child in children.iter_mut() {
929 child.origin = bounds.origin();
930 child.size = bounds.size();
931 }
932 }
933
934 fn children(&self) -> Vec<WidgetId> {
935 self.child_ids.clone()
936 }
937
938 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
939 builder.set_role(accesskit::Role::SpinButton);
940 builder.add_action(accesskit::Action::Focus);
941 }
942 }
943
944 #[test]
955 fn an_at_focus_on_a_non_focusable_composite_lands_on_its_focusable_leaf() {
956 let mut tree = WidgetTree::new();
957 let leaf = tree.add(ClickableWidget.focusable(true));
958 let root = tree.add(CompositeWidget {
959 child_ids: vec![leaf],
960 });
961 tree.layout(SizeProposal::exact(100.0, 100.0));
962
963 let mut ops = crate::window::NoopWindowOps;
964 assert!(tree.dispatch_access_action(
965 crate::accessibility::widget_id_to_node_id(root),
966 accesskit::Action::Focus,
967 None,
968 &mut ops,
969 ));
970 assert_eq!(
971 tree.focused(),
972 Some(leaf),
973 "focus must reach the focusable leaf, not the composite root"
974 );
975 }
976
977 #[test]
986 fn an_at_focus_on_a_plain_container_does_not_walk_into_it() {
987 let mut tree = WidgetTree::new();
988 let leaf = tree.add(ClickableWidget.focusable(true));
989 let root = tree.add(StackWidget::new().add_child(leaf));
990 tree.layout(SizeProposal::exact(100.0, 100.0));
991
992 let mut ops = crate::window::NoopWindowOps;
993 assert!(
994 !tree.dispatch_access_action(
995 crate::accessibility::widget_id_to_node_id(root),
996 accesskit::Action::Focus,
997 None,
998 &mut ops,
999 ),
1000 "a node that never advertised `Focus` must report the action unhandled"
1001 );
1002 assert_eq!(
1003 tree.focused(),
1004 None,
1005 "focus must stay where it was, not fall into the container's first control"
1006 );
1007 }
1008
1009 #[test]
1012 fn an_at_focus_on_a_focusable_node_still_lands_on_that_node() {
1013 let mut tree = WidgetTree::new();
1014 let outer = tree.add(ClickableWidget.focusable(true));
1015 tree.layout(SizeProposal::exact(100.0, 100.0));
1016
1017 let mut ops = crate::window::NoopWindowOps;
1018 assert!(tree.dispatch_access_action(
1019 crate::accessibility::widget_id_to_node_id(outer),
1020 accesskit::Action::Focus,
1021 None,
1022 &mut ops,
1023 ));
1024 assert_eq!(tree.focused(), Some(outer));
1025 }
1026
1027 #[test]
1030 fn a_derived_focus_action_is_still_removable_by_an_override() {
1031 let mut tree = WidgetTree::new();
1032 let id = tree.add(
1033 ClickableWidget
1034 .focusable(true)
1035 .access_remove_action(accesskit::Action::Focus),
1036 );
1037 tree.layout(SizeProposal::exact(100.0, 100.0));
1038 assert!(
1039 !tree
1040 .accessibility_node(id)
1041 .actions()
1042 .contains(&accesskit::Action::Focus)
1043 );
1044 }
1045
1046 #[test]
1049 fn a_non_focusable_node_does_not_advertise_focus() {
1050 let mut tree = WidgetTree::new();
1051 let id = tree.add(ClickableWidget);
1052 tree.layout(SizeProposal::exact(100.0, 100.0));
1053 assert!(
1054 !tree
1055 .accessibility_node(id)
1056 .actions()
1057 .contains(&accesskit::Action::Focus)
1058 );
1059 }
1060
1061 #[test]
1062 fn labeled_widget_has_accessibility() {
1063 let mut tree = WidgetTree::new();
1064 let widget = tree.add(FillWidget::new().label("Hello"));
1065 tree.layout(SizeProposal::exact(100.0, 20.0));
1066 let info = tree.accessibility_node(widget);
1067 assert_eq!(info.role(), accesskit::Role::Label);
1068 assert_eq!(info.name(), Some("Hello"));
1069 }
1070
1071 #[test]
1072 fn find_by_label_works() {
1073 let mut tree = WidgetTree::new();
1074 let widget = tree.add(FillWidget::new().label("Save"));
1075 tree.layout(SizeProposal::exact(100.0, 20.0));
1076 assert_eq!(tree.find_by_label("Save"), Some(widget));
1077 }
1078
1079 #[test]
1080 fn label_role_emits_text_as_value_not_label() {
1081 let mut tree = WidgetTree::new();
1088 let widget = tree.add(FillWidget::new().label("Section")); tree.layout(SizeProposal::exact(100.0, 20.0));
1090
1091 assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1093
1094 let update = tree.sync_accessibility();
1096 let node = &update
1097 .nodes
1098 .iter()
1099 .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1100 .expect("label node must be present in the tree update")
1101 .1;
1102 assert_eq!(node.role(), accesskit::Role::Label);
1103 assert_eq!(
1104 node.value(),
1105 Some("Section"),
1106 "Role::Label text must be exposed via `value`"
1107 );
1108 assert_eq!(
1109 node.label(),
1110 None,
1111 "Role::Label must not leave text in the `label` property"
1112 );
1113 }
1114
1115 #[test]
1116 fn find_by_role_works() {
1117 let mut tree = WidgetTree::new();
1118 tree.add(FillWidget::new().label("Text"));
1119 tree.layout(SizeProposal::exact(100.0, 20.0));
1120 assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1121 }
1122
1123 #[test]
1124 fn access_hidden_bound_to_signal_toggles_reactively() {
1125 use crate::signal::Signal;
1126 use crate::widget_builder::WidgetBuilder;
1127 let hidden = Signal::new(false);
1128 let mut tree = WidgetTree::new();
1129 let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1130 tree.layout(SizeProposal::exact(100.0, 30.0));
1131 assert!(
1132 !tree.accessibility_node(w).is_hidden(),
1133 "node should be visible to AT while the signal is false"
1134 );
1135 hidden.set(true);
1136 assert!(
1138 tree.accessibility_node(w).is_hidden(),
1139 "node should hide from AT when the bound signal flips to true"
1140 );
1141 hidden.set(false);
1142 assert!(!tree.accessibility_node(w).is_hidden());
1143 }
1144
1145 #[test]
1149 fn a_widget_with_a_context_menu_advertises_the_action() {
1150 let mut tree = WidgetTree::new();
1151 let plain = tree.add(FillWidget::new().label("no menu"));
1152 let with_menu = tree.add(
1153 FillWidget::new()
1154 .label("has menu")
1155 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1156 );
1157 tree.layout(SizeProposal::exact(100.0, 20.0));
1158
1159 assert!(
1160 tree.accessibility_node(with_menu)
1161 .actions()
1162 .contains(&Action::ShowContextMenu),
1163 "a node owning a context-menu factory must announce it"
1164 );
1165 assert!(
1166 !tree
1167 .accessibility_node(plain)
1168 .actions()
1169 .contains(&Action::ShowContextMenu),
1170 "and a node without one must not"
1171 );
1172 }
1173
1174 #[test]
1177 fn a_removed_context_menu_action_stays_removed() {
1178 let mut tree = WidgetTree::new();
1179 let id = tree.add(
1180 FillWidget::new()
1181 .label("suppressed")
1182 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1183 .access_remove_action(Action::ShowContextMenu),
1184 );
1185 tree.layout(SizeProposal::exact(100.0, 20.0));
1186 assert!(
1187 !tree
1188 .accessibility_node(id)
1189 .actions()
1190 .contains(&Action::ShowContextMenu)
1191 );
1192 }
1193
1194 #[test]
1199 fn a_child_does_not_advertise_its_parents_context_menu() {
1200 let mut tree = WidgetTree::new();
1201 let child = tree.add(FillWidget::new().label("inner"));
1202 let parent = tree.add(
1203 StackWidget::new()
1204 .add_child(child)
1205 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1206 );
1207 tree.layout(SizeProposal::exact(100.0, 20.0));
1208
1209 assert!(
1210 tree.accessibility_node(parent)
1211 .actions()
1212 .contains(&Action::ShowContextMenu)
1213 );
1214 assert!(
1215 !tree
1216 .accessibility_node(child)
1217 .actions()
1218 .contains(&Action::ShowContextMenu),
1219 "the menu belongs to the widget that declared it"
1220 );
1221 }
1222
1223 #[test]
1224 fn accessibility_node_collects_actions() {
1225 let mut tree = WidgetTree::new();
1226 let widget = tree.add(ActionWidget);
1227 tree.layout(SizeProposal::exact(100.0, 40.0));
1228
1229 let info = tree.accessibility_node(widget);
1230 assert_eq!(info.role(), accesskit::Role::Button);
1231 assert_eq!(info.name(), Some("Save"));
1232 assert_eq!(info.actions().len(), 2);
1233 assert!(info.actions().contains(&accesskit::Action::Click));
1234 assert!(info.actions().contains(&accesskit::Action::Focus));
1235 }
1236
1237 #[test]
1238 fn sync_accessibility_produces_tree_update() {
1239 let mut tree = WidgetTree::new();
1240 tree.add(FillWidget::new().label("First"));
1241 tree.add(FillWidget::new().label("Second"));
1242 tree.layout(SizeProposal::exact(200.0, 100.0));
1243
1244 let update = tree.sync_accessibility();
1245 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1248 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1249 assert!(update.tree.is_some());
1250 assert_a11y_tree_valid(&update);
1251 }
1252
1253 #[test]
1254 fn root_node_carries_locale_as_language() {
1255 let mut tree = WidgetTree::new();
1256 tree.add(FillWidget::new().label("Bonjour"));
1257
1258 tree.layout(SizeProposal::exact(200.0, 100.0));
1260 let update = tree.sync_accessibility();
1261 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1262 assert_eq!(
1263 update.nodes[0].1.language(),
1264 None,
1265 "no language before a locale is set"
1266 );
1267
1268 tree.set_locale("fr-FR".to_string());
1271 let update = tree.sync_accessibility();
1272 assert_eq!(
1273 update.nodes[0].1.language(),
1274 Some("fr-FR"),
1275 "root node must advertise the active locale as its language"
1276 );
1277 }
1278
1279 #[test]
1280 fn sync_accessibility_excludes_dormant_widgets() {
1281 let mut tree = WidgetTree::new();
1282 tree.add(FillWidget::new().label("Active"));
1283 let dormant = tree.add(FillWidget::new().label("Dormant"));
1284 tree.layout(SizeProposal::exact(200.0, 100.0));
1285
1286 tree.set_dormant(dormant);
1287
1288 let update = tree.sync_accessibility();
1289 assert_eq!(update.nodes.len(), 2 + ANNOUNCER_NODES);
1290 assert_a11y_tree_valid(&update);
1291 }
1292
1293 #[test]
1300 fn relayout_without_activation_change_does_not_dirty_a11y() {
1301 let mut tree = WidgetTree::new();
1302 let id = tree.add(FillWidget::new().label("Static"));
1303 tree.layout(SizeProposal::exact(200.0, 100.0));
1304
1305 let _ = tree.sync_accessibility();
1307 assert!(
1308 !tree.a11y_dirty,
1309 "sync_accessibility must clear the dirty flag"
1310 );
1311
1312 tree.arena.mark_needs_layout(id);
1316 tree.layout(SizeProposal::exact(200.0, 100.0));
1317
1318 assert!(
1319 !tree.a11y_dirty,
1320 "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1321 );
1322 }
1323
1324 #[derive(Debug)]
1327 struct Mover {
1328 offset: crate::signal::Signal<f32>,
1329 child: WidgetId,
1330 }
1331
1332 impl crate::widget::Widget for Mover {
1333 fn children(&self) -> Vec<WidgetId> {
1334 vec![self.child]
1335 }
1336 fn layout_response(
1337 &self,
1338 proposal: SizeProposal,
1339 _ctx: &crate::widget::LayoutContext,
1340 ) -> crate::widget::LayoutResponse {
1341 proposal.resolve(200.0, 100.0).into()
1342 }
1343 fn place_children(
1344 &self,
1345 bounds: teksilo_canvas::Rect,
1346 _proposal: SizeProposal,
1347 children: &mut [crate::widget::WidgetPlacement],
1348 _ctx: &crate::widget::LayoutContext,
1349 ) {
1350 if let Some(placement) = children.first_mut() {
1351 placement.origin =
1352 teksilo_canvas::Point::new(bounds.x + self.offset.get(), bounds.y);
1353 placement.size = teksilo_canvas::Size::new(50.0, 20.0);
1354 }
1355 }
1356 }
1357
1358 #[test]
1365 fn translating_a_widget_patches_the_cache_without_a_walk() {
1366 let mut tree = WidgetTree::new();
1367 let inner = tree.add(FillWidget::new().label("Row"));
1368 let offset = crate::signal::Signal::new(0.0f32);
1369 let root = tree.add(Mover {
1370 offset: offset.clone(),
1371 child: inner,
1372 });
1373 tree.layout(SizeProposal::exact(200.0, 100.0));
1374 let update = tree.sync_accessibility();
1375 let walks = tree.a11y_walk_generation();
1376 let before = find_node(&update, inner)
1377 .unwrap()
1378 .bounds()
1379 .expect("the node carries bounds");
1380
1381 offset.set(20.0);
1383 tree.arena.mark_needs_layout(root);
1384 tree.layout(SizeProposal::exact(200.0, 100.0));
1385
1386 let update = tree.sync_accessibility();
1387 assert_eq!(
1388 tree.a11y_walk_generation(),
1389 walks,
1390 "a pure translation must not cost a walk"
1391 );
1392 let after = find_node(&update, inner)
1393 .unwrap()
1394 .bounds()
1395 .expect("the node still carries bounds");
1396 assert!(
1397 (after.x0 - before.x0 - 20.0).abs() < 0.01,
1398 "the cached node must follow the move: {before:?} -> {after:?}"
1399 );
1400 }
1401
1402 #[test]
1403 fn at_version_does_not_bump_for_a_move() {
1404 let mut tree = WidgetTree::new();
1408 let inner = tree.add(FillWidget::new().label("Row"));
1409 let offset = crate::signal::Signal::new(0.0f32);
1410 let root = tree.add(Mover {
1411 offset: offset.clone(),
1412 child: inner,
1413 });
1414 tree.layout(SizeProposal::exact(200.0, 100.0));
1415 let _ = tree.sync_accessibility();
1416 let version = tree.at_version().get();
1417
1418 offset.set(20.0);
1419 tree.arena.mark_needs_layout(root);
1420 tree.layout(SizeProposal::exact(200.0, 100.0));
1421 let _ = tree.sync_accessibility();
1422
1423 assert_eq!(tree.at_version().get(), version);
1424 }
1425
1426 #[test]
1427 fn resizing_a_widget_dirties_the_accessibility_tree() {
1428 let mut tree = WidgetTree::new();
1432 let id = tree.add(FillWidget::new().label("Static"));
1433 tree.layout(SizeProposal::exact(200.0, 100.0));
1434 let _ = tree.sync_accessibility();
1435 let walks = tree.a11y_walk_generation();
1436
1437 tree.layout(SizeProposal::exact(300.0, 100.0));
1438 let _ = tree.sync_accessibility();
1439
1440 assert!(
1441 tree.a11y_walk_generation() > walks,
1442 "a resize must be walked, not patched"
1443 );
1444 let _ = id;
1445 }
1446
1447 #[test]
1448 fn changing_the_theme_dirties_the_accessibility_tree() {
1449 let mut tree = WidgetTree::new();
1454 tree.add(FillWidget::new().label("Static"));
1455 tree.layout(SizeProposal::exact(200.0, 100.0));
1456 let _ = tree.sync_accessibility();
1457 let walks = tree.a11y_walk_generation();
1458
1459 tree.set_theme(crate::presets::intui::dark());
1460 let _ = tree.sync_accessibility();
1461
1462 assert!(tree.a11y_walk_generation() > walks);
1463 }
1464
1465 #[test]
1466 fn changing_the_text_scale_dirties_the_accessibility_tree() {
1467 let mut tree = WidgetTree::new();
1468 tree.add(FillWidget::new().label("Static"));
1469 tree.layout(SizeProposal::exact(200.0, 100.0));
1470 let _ = tree.sync_accessibility();
1471 let walks = tree.a11y_walk_generation();
1472
1473 tree.set_user_text_scale(1.5);
1474 let _ = tree.sync_accessibility();
1475
1476 assert!(tree.a11y_walk_generation() > walks);
1477 }
1478
1479 #[test]
1480 fn a_moved_node_absent_from_the_cache_is_skipped() {
1481 use crate::widget_builder::WidgetBuilder;
1486 let mut tree = WidgetTree::new();
1487 let hidden = tree.add(FillWidget::new().label("Inner"));
1488 let offset = crate::signal::Signal::new(0.0f32);
1489 let root = tree.add(
1490 Mover {
1491 offset: offset.clone(),
1492 child: hidden,
1493 }
1494 .access_exclude_subtree(),
1495 );
1496 tree.layout(SizeProposal::exact(200.0, 100.0));
1497 let update = tree.sync_accessibility();
1498 assert!(
1499 find_node(&update, hidden).is_none(),
1500 "the excluded descendant must be absent for this test to mean anything"
1501 );
1502
1503 offset.set(20.0);
1504 tree.arena.mark_needs_layout(root);
1505 tree.layout(SizeProposal::exact(200.0, 100.0));
1506 let update = tree.sync_accessibility();
1507
1508 assert!(find_node(&update, hidden).is_none());
1509 assert_no_dangling_relationships(&update);
1510 }
1511
1512 #[test]
1516 fn activation_transition_does_dirty_a11y() {
1517 let mut tree = WidgetTree::new();
1518 let id = tree.add(FillWidget::new().label("Toggle"));
1519 tree.layout(SizeProposal::exact(200.0, 100.0));
1520 let _ = tree.sync_accessibility();
1521 assert!(!tree.a11y_dirty);
1522
1523 tree.set_dormant(id);
1524 tree.layout(SizeProposal::exact(200.0, 100.0));
1525 assert!(
1526 tree.a11y_dirty,
1527 "active→dormant transition must dirty the AT cache so the dormant node is removed"
1528 );
1529 }
1530
1531 #[test]
1532 fn sync_accessibility_includes_focus() {
1533 let mut tree = WidgetTree::new();
1534 let widget = tree.add(FillWidget::new().focusable().label("Focused"));
1535 tree.layout(SizeProposal::exact(100.0, 50.0));
1536 tree.focus(widget);
1537
1538 let update = tree.sync_accessibility();
1539 let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
1540 assert_eq!(update.focus, expected_focus);
1541 assert_a11y_tree_valid(&update);
1542 }
1543
1544 #[test]
1545 fn sync_accessibility_parent_child_relationship() {
1546 let mut tree = WidgetTree::new();
1547 let child = tree.add(FillWidget::new().label("Child"));
1548 let parent = tree.add(
1551 StackWidget::new()
1552 .add_child(child)
1553 .access_label_literal("Parent"),
1554 );
1555 tree.layout(SizeProposal::exact(100.0, 50.0));
1556
1557 let update = tree.sync_accessibility();
1558 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1559
1560 let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
1561 let parent_node = update
1562 .nodes
1563 .iter()
1564 .find(|(id, _)| *id == parent_node_id)
1565 .map(|(_, node)| node)
1566 .unwrap();
1567
1568 let child_node_id = crate::accessibility::widget_id_to_node_id(child);
1569 assert!(parent_node.children().contains(&child_node_id));
1570 assert_a11y_tree_valid(&update);
1571 }
1572
1573 #[test]
1574 fn presentational_containers_collapse_and_promote_children() {
1575 let mut tree = WidgetTree::new();
1581 let leaf = tree.add(FillWidget::new().label("Leaf")); let inner = tree.add(StackWidget::new().add_child(leaf)); let outer = tree.add(StackWidget::new().add_child(inner)); let labeled = tree.add(
1585 StackWidget::new()
1586 .add_child(outer)
1587 .access_label_literal("Group"),
1588 );
1589 tree.layout(SizeProposal::exact(100.0, 50.0));
1590 let update = tree.sync_accessibility();
1591
1592 assert!(find_node(&update, inner).is_none(), "bare inner pruned");
1593 assert!(find_node(&update, outer).is_none(), "bare outer pruned");
1594
1595 let labeled_node = find_node(&update, labeled).expect("labeled group survives");
1596 let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
1597 assert!(
1598 labeled_node.children().contains(&leaf_nid),
1599 "leaf promoted past both bare containers to the labeled ancestor"
1600 );
1601 assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
1602 assert_a11y_tree_valid(&update);
1603 }
1604
1605 #[test]
1606 fn find_by_action_finds_clickable() {
1607 let mut tree = WidgetTree::new();
1608 let widget = tree.add(ClickableWidget);
1609 tree.layout(SizeProposal::exact(100.0, 40.0));
1610
1611 assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
1612 assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
1613 }
1614
1615 #[test]
1616 fn text_content_returns_accessibility_name() {
1617 let mut tree = WidgetTree::new();
1618 let widget = tree.add(FillWidget::new().label("Hello World"));
1619 tree.layout(SizeProposal::exact(100.0, 50.0));
1620
1621 assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
1622 }
1623
1624 #[test]
1625 fn text_content_returns_none_without_label() {
1626 let mut tree = WidgetTree::new();
1627 let widget = tree.add(FillWidget::new());
1628 tree.layout(SizeProposal::exact(100.0, 50.0));
1629
1630 assert_eq!(tree.text_content(widget), None);
1631 }
1632
1633 #[test]
1634 fn descendant_of_disabled_ancestor_reports_disabled() {
1635 use crate::signal::Signal;
1636
1637 let mut tree = WidgetTree::new();
1638 let child = tree.add(FillWidget::new().label("Child"));
1639 let parent = tree.add(StackWidget::new().add_child(child));
1640 tree.enabled_when(parent, Signal::new(false));
1641 tree.layout(SizeProposal::exact(100.0, 50.0));
1642
1643 assert!(
1644 tree.accessibility_node(child).is_disabled(),
1645 "descendant should report disabled when ancestor is disabled"
1646 );
1647 }
1648
1649 #[test]
1650 fn text_value_returns_accessibility_value() {
1651 #[derive(Debug)]
1652 struct ValueWidget;
1653
1654 impl Widget for ValueWidget {
1655 fn layout_response(
1656 &self,
1657 proposal: SizeProposal,
1658 _ctx: &LayoutContext,
1659 ) -> crate::widget::LayoutResponse {
1660 proposal.resolve(0.0, 0.0).into()
1661 }
1662
1663 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1664 builder.set_role(accesskit::Role::Slider);
1665 builder.set_name("Volume");
1666 builder.set_value("75%");
1667 }
1668 }
1669
1670 let mut tree = WidgetTree::new();
1671 let widget = tree.add(ValueWidget);
1672 tree.layout(SizeProposal::exact(100.0, 40.0));
1673
1674 assert_eq!(tree.text_value(widget), Some("75%".to_string()));
1675 assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
1676 }
1677
1678 #[test]
1679 fn sync_accessibility_has_no_duplicate_children() {
1680 let mut tree = WidgetTree::new();
1684 let grandchild = tree.add(FillWidget::new().label("Grandchild"));
1685 let child_a = tree.add(StackWidget::new().add_child(grandchild));
1686 let child_b = tree.add(FillWidget::new().label("Sibling"));
1687 let _root = tree.add(StackWidget::new().add_child(child_a).add_child(child_b));
1688 tree.layout(SizeProposal::exact(200.0, 100.0));
1689
1690 let update = tree.sync_accessibility();
1691
1692 let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
1693 std::collections::HashMap::new();
1694 for (parent_id, node) in &update.nodes {
1695 for &child_id in node.children() {
1696 let prev = all_children.insert(child_id, *parent_id);
1697 assert!(
1698 prev.is_none(),
1699 "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
1700 );
1701 }
1702 }
1703 assert_a11y_tree_valid(&update);
1704 }
1705
1706 #[test]
1707 fn no_dangling_relationships_in_basic_tree() {
1708 let mut tree = WidgetTree::new();
1709 let child = tree.add(FillWidget::new().label("Child"));
1710 let _parent = tree.add(StackWidget::new().add_child(child));
1711 tree.layout(SizeProposal::exact(100.0, 50.0));
1712
1713 let update = tree.sync_accessibility();
1714 assert_no_dangling_relationships(&update);
1715 assert_a11y_tree_valid(&update);
1716 }
1717
1718 use crate::widget_builder::WidgetBuilder;
1723 use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
1724
1725 fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
1727 let nid = crate::accessibility::widget_id_to_node_id(id);
1728 update
1729 .nodes
1730 .iter()
1731 .find(|(node_id, _)| *node_id == nid)
1732 .map(|(_, n)| n)
1733 }
1734
1735 #[derive(Debug)]
1738 struct AlwaysHiddenWidget;
1739 impl Widget for AlwaysHiddenWidget {
1740 fn layout_response(
1741 &self,
1742 proposal: SizeProposal,
1743 _ctx: &LayoutContext,
1744 ) -> crate::widget::LayoutResponse {
1745 proposal.resolve(0.0, 0.0).into()
1746 }
1747 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1748 builder.set_role(Role::GenericContainer);
1749 builder.set_hidden();
1750 }
1751 }
1752
1753 #[test]
1755 fn access_label_replaces_widget_label() {
1756 let mut tree = WidgetTree::new();
1757 let id = tree.add(ClickableWidget.access_label_literal("Publish"));
1758 tree.layout(SizeProposal::exact(100.0, 40.0));
1759 assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
1760 }
1761
1762 #[test]
1764 fn access_description_appears_on_bare_widget() {
1765 let mut tree = WidgetTree::new();
1766 let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
1767 tree.layout(SizeProposal::exact(50.0, 50.0));
1768 let update = tree.sync_accessibility();
1769 let node = find_node(&update, id).expect("node present");
1770 assert_eq!(node.description(), Some("Decorative"));
1771 }
1772
1773 #[test]
1775 fn access_value_replaces_widget_value() {
1776 #[derive(Debug)]
1777 struct SliderWidget;
1778 impl Widget for SliderWidget {
1779 fn layout_response(
1780 &self,
1781 proposal: SizeProposal,
1782 _ctx: &LayoutContext,
1783 ) -> crate::widget::LayoutResponse {
1784 proposal.resolve(0.0, 0.0).into()
1785 }
1786 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1787 builder.set_role(Role::Slider);
1788 builder.set_value("50");
1789 }
1790 }
1791 let mut tree = WidgetTree::new();
1792 let id = tree.add(SliderWidget.access_value_literal("Custom"));
1793 tree.layout(SizeProposal::exact(100.0, 40.0));
1794 assert_eq!(tree.text_value(id), Some("Custom".to_string()));
1795 }
1796
1797 #[test]
1799 fn access_role_overrides_widget_role() {
1800 let mut tree = WidgetTree::new();
1801 let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
1802 tree.layout(SizeProposal::exact(100.0, 40.0));
1803 assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
1804 }
1805
1806 #[test]
1807 fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
1808 let mut tree = WidgetTree::new();
1815 let anchor = tree.add(FillWidget::new().label("Save"));
1816 let tip = tree.add(FillWidget::new().label("Save the file"));
1817 tree.layout(SizeProposal::exact(200.0, 100.0));
1818 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
1819
1820 assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
1821 let update = tree.sync_accessibility();
1822 let node = find_node(&update, anchor).expect("anchor node present");
1823 assert_eq!(
1824 node.description(),
1825 Some("Save the file"),
1826 "the anchor must describe itself with its tooltip text while unshown"
1827 );
1828 }
1829
1830 #[test]
1831 fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
1832 let mut tree = WidgetTree::new();
1835 let anchor = tree.add(FillWidget::new().label("Save"));
1836 let tip = tree.add(FillWidget::new().label("Save the file"));
1837 tree.layout(SizeProposal::exact(200.0, 100.0));
1838 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
1839
1840 tree.pointer_move(tree.bounds(anchor).center());
1841 tree.advance_time(std::time::Duration::from_millis(150));
1842 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
1843
1844 let update = tree.sync_accessibility();
1845 let node = find_node(&update, anchor).expect("anchor node present");
1846 assert!(
1847 !node.described_by().is_empty(),
1848 "a shown tooltip is wired as a described_by relation"
1849 );
1850 }
1851
1852 #[test]
1854 fn access_hint_alias_writes_description_field() {
1855 let mut tree = WidgetTree::new();
1856 let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
1857 tree.layout(SizeProposal::exact(50.0, 50.0));
1858 let update = tree.sync_accessibility();
1859 let node = find_node(&update, id).unwrap();
1860 assert_eq!(node.description(), Some("Tip"));
1861 }
1862
1863 #[test]
1865 fn access_identifier_writes_author_id() {
1866 let mut tree = WidgetTree::new();
1867 let id = tree.add(FillWidget::new().access_identifier("save-button"));
1868 tree.layout(SizeProposal::exact(50.0, 50.0));
1869 let update = tree.sync_accessibility();
1870 let node = find_node(&update, id).unwrap();
1871 assert_eq!(node.author_id(), Some("save-button"));
1872 }
1873
1874 #[test]
1876 fn access_shortcut_literal_set() {
1877 let mut tree = WidgetTree::new();
1878 let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
1879 tree.layout(SizeProposal::exact(50.0, 50.0));
1880 let update = tree.sync_accessibility();
1881 let node = find_node(&update, id).unwrap();
1882 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
1883 }
1884
1885 #[test]
1888 fn access_shortcut_id_resolves_and_tracks_rebinds() {
1889 use crate::event::Key;
1890 use crate::shortcut::{KeyStroke, Shortcut};
1891
1892 let mut tree = WidgetTree::new();
1893 tree.shortcut_registry_mut().register(
1894 Shortcut::new("app.save")
1895 .name("Save")
1896 .primary(KeyStroke::command(Key::S))
1897 .build(),
1898 );
1899 let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
1900 tree.layout(SizeProposal::exact(50.0, 50.0));
1901
1902 let accel = if cfg!(target_os = "macos") {
1906 "Cmd"
1907 } else {
1908 "Ctrl"
1909 };
1910
1911 let update = tree.sync_accessibility();
1913 let node = find_node(&update, id).unwrap();
1914 assert_eq!(
1915 node.keyboard_shortcut(),
1916 Some(format!("{accel}+S").as_str())
1917 );
1918
1919 tree.shortcut_registry_mut()
1921 .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
1922 let update = tree.sync_accessibility();
1923 let node = find_node(&update, id).unwrap();
1924 assert_eq!(
1925 node.keyboard_shortcut(),
1926 Some(format!("{accel}+Q").as_str())
1927 );
1928 }
1929
1930 #[test]
1933 fn access_shortcut_id_unknown_id_omits_announcement() {
1934 let mut tree = WidgetTree::new();
1935 let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
1936 tree.layout(SizeProposal::exact(50.0, 50.0));
1937 let update = tree.sync_accessibility();
1938 let node = find_node(&update, id).unwrap();
1939 assert_eq!(node.keyboard_shortcut(), None);
1940 }
1941
1942 #[test]
1944 fn access_hidden_true_hides_widget() {
1945 let mut tree = WidgetTree::new();
1946 let id = tree.add(ClickableWidget.access_hidden(true));
1947 tree.layout(SizeProposal::exact(50.0, 50.0));
1948 assert!(tree.accessibility_node(id).is_hidden());
1949 }
1950
1951 #[test]
1953 fn access_hidden_false_clears_widget_set_hidden() {
1954 let mut tree = WidgetTree::new();
1955 let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
1956 tree.layout(SizeProposal::exact(50.0, 50.0));
1957 assert!(
1958 !tree.accessibility_node(id).is_hidden(),
1959 "access_hidden(false) should clear widget-emitted hidden"
1960 );
1961 }
1962
1963 #[test]
1965 fn access_disabled_true_marks_disabled() {
1966 let mut tree = WidgetTree::new();
1967 let id = tree.add(FillWidget::new().access_disabled(true));
1968 tree.layout(SizeProposal::exact(50.0, 50.0));
1969 assert!(tree.accessibility_node(id).is_disabled());
1970 }
1971
1972 #[test]
1974 fn access_disabled_false_clears_arena_driven_disabled() {
1975 use crate::signal::Signal;
1976 let mut tree = WidgetTree::new();
1977 let id = tree.add(FillWidget::new().label("X").access_disabled(false));
1978 tree.enabled_when(id, Signal::new(false));
1979 tree.layout(SizeProposal::exact(50.0, 50.0));
1980 assert!(
1981 !tree.accessibility_node(id).is_disabled(),
1982 "access_disabled(false) should clear even arena-driven disabled"
1983 );
1984 }
1985
1986 #[test]
1988 fn access_controls_appends() {
1989 let mut tree = WidgetTree::new();
1990 let target = tree.add(FillWidget::new().label("Target"));
1991 let controller = tree.add(
1992 FillWidget::new()
1993 .label("Controller")
1994 .access_controls(target),
1995 );
1996 tree.layout(SizeProposal::exact(100.0, 50.0));
1997 let update = tree.sync_accessibility();
1998 let node = find_node(&update, controller).unwrap();
1999 let target_nid = crate::accessibility::widget_id_to_node_id(target);
2000 assert!(
2001 node.controls().contains(&target_nid),
2002 "controls list should contain the target NodeId"
2003 );
2004 }
2005
2006 #[test]
2008 fn access_described_by_appends() {
2009 let mut tree = WidgetTree::new();
2010 let other = tree.add(FillWidget::new().label("Desc"));
2011 let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
2012 tree.layout(SizeProposal::exact(100.0, 50.0));
2013 let update = tree.sync_accessibility();
2014 let node = find_node(&update, id).unwrap();
2015 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2016 assert!(node.described_by().contains(&other_nid));
2017 }
2018
2019 #[test]
2021 fn access_labelled_by_appends() {
2022 let mut tree = WidgetTree::new();
2023 let other = tree.add(FillWidget::new().label("Lbl"));
2024 let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
2025 tree.layout(SizeProposal::exact(100.0, 50.0));
2026 let update = tree.sync_accessibility();
2027 let node = find_node(&update, id).unwrap();
2028 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2029 assert!(node.labelled_by().contains(&other_nid));
2030 }
2031
2032 #[test]
2033 fn a_labelled_by_target_that_went_dormant_is_stripped() {
2034 let mut tree = WidgetTree::new();
2040 let title = tree.add(FillWidget::new().label("Preferences"));
2041 let dialog = tree.add(FillWidget::new().access_labelled_by(title));
2042 tree.layout(SizeProposal::exact(200.0, 100.0));
2043
2044 let update = tree.sync_accessibility();
2045 assert!(
2046 !find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2047 "the relation is live while the target is in the tree"
2048 );
2049
2050 tree.set_dormant(title);
2051 let update = tree.sync_accessibility();
2052 assert!(
2053 find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2054 "a target absent from the tree must not survive in the relation"
2055 );
2056 assert_no_dangling_relationships(&update);
2057 }
2058
2059 #[test]
2060 fn re_registering_a_labelled_by_relation_does_not_duplicate_it() {
2061 let mut tree = WidgetTree::new();
2065 let title = tree.add(FillWidget::new().label("Name"));
2066 let field = tree.add(FillWidget::new());
2067 tree.layout(SizeProposal::exact(200.0, 100.0));
2068
2069 for _ in 0..3 {
2070 tree.push_access_labelled_by(field, title);
2071 }
2072 let update = tree.sync_accessibility();
2073 assert_eq!(
2074 find_node(&update, field).unwrap().labelled_by().len(),
2075 1,
2076 "the same target registered repeatedly is one relation"
2077 );
2078 }
2079
2080 #[test]
2081 fn re_registering_a_described_by_relation_does_not_duplicate_it() {
2082 let mut tree = WidgetTree::new();
2083 let hint = tree.add(FillWidget::new().label("Must be unique"));
2084 let field = tree.add(FillWidget::new());
2085 tree.layout(SizeProposal::exact(200.0, 100.0));
2086
2087 for _ in 0..3 {
2088 tree.push_access_described_by(field, hint);
2089 }
2090 let update = tree.sync_accessibility();
2091 assert_eq!(
2092 find_node(&update, field).unwrap().described_by().len(),
2093 1,
2094 "the same target registered repeatedly is one relation"
2095 );
2096 }
2097
2098 #[test]
2100 fn access_live_assertive_set() {
2101 let mut tree = WidgetTree::new();
2102 let id = tree.add(FillWidget::new().access_live(Live::Assertive));
2103 tree.layout(SizeProposal::exact(50.0, 50.0));
2104 let update = tree.sync_accessibility();
2105 let node = find_node(&update, id).unwrap();
2106 assert_eq!(node.live(), Some(Live::Assertive));
2107 }
2108
2109 #[test]
2111 fn access_aria_current_set() {
2112 let mut tree = WidgetTree::new();
2113 let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
2114 tree.layout(SizeProposal::exact(50.0, 50.0));
2115 let update = tree.sync_accessibility();
2116 let node = find_node(&update, id).unwrap();
2117 assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
2118 }
2119
2120 #[test]
2122 fn access_has_popup_set() {
2123 let mut tree = WidgetTree::new();
2124 let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
2125 tree.layout(SizeProposal::exact(50.0, 50.0));
2126 let update = tree.sync_accessibility();
2127 let node = find_node(&update, id).unwrap();
2128 assert_eq!(node.has_popup(), Some(HasPopup::Menu));
2129 }
2130
2131 #[test]
2133 fn access_orientation_set() {
2134 let mut tree = WidgetTree::new();
2135 let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
2136 tree.layout(SizeProposal::exact(50.0, 50.0));
2137 let update = tree.sync_accessibility();
2138 let node = find_node(&update, id).unwrap();
2139 assert_eq!(node.orientation(), Some(Orientation::Vertical));
2140 }
2141
2142 #[test]
2144 fn access_numeric_value_and_range() {
2145 let mut tree = WidgetTree::new();
2146 let id = tree.add(
2147 FillWidget::new()
2148 .access_role(Role::Slider)
2149 .access_numeric_value(50.0)
2150 .access_numeric_range(0.0, 100.0)
2151 .access_numeric_step(5.0),
2152 );
2153 tree.layout(SizeProposal::exact(50.0, 50.0));
2154 let update = tree.sync_accessibility();
2155 let node = find_node(&update, id).unwrap();
2156 assert_eq!(node.numeric_value(), Some(50.0));
2157 assert_eq!(node.min_numeric_value(), Some(0.0));
2158 assert_eq!(node.max_numeric_value(), Some(100.0));
2159 assert_eq!(node.numeric_value_step(), Some(5.0));
2160 }
2161
2162 #[test]
2164 fn access_action_advertises_and_routes() {
2165 use crate::signal::Signal;
2166 let flag = Signal::new(false);
2167 let flag_for_cb = flag.clone();
2168 let mut tree = WidgetTree::new();
2169 let id = tree.add(
2170 FillWidget::new()
2171 .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
2172 );
2173 tree.layout(SizeProposal::exact(50.0, 50.0));
2174 let info = tree.accessibility_node(id);
2175 assert!(info.actions().contains(&Action::ShowContextMenu));
2176 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2177 action: Action::ShowContextMenu,
2178 target: Some(id),
2179 target_node: crate::accessibility::widget_id_to_node_id(id),
2180 data: None,
2181 });
2182 assert!(flag.get(), "callback should have been invoked");
2183 }
2184
2185 #[test]
2187 fn access_two_actions_both_route() {
2188 use crate::signal::Signal;
2189 let click = Signal::new(false);
2190 let increment = Signal::new(false);
2191 let click_cb = click.clone();
2192 let inc_cb = increment.clone();
2193 let mut tree = WidgetTree::new();
2194 let id = tree.add(
2195 FillWidget::new()
2196 .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
2197 .access_action(Action::Increment, move |_| inc_cb.set(true)),
2198 );
2199 tree.layout(SizeProposal::exact(50.0, 50.0));
2200 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2201 action: Action::ShowContextMenu,
2202 target: Some(id),
2203 target_node: crate::accessibility::widget_id_to_node_id(id),
2204 data: None,
2205 });
2206 assert!(click.get() && !increment.get(), "only first action fired");
2207 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2208 action: Action::Increment,
2209 target: Some(id),
2210 target_node: crate::accessibility::widget_id_to_node_id(id),
2211 data: None,
2212 });
2213 assert!(
2214 click.get() && increment.get(),
2215 "both actions fired exactly once each"
2216 );
2217 }
2218
2219 #[test]
2221 fn access_remove_action_suppresses_widget_action() {
2222 let mut tree = WidgetTree::new();
2223 let id = tree.add(ActionWidget.access_remove_action(Action::Click));
2224 tree.layout(SizeProposal::exact(50.0, 50.0));
2225 let info = tree.accessibility_node(id);
2226 assert!(!info.actions().contains(&Action::Click));
2227 assert!(info.actions().contains(&Action::Focus));
2228 }
2229
2230 #[test]
2232 fn access_custom_action_uses_localized_label() {
2233 let mut tree = WidgetTree::new();
2234 let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
2235 tree.layout(SizeProposal::exact(50.0, 50.0));
2236 let update = tree.sync_accessibility();
2237 let node = find_node(&update, id).unwrap();
2238 let actions = node.custom_actions();
2239 assert_eq!(actions.len(), 1);
2240 assert_eq!(actions[0].id, 0);
2241 assert_eq!(actions[0].description.as_str(), "Reply");
2242 }
2243
2244 #[test]
2246 fn access_custom_action_routes_by_index() {
2247 use crate::signal::Signal;
2248 let first = Signal::new(false);
2249 let second = Signal::new(false);
2250 let f = first.clone();
2251 let s = second.clone();
2252 let mut tree = WidgetTree::new();
2253 let id = tree.add(
2254 FillWidget::new()
2255 .access_custom_action_literal("First", move |_| f.set(true))
2256 .access_custom_action_literal("Second", move |_| s.set(true)),
2257 );
2258 tree.layout(SizeProposal::exact(50.0, 50.0));
2259 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2260 action: Action::CustomAction,
2261 target: Some(id),
2262 target_node: crate::accessibility::widget_id_to_node_id(id),
2263 data: Some(accesskit::ActionData::CustomAction(1)),
2264 });
2265 assert!(!first.get(), "first should not fire");
2266 assert!(second.get(), "second should fire (index 1)");
2267 }
2268
2269 #[test]
2271 fn access_action_layered_with_on_access_action() {
2272 use crate::signal::Signal;
2273 let from_override = Signal::new(false);
2274 let from_user = Signal::new(false);
2275 let ov_cb = from_override.clone();
2276 let user_cb = from_user.clone();
2277 let mut tree = WidgetTree::new();
2278 let id = tree.add(
2279 FillWidget::new()
2280 .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
2281 .on_access_action(move |_action, _ctx| {
2282 user_cb.set(true);
2283 crate::event::EventResponse::Handled
2284 }),
2285 );
2286 tree.layout(SizeProposal::exact(50.0, 50.0));
2287 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2288 action: Action::ShowContextMenu,
2289 target: Some(id),
2290 target_node: crate::accessibility::widget_id_to_node_id(id),
2291 data: None,
2292 });
2293 assert!(from_override.get(), "override callback fired");
2294 assert!(from_user.get(), "user catch-all fired");
2295 }
2296
2297 #[test]
2303 fn access_label_accepts_resolved_string_via_into() {
2304 struct ResolvedAtCall(String);
2308 impl From<ResolvedAtCall> for crate::signal::Prop<String> {
2309 fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
2310 crate::signal::Prop::Static(v.0)
2311 }
2312 }
2313 let mut tree = WidgetTree::new();
2314 let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
2315 tree.layout(SizeProposal::exact(50.0, 50.0));
2316 assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
2317 }
2318
2319 #[test]
2321 fn access_exclude_subtree_prunes_children_from_at_tree() {
2322 let mut tree = WidgetTree::new();
2323 let inner1 = tree.add(FillWidget::new().label("A"));
2324 let inner2 = tree.add(FillWidget::new().label("B"));
2325 let outer = tree.add(
2326 StackWidget::new()
2327 .add_child(inner1)
2328 .add_child(inner2)
2329 .access_label_literal("Section")
2333 .access_exclude_subtree(),
2334 );
2335 tree.layout(SizeProposal::exact(100.0, 50.0));
2336 let update = tree.sync_accessibility();
2337 assert!(find_node(&update, outer).is_some());
2339 assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
2340 assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
2341 let outer_node = find_node(&update, outer).unwrap();
2342 assert!(
2343 outer_node.children().is_empty(),
2344 "outer should have no AT children when excluded"
2345 );
2346 }
2347
2348 #[test]
2350 fn access_merge_subtree_concatenates_descendant_labels() {
2351 let mut tree = WidgetTree::new();
2352 let title = tree.add(FillWidget::new().label("Title"));
2353 let subtitle = tree.add(FillWidget::new().label("Subtitle"));
2354 let card = tree.add(
2355 StackWidget::new()
2356 .add_child(title)
2357 .add_child(subtitle)
2358 .access_merge_subtree(),
2359 );
2360 tree.layout(SizeProposal::exact(100.0, 50.0));
2361 assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
2363 let update = tree.sync_accessibility();
2364 assert!(find_node(&update, title).is_none());
2365 assert!(find_node(&update, subtitle).is_none());
2366 }
2367
2368 #[test]
2370 fn access_merge_subtree_unions_actions() {
2371 let mut tree = WidgetTree::new();
2372 let click_a = tree.add(ClickableWidget);
2373 let click_b = tree.add(ClickableWidget);
2374 let card = tree.add(
2375 StackWidget::new()
2376 .add_child(click_a)
2377 .add_child(click_b)
2378 .access_merge_subtree(),
2379 );
2380 tree.layout(SizeProposal::exact(100.0, 50.0));
2381 let actions = tree.accessibility_node(card).actions().to_vec();
2382 let click_count = actions.iter().filter(|a| **a == Action::Click).count();
2383 assert_eq!(
2384 click_count, 1,
2385 "Click should be present exactly once after merge (deduplicated)"
2386 );
2387 }
2388
2389 #[test]
2399 fn access_merge_subtree_first_nonempty_value_wins() {
2400 #[derive(Debug)]
2401 struct ValueWidget(&'static str);
2402 impl Widget for ValueWidget {
2403 fn layout_response(
2404 &self,
2405 proposal: SizeProposal,
2406 _ctx: &LayoutContext,
2407 ) -> crate::widget::LayoutResponse {
2408 proposal.resolve(0.0, 0.0).into()
2409 }
2410 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2411 builder.set_role(Role::Slider);
2412 builder.set_value(self.0);
2413 }
2414 }
2415 let mut tree = WidgetTree::new();
2416 let v1 = tree.add(ValueWidget("first"));
2417 let v2 = tree.add(ValueWidget("second"));
2418 let card = tree.add(
2419 StackWidget::new()
2420 .add_child(v1)
2421 .add_child(v2)
2422 .access_merge_subtree(),
2423 );
2424 tree.layout(SizeProposal::exact(100.0, 50.0));
2425 assert_eq!(tree.text_value(card), Some("first".to_string()));
2426 }
2427
2428 #[test]
2430 fn access_exclude_inside_merge() {
2431 let mut tree = WidgetTree::new();
2432 let visible = tree.add(FillWidget::new().label("VISIBLE"));
2433 let pruned = tree.add(FillWidget::new().label("PRUNED"));
2434 let inner_excluded = tree.add(
2435 StackWidget::new()
2436 .add_child(pruned)
2437 .access_exclude_subtree(),
2438 );
2439 let card = tree.add(
2440 StackWidget::new()
2441 .add_child(visible)
2442 .add_child(inner_excluded)
2443 .access_merge_subtree(),
2444 );
2445 tree.layout(SizeProposal::exact(100.0, 50.0));
2446 let merged = tree.text_content(card).unwrap_or_default();
2447 assert!(merged.contains("VISIBLE"));
2450 assert!(
2451 !merged.contains("PRUNED"),
2452 "excluded subtree should not contribute to merge"
2453 );
2454 }
2455
2456 #[test]
2458 fn access_merge_inside_merge() {
2459 let mut tree = WidgetTree::new();
2460 let inner_a = tree.add(FillWidget::new().label("a"));
2461 let inner_b = tree.add(FillWidget::new().label("b"));
2462 let inner_card = tree.add(
2463 StackWidget::new()
2464 .add_child(inner_a)
2465 .add_child(inner_b)
2466 .access_merge_subtree(),
2467 );
2468 let outer_extra = tree.add(FillWidget::new().label("X"));
2469 let outer = tree.add(
2470 StackWidget::new()
2471 .add_child(inner_card)
2472 .add_child(outer_extra)
2473 .access_merge_subtree(),
2474 );
2475 tree.layout(SizeProposal::exact(200.0, 100.0));
2476 let merged = tree.text_content(outer).unwrap_or_default();
2481 assert!(merged.contains("a"));
2482 assert!(merged.contains("b"));
2483 assert!(merged.contains("X"));
2484 let update = tree.sync_accessibility();
2486 assert!(find_node(&update, inner_a).is_none());
2487 assert!(find_node(&update, inner_b).is_none());
2488 assert!(find_node(&update, inner_card).is_none());
2489 assert!(find_node(&update, outer_extra).is_none());
2490 }
2491
2492 #[test]
2494 fn access_customize_runs_last() {
2495 let mut tree = WidgetTree::new();
2496 let id = tree.add(
2497 FillWidget::new()
2498 .access_label_literal("A")
2499 .access_customize(|b| b.set_name("B")),
2500 );
2501 tree.layout(SizeProposal::exact(50.0, 50.0));
2502 assert_eq!(tree.accessibility_node(id).name(), Some("B"));
2503 }
2504
2505 #[test]
2507 fn access_customize_can_reach_inner_mut() {
2508 let mut tree = WidgetTree::new();
2509 let id = tree.add(FillWidget::new().access_customize(|b| {
2510 b.inner_mut().set_author_id("from-customize");
2511 }));
2512 tree.layout(SizeProposal::exact(50.0, 50.0));
2513 let update = tree.sync_accessibility();
2514 let node = find_node(&update, id).unwrap();
2515 assert_eq!(node.author_id(), Some("from-customize"));
2516 }
2517
2518 #[test]
2520 fn access_overrides_zero_cost_when_unused() {
2521 use std::mem::size_of;
2528 assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
2529 assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
2530 }
2531
2532 #[test]
2533 fn accessibility_children_overrides_at_reading_order() {
2534 #[derive(Debug)]
2539 struct NamedLeaf(&'static str);
2540 impl Widget for NamedLeaf {
2541 fn layout_response(
2542 &self,
2543 proposal: SizeProposal,
2544 _ctx: &LayoutContext,
2545 ) -> crate::widget::LayoutResponse {
2546 proposal.resolve(10.0, 10.0).into()
2547 }
2548 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2549 builder.set_role(accesskit::Role::Button);
2553 builder.set_name(self.0);
2554 }
2555 }
2556
2557 #[derive(Debug, Default)]
2558 struct ReorderContainer {
2559 a: Option<WidgetId>,
2560 b: Option<WidgetId>,
2561 }
2562 impl Widget for ReorderContainer {
2563 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2564 let a = ctx.add(NamedLeaf("A"));
2565 let b = ctx.add(NamedLeaf("B"));
2566 self.a = Some(a);
2567 self.b = Some(b);
2568 vec![a, b]
2569 }
2570 fn layout_response(
2571 &self,
2572 proposal: SizeProposal,
2573 _ctx: &LayoutContext,
2574 ) -> crate::widget::LayoutResponse {
2575 proposal.resolve(100.0, 100.0).into()
2576 }
2577 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2578 builder.set_role(accesskit::Role::Group);
2581 builder.set_name("Container");
2582 }
2583 fn children(&self) -> Vec<WidgetId> {
2584 [self.a, self.b].into_iter().flatten().collect()
2585 }
2586 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2587 Some([self.b, self.a].into_iter().flatten().collect())
2589 }
2590 }
2591
2592 let mut tree = WidgetTree::new();
2593 let container = tree.add(ReorderContainer::default());
2594 tree.layout(SizeProposal::exact(100.0, 100.0));
2595 let update = tree.sync_accessibility();
2596
2597 let a_nid = update
2598 .nodes
2599 .iter()
2600 .find(|(_, n)| n.label() == Some("A"))
2601 .map(|(id, _)| *id)
2602 .expect("A node present");
2603 let b_nid = update
2604 .nodes
2605 .iter()
2606 .find(|(_, n)| n.label() == Some("B"))
2607 .map(|(id, _)| *id)
2608 .expect("B node present");
2609 let cnid = crate::accessibility::widget_id_to_node_id(container);
2610 let cnode = update
2611 .nodes
2612 .iter()
2613 .find(|(id, _)| *id == cnid)
2614 .map(|(_, n)| n)
2615 .expect("container node present");
2616 let kids = cnode.children();
2617 let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
2618 let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
2619 assert!(
2620 pb < pa,
2621 "accessibility_children() must set AT order B-before-A; got {kids:?}"
2622 );
2623 }
2624
2625 fn consumer_bounding_box(update: &accesskit::TreeUpdate, label: &str) -> accesskit::Rect {
2639 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
2640 let state = consumer.state();
2641 let mut stack = vec![state.root()];
2642 while let Some(node) = stack.pop() {
2643 let text = node.value().or_else(|| node.label());
2646 if text.as_deref() == Some(label) {
2647 return node.bounding_box().expect("node has bounds");
2648 }
2649 for child in node.children() {
2650 stack.push(child);
2651 }
2652 }
2653 panic!("no node labelled {label:?} in the consumer tree");
2654 }
2655
2656 fn tree_at_scale(scale: f32) -> (WidgetTree, WidgetId) {
2657 let mut tree = WidgetTree::new();
2658 let id = tree.add(FillWidget::new().label("content"));
2659 tree.set_device_scale_factor(scale);
2660 tree.layout(SizeProposal::exact(200.0, 100.0));
2661 (tree, id)
2662 }
2663
2664 #[test]
2665 fn the_root_carries_the_device_scale_as_its_transform() {
2666 let (mut tree, _) = tree_at_scale(2.0);
2667 let update = tree.sync_accessibility();
2668 let root = update
2669 .nodes
2670 .iter()
2671 .find(|(id, _)| *id == crate::accessibility::root_node_id())
2672 .map(|(_, n)| n)
2673 .expect("root node present");
2674 assert_eq!(
2675 root.transform().copied(),
2676 Some(accesskit::Affine::scale(2.0)),
2677 "AccessKit wants physical coordinates; the tree emits logical ones"
2678 );
2679 assert_a11y_tree_valid(&update);
2680 }
2681
2682 #[test]
2683 fn emitted_bounds_stay_logical_at_scale_two() {
2684 let (mut tree, id) = tree_at_scale(2.0);
2693 let update = tree.sync_accessibility();
2694 let layout = tree.bounds(id);
2695 let nid = crate::accessibility::widget_id_to_node_id(id);
2696 let raw = update
2697 .nodes
2698 .iter()
2699 .find(|(n, _)| *n == nid)
2700 .and_then(|(_, n)| n.bounds())
2701 .expect("widget node has bounds");
2702 assert_eq!(
2703 (raw.x0, raw.y0, raw.x1, raw.y1),
2704 (
2705 layout.x as f64,
2706 layout.y as f64,
2707 (layout.x + layout.width) as f64,
2708 (layout.y + layout.height) as f64,
2709 ),
2710 "an emitted rectangle must be the logical layout rectangle, unscaled"
2711 );
2712 }
2713
2714 #[test]
2733 fn an_interactive_node_emits_its_exact_layout_rectangle() {
2734 let mut tree = WidgetTree::new();
2735 let id = tree.add(ActionWidget);
2736 tree.layout(SizeProposal::exact(200.0, 100.0));
2737 let update = tree.sync_accessibility();
2738 let nid = crate::accessibility::widget_id_to_node_id(id);
2739 let node = update
2740 .nodes
2741 .iter()
2742 .find(|(n, _)| *n == nid)
2743 .map(|(_, n)| n)
2744 .expect("the interactive node is in the update");
2745 assert!(
2746 node.supports_action(accesskit::Action::Click),
2747 "the fixture has to be the clickable shape the rule is about, \
2748 or this test guards nothing"
2749 );
2750 let layout = tree.bounds(id);
2751 assert!(
2752 layout.width > 0.0 && layout.height > 0.0,
2753 "a real, non-degenerate rectangle to compare against: {layout:?}"
2754 );
2755 let raw = node.bounds().expect("the interactive node has bounds");
2756 assert_eq!(
2757 (raw.x0, raw.y0, raw.x1, raw.y1),
2758 (
2759 layout.x as f64,
2760 layout.y as f64,
2761 (layout.x + layout.width) as f64,
2762 (layout.y + layout.height) as f64,
2763 ),
2764 "a clickable node's emitted rectangle is its layout rectangle, \
2765 not an outset one"
2766 );
2767 }
2768
2769 #[test]
2770 fn what_an_assistive_technology_reads_is_the_physical_rectangle() {
2771 let (mut one, _) = tree_at_scale(1.0);
2775 let at_one = consumer_bounding_box(&one.sync_accessibility(), "content");
2776 let (mut two, _) = tree_at_scale(2.0);
2777 let at_two = consumer_bounding_box(&two.sync_accessibility(), "content");
2778 assert_eq!(at_two.x0, at_one.x0 * 2.0);
2779 assert_eq!(at_two.y0, at_one.y0 * 2.0);
2780 assert_eq!(at_two.x1, at_one.x1 * 2.0);
2781 assert_eq!(at_two.y1, at_one.y1 * 2.0);
2782 assert!(at_two.x1 > at_two.x0, "a real, non-degenerate rectangle");
2783 }
2784
2785 #[test]
2786 fn a_scale_change_invalidates_the_accessibility_cache() {
2787 let (mut tree, _) = tree_at_scale(1.0);
2793 let _ = tree.sync_accessibility();
2794 tree.set_device_scale_factor(2.0);
2795 let update = tree.sync_accessibility();
2796 let root = update
2797 .nodes
2798 .iter()
2799 .find(|(id, _)| *id == crate::accessibility::root_node_id())
2800 .map(|(_, n)| n)
2801 .expect("root node present");
2802 assert_eq!(
2803 root.transform().copied(),
2804 Some(accesskit::Affine::scale(2.0))
2805 );
2806 }
2807
2808 #[test]
2809 fn setting_the_same_scale_does_not_dirty_the_tree() {
2810 let (mut tree, _) = tree_at_scale(2.0);
2814 let first = tree.sync_accessibility();
2815 tree.set_device_scale_factor(2.0);
2816 let second = tree.sync_accessibility();
2817 assert_eq!(first, second);
2818 }
2819
2820 #[test]
2823 fn explore_by_touch_is_off_by_default() {
2824 let tree = WidgetTree::new();
2825 assert_eq!(tree.explore_by_touch(), crate::ExploreByTouch::Off);
2826 assert_eq!(
2827 tree.screen_reader_state(),
2828 crate::ScreenReaderState::Unknown
2829 );
2830 assert!(!tree.explore_by_touch_active());
2831 }
2832
2833 #[test]
2834 fn auto_follows_the_operating_systems_screen_reader_flag() {
2835 let mut tree = WidgetTree::new();
2836 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
2837 assert!(!tree.explore_by_touch_active(), "Unknown is not a yes");
2838 tree.set_screen_reader_state(crate::ScreenReaderState::Inactive);
2839 assert!(!tree.explore_by_touch_active());
2840 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
2841 assert!(tree.explore_by_touch_active());
2842 }
2843
2844 #[test]
2845 fn on_ignores_the_screen_reader_flag_and_off_ignores_everything() {
2846 let mut tree = WidgetTree::new();
2847 tree.set_explore_by_touch(crate::ExploreByTouch::On);
2848 assert!(tree.explore_by_touch_active(), "On is the app's own choice");
2849 tree.set_explore_by_touch(crate::ExploreByTouch::Off);
2850 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
2851 assert!(!tree.explore_by_touch_active());
2852 }
2853
2854 #[test]
2855 fn an_attached_accesskit_client_does_not_switch_exploring_on() {
2856 let mut tree = WidgetTree::new();
2862 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
2863 tree.set_at_client_attached(true);
2864 assert!(tree.at_client_attached());
2865 assert_eq!(
2866 tree.screen_reader_state(),
2867 crate::ScreenReaderState::Unknown,
2868 "attaching says nothing about screen readers"
2869 );
2870 assert!(!tree.explore_by_touch_active());
2871 }
2872
2873 #[test]
2874 fn touch_still_reaches_a_handler_with_an_accesskit_client_attached() {
2875 use crate::Modifiers;
2881 use crate::pointer::{
2882 BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase,
2883 PointerSample,
2884 };
2885 use crate::widget_builder::WidgetBuilder;
2886 use std::cell::Cell;
2887 use std::rc::Rc;
2888
2889 let taps = Rc::new(Cell::new(0u32));
2890 let seen = Rc::clone(&taps);
2891 let mut tree = WidgetTree::new();
2892 let id = tree.add(
2893 FillWidget::new()
2894 .label("target")
2895 .on_tap(move |_e, _ctx| seen.set(seen.get() + 1)),
2896 );
2897 tree.layout(SizeProposal::exact(200.0, 100.0));
2898 tree.set_at_client_attached(true);
2899 assert!(!tree.explore_by_touch_active());
2900
2901 let at = Point::new(50.0, 50.0);
2902 let pid = PointerIdAllocator::global().begin(BackendDeviceKey::new(0x0A11), 1);
2903 let contact = |phase| PointerSample {
2904 pointer: PointerInfo::touch(pid, EventTime::ZERO),
2905 phase,
2906 position: at,
2907 button: None,
2908 modifiers: Modifiers::NONE,
2909 coalesced: Vec::new(),
2910 };
2911 tree.dispatch_pointer(contact(PointerPhase::Down));
2912 tree.dispatch_pointer(contact(PointerPhase::Up));
2913 assert!(tree.bounds(id).width > 0.0);
2914 assert_eq!(taps.get(), 1, "a touch tap must still activate its handler");
2915 }
2916
2917 #[test]
2918 fn a_detaching_client_is_evidence_that_no_screen_reader_is_reading() {
2919 let mut tree = WidgetTree::new();
2923 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
2924 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
2925 tree.set_at_client_attached(true);
2926 assert!(tree.explore_by_touch_active());
2927 tree.set_at_client_attached(false);
2928 assert_eq!(
2929 tree.screen_reader_state(),
2930 crate::ScreenReaderState::Inactive
2931 );
2932 assert!(!tree.explore_by_touch_active());
2933 }
2934
2935 #[test]
2936 fn a_repeated_detach_notice_does_not_clobber_a_fresh_os_reading() {
2937 let mut tree = WidgetTree::new();
2941 tree.set_at_client_attached(false);
2942 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
2943 tree.set_at_client_attached(false);
2944 assert_eq!(tree.screen_reader_state(), crate::ScreenReaderState::Active);
2945 }
2946
2947 #[test]
2950 fn a_density_switch_announces_once_in_the_applications_own_words() {
2951 use teksilo_tokens::TargetDensity;
2952 let mut tree = tree_with_one_widget();
2953 tree.set_density_announcement(Some(std::rc::Rc::new(|d: TargetDensity| {
2954 format!("layout {d:?}")
2955 })));
2956 let _ = tree.sync_accessibility();
2957
2958 tree.set_input_density(TargetDensity::Comfortable);
2959 let update = tree.sync_accessibility();
2960 assert_eq!(
2961 announced_by_consumer(&update),
2962 vec![("layout Comfortable".to_string(), accesskit::Live::Polite)]
2963 );
2964
2965 let _ = tree.sync_accessibility(); tree.set_input_density(TargetDensity::Comfortable);
2968 let update = tree.sync_accessibility();
2969 assert_eq!(announced_by_consumer(&update), Vec::new());
2970 }
2971
2972 #[test]
2973 fn a_density_switch_is_silent_without_a_registered_wording() {
2974 use teksilo_tokens::TargetDensity;
2979 let mut tree = tree_with_one_widget();
2980 let _ = tree.sync_accessibility();
2981 tree.set_input_density(TargetDensity::Comfortable);
2982 let update = tree.sync_accessibility();
2983 assert_eq!(announced_by_consumer(&update), Vec::new());
2984 }
2985
2986 #[test]
2987 fn an_announcement_stays_quiet_when_the_widget_already_speaks() {
2988 let mut tree = WidgetTree::new();
2989 use crate::widget_builder::WidgetBuilder;
2990 let widget = tree.add(
2991 FillWidget::new()
2992 .label("Saved")
2993 .access_live(accesskit::Live::Polite),
2994 );
2995 tree.layout(SizeProposal::exact(200.0, 100.0));
2996 let _ = tree.sync_accessibility();
2998
2999 assert!(
3000 !tree.announce_unless_widget_speaks(widget, "Saved"),
3001 "a widget with its own live region must not be doubled"
3002 );
3003 let update = tree.sync_accessibility();
3004 let live = live_nodes_in_filtered_tree(&update);
3008 assert_eq!(live.len(), 1, "exactly one voice, not two: {live:?}");
3009 }
3010
3011 #[test]
3012 fn an_announcement_goes_ahead_when_the_widget_is_silent() {
3013 let mut tree = WidgetTree::new();
3014 let widget = tree.add(FillWidget::new().label("Row"));
3015 tree.layout(SizeProposal::exact(200.0, 100.0));
3016 let _ = tree.sync_accessibility();
3017
3018 assert!(tree.announce_unless_widget_speaks(widget, "Moved to 3 of 12"));
3019 let update = tree.sync_accessibility();
3020 assert_eq!(
3021 announced_by_consumer(&update),
3022 vec![("Moved to 3 of 12".to_string(), accesskit::Live::Polite)]
3023 );
3024 }
3025
3026 #[test]
3027 fn a_live_region_with_no_text_does_not_count_as_speaking() {
3028 let mut tree = WidgetTree::new();
3031 use crate::widget_builder::WidgetBuilder;
3032 let widget = tree.add(FillWidget::new().access_live(accesskit::Live::Polite));
3033 tree.layout(SizeProposal::exact(200.0, 100.0));
3034 let _ = tree.sync_accessibility();
3035 assert!(tree.announce_unless_widget_speaks(widget, "Something happened"));
3036 }
3037
3038 #[derive(Debug)]
3041 struct SpeakingSynthetic;
3042
3043 impl crate::widget::Widget for SpeakingSynthetic {
3044 fn layout_response(
3045 &self,
3046 proposal: SizeProposal,
3047 _ctx: &crate::widget::LayoutContext,
3048 ) -> crate::widget::LayoutResponse {
3049 proposal.resolve(0.0, 0.0).into()
3050 }
3051
3052 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3053 builder.set_role(accesskit::Role::Group);
3054 let child = builder.push_annotation_child(1, "2 items selected");
3055 builder.with_collected_node(child, |node| node.set_live(accesskit::Live::Polite));
3056 }
3057 }
3058
3059 #[test]
3060 fn a_live_synthetic_child_counts_as_the_widget_speaking() {
3061 let mut tree = WidgetTree::new();
3066 let widget = tree.add(SpeakingSynthetic);
3067 tree.layout(SizeProposal::exact(200.0, 100.0));
3068 let update = tree.sync_accessibility();
3069 assert!(
3070 update
3071 .nodes
3072 .iter()
3073 .any(|(id, _)| crate::accessibility::is_synthetic(*id)),
3074 "the fixture must actually emit a synthetic child"
3075 );
3076 assert!(!tree.announce_unless_widget_speaks(widget, "2 items selected"));
3077 }
3078
3079 #[test]
3080 fn a_descendants_live_region_silences_its_ancestor_too() {
3081 let mut tree = WidgetTree::new();
3084 let container = tree.add(StackWidget::new());
3085 use crate::widget_builder::WidgetBuilder;
3086 let inner = FillWidget::new()
3087 .label("3 results")
3088 .access_live(accesskit::Live::Polite);
3089 tree.add_child(container, inner);
3090 tree.layout(SizeProposal::exact(200.0, 100.0));
3091 let _ = tree.sync_accessibility();
3092 assert!(!tree.announce_unless_widget_speaks(container, "3 results"));
3093 }
3094}