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 {
180 let Some(delta) = moved.get(&owner) else {
181 continue;
182 };
183 let Some(&slot) = index.get(&syn_id) else {
184 continue;
185 };
186 let node = &mut cached.nodes[slot].1;
187 if node.transform().is_some() {
195 continue;
196 }
197 if let Some(local) = self.synthetic_local_bounds.get(&syn_id) {
198 let origin = self.arena.bounds(owner).origin();
199 node.set_bounds(to_accesskit_rect(teksilo_canvas::Rect::new(
200 origin.x + local.x,
201 origin.y + local.y,
202 local.width,
203 local.height,
204 )));
205 } else if let Some(rect) = node.bounds() {
206 node.set_bounds(accesskit::Rect {
207 x0: rect.x0 + delta.x as f64,
208 y0: rect.y0 + delta.y as f64,
209 x1: rect.x1 + delta.x as f64,
210 y1: rect.y1 + delta.y as f64,
211 });
212 }
213 }
214 }
215
216 pub fn accessibility_tree_snapshot(&self) -> accesskit::TreeUpdate {
230 self.build_accessibility_tree().0
231 }
232
233 fn step_announcers(&mut self) -> bool {
236 let polite = self.announcer_polite.step();
240 let assertive = self.announcer_assertive.step();
241 if polite || assertive {
242 self.a11y_dirty = true;
243 }
244 polite || assertive
245 }
246
247 fn collect_announcements(&mut self, update: &accesskit::TreeUpdate) {
253 use accesskit::Live;
254 let mut seen: std::collections::HashSet<accesskit::NodeId> =
255 std::collections::HashSet::with_capacity(self.automation_last_text.len());
256 for (node_id, node) in &update.nodes {
257 let assertive = match node.live() {
258 Some(Live::Polite) => false,
259 Some(Live::Assertive) => true,
260 _ => continue,
262 };
263 let text = node
266 .value()
267 .or_else(|| node.label())
268 .unwrap_or("")
269 .trim()
270 .to_string();
271 if text.is_empty() {
272 self.automation_last_text.remove(node_id);
277 continue;
278 }
279 seen.insert(*node_id);
280 let changed = self
281 .automation_last_text
282 .get(node_id)
283 .map(|prev| prev != &text)
284 .unwrap_or(true);
285 if !changed {
286 continue;
287 }
288 self.automation_last_text.insert(*node_id, text.clone());
289 self.automation_announce_seq = self.automation_announce_seq.saturating_add(1);
290 if self.automation_announcements.len() >= super::AUTOMATION_ANNOUNCE_CAP {
291 self.automation_announcements.pop_front();
292 }
293 self.automation_announcements
294 .push_back(crate::accessibility::Announcement {
295 seq: self.automation_announce_seq,
296 text,
297 assertive,
298 });
299 }
300 self.automation_last_text.retain(|k, _| seen.contains(k));
303 }
304
305 pub fn dispatch_access_action(
328 &mut self,
329 node_id: accesskit::NodeId,
330 action: accesskit::Action,
331 data: Option<accesskit::ActionData>,
332 ops: &mut dyn crate::window::WindowOps,
333 ) -> bool {
334 let target = crate::accessibility::node_id_to_widget_id_maybe(node_id)
335 .or_else(|| self.widget_for_synthetic(node_id));
336 let event = crate::event::WidgetEvent::AccessAction {
337 action,
338 target,
339 target_node: node_id,
340 data,
341 };
342 self.access_action_handled = false;
343 self.dispatch_event_with_ops(event, ops);
344 self.access_action_handled
345 }
346
347 pub fn widget_for_synthetic(&self, node_id: accesskit::NodeId) -> Option<WidgetId> {
356 self.synthetic_parent_map.get(&node_id).copied()
357 }
358
359 pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
384 self.arena
385 .get(content_id)
386 .and_then(|node| node.widget.as_any())
387 .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
388 .and_then(|d| d.materialized_child())
389 .unwrap_or(content_id)
390 }
391
392 pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
393 let content_id = self.tooltip_content_node(content_id);
394 let node = self.arena.get(content_id)?;
395 let mut probe = AccessNodeBuilder::for_name_probe(content_id);
396 node.widget.accessibility(&mut probe);
397 if let Some(ov) = node.access_overrides.as_deref() {
398 ov.apply(&mut probe);
399 }
400 probe
401 .name()
402 .map(str::trim)
403 .filter(|name| !name.is_empty())
404 .map(str::to_owned)
405 }
406
407 pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
408 let node = self.arena.get(id).expect("widget id is active in arena");
409 let builder = self.build_overridden_builder(id);
410 let role = builder.role();
411 let name = builder.name().map(|s| s.to_string());
412 let actions = builder.actions().to_vec();
413 let mut info = AccessibilityInfo::new(role, name, actions);
414 if let Some(toggled) = builder.toggled() {
415 info = info.with_toggled(toggled);
416 }
417 if let Some(expanded) = builder.expanded() {
418 info = info.with_expanded(expanded);
419 }
420 if let Some(selected) = builder.selected() {
421 info = info.with_selected(selected);
422 }
423 let force_clear_disabled =
427 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
428 let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
429 let disabled_override =
434 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(true);
435 if disabled_arena || disabled_override {
436 info = info.with_disabled(true);
437 }
438 if builder.is_hidden() {
439 info = info.with_hidden(true);
440 }
441 info
442 }
443
444 pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
445 self.arena
446 .active_ids_iter()
447 .find(|&id| self.build_overridden_builder(id).role() == role)
448 }
449
450 pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
451 self.arena
452 .active_ids_iter()
453 .find(|&id| self.build_overridden_builder(id).name() == Some(label))
454 }
455
456 pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
457 self.arena.active_ids_iter().find(|&id| {
458 self.build_overridden_builder(id)
459 .actions()
460 .contains(&action)
461 })
462 }
463
464 pub fn text_content(&self, id: WidgetId) -> Option<String> {
468 self.arena.get(id)?;
469 self.build_overridden_builder(id)
470 .name()
471 .map(|s| s.to_string())
472 }
473
474 pub fn text_value(&self, id: WidgetId) -> Option<String> {
478 self.arena.get(id)?;
479 self.build_overridden_builder(id)
480 .value()
481 .map(|s| s.to_string())
482 }
483}
484
485pub(crate) fn emit_root_transform(root: &mut accesskit::Node, device_scale_factor: f32) {
514 root.set_transform(accesskit::Affine::scale(device_scale_factor as f64));
515}
516
517#[cfg(test)]
518pub(crate) mod test_helpers {
519 pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
525 accesskit_consumer::Tree::new(update.clone(), false);
526 }
527
528 pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
537 let emitted: std::collections::HashSet<accesskit::NodeId> =
538 update.nodes.iter().map(|(id, _)| *id).collect();
539 for (parent_id, node) in &update.nodes {
540 for &target in node.controls() {
541 assert!(
542 emitted.contains(&target),
543 "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
544 );
545 }
546 for &target in node.described_by() {
547 assert!(
548 emitted.contains(&target),
549 "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
550 );
551 }
552 for &target in node.labelled_by() {
553 assert!(
554 emitted.contains(&target),
555 "node {parent_id:?} has labelled_by() → {target:?} which is absent from the tree"
556 );
557 }
558 if let Some(target) = node.next_on_line() {
559 assert!(
560 emitted.contains(&target),
561 "node {parent_id:?} has next_on_line() → {target:?} which is absent from the tree"
562 );
563 }
564 if let Some(target) = node.previous_on_line() {
565 assert!(
566 emitted.contains(&target),
567 "node {parent_id:?} has previous_on_line() → {target:?} which is absent from the tree"
568 );
569 }
570 }
571 }
572
573 #[allow(dead_code)]
575 pub(crate) fn nodes_with_role(
576 update: &accesskit::TreeUpdate,
577 role: accesskit::Role,
578 ) -> Vec<accesskit::NodeId> {
579 update
580 .nodes
581 .iter()
582 .filter(|(_, node)| node.role() == role)
583 .map(|(id, _)| *id)
584 .collect()
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::test_helpers::*;
591 use super::*;
592 use crate::test_widgets::{FillWidget, StackWidget};
593
594 const ANNOUNCER_NODES: usize = 2;
598
599 fn announced_by_consumer(update: &accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
615 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
616 let state = consumer.state();
617 let mut found = Vec::new();
618 let mut stack = vec![state.root()];
619 while let Some(node) = stack.pop() {
620 if node.live() != accesskit::Live::Off
621 && let Some(label) = node.label()
622 {
623 found.push((label, node.live()));
624 }
625 for child in node.filtered_children(&accesskit_consumer::common_filter) {
626 stack.push(child);
627 }
628 }
629 found
630 }
631
632 fn live_nodes_in_filtered_tree(update: &accesskit::TreeUpdate) -> Vec<String> {
641 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
642 let state = consumer.state();
643 let mut found = Vec::new();
644 let mut stack = vec![state.root()];
645 while let Some(node) = stack.pop() {
646 if node.live() != accesskit::Live::Off {
647 found.push(format!("{:?}", node.id()));
648 }
649 for child in node.filtered_children(&accesskit_consumer::common_filter) {
650 stack.push(child);
651 }
652 }
653 found.sort();
654 found
655 }
656
657 fn tree_with_one_widget() -> WidgetTree {
658 let mut tree = WidgetTree::new();
659 tree.add(FillWidget::new().label("content"));
660 tree.layout(SizeProposal::exact(200.0, 100.0));
661 tree
662 }
663
664 #[test]
667 fn an_announcement_enters_the_filtered_tree_then_leaves_it() {
668 let mut tree = tree_with_one_widget();
669 let update = tree.sync_accessibility();
672 assert_eq!(announced_by_consumer(&update), Vec::new());
673 assert_eq!(
674 live_nodes_in_filtered_tree(&update),
675 Vec::<String>::new(),
676 "an idle announcer must be outside the filtered tree"
677 );
678 assert_a11y_tree_valid(&update);
679
680 tree.announce("Event added");
681
682 let exposed = tree.sync_accessibility();
683 assert_eq!(
684 announced_by_consumer(&exposed),
685 vec![("Event added".to_string(), accesskit::Live::Polite)],
686 "the message must be a label on a live node inside the filtered tree"
687 );
688 assert_eq!(
689 live_nodes_in_filtered_tree(&exposed).len(),
690 1,
691 "exactly the polite announcer must have entered the filtered tree"
692 );
693 assert_a11y_tree_valid(&exposed);
694
695 let retracted = tree.sync_accessibility();
696 assert_eq!(
697 announced_by_consumer(&retracted),
698 Vec::new(),
699 "the node must stop carrying the message"
700 );
701 assert_eq!(
707 live_nodes_in_filtered_tree(&retracted),
708 Vec::<String>::new(),
709 "the live node must leave the filtered tree entirely, not merely \
710 lose its label"
711 );
712 assert_a11y_tree_valid(&retracted);
713 }
714
715 #[test]
720 fn the_same_message_announced_twice_is_exposed_twice() {
721 let mut tree = tree_with_one_widget();
722 tree.announce("Saved");
723 tree.announce("Saved");
724
725 let mut exposures = 0;
726 for _ in 0..6 {
727 let update = tree.sync_accessibility();
728 if announced_by_consumer(&update)
729 .iter()
730 .any(|(text, _)| text == "Saved")
731 {
732 exposures += 1;
733 }
734 }
735 assert_eq!(exposures, 2, "each announcement needs its own exposure");
736 }
737
738 #[test]
739 fn an_assertive_announcement_uses_the_assertive_live_setting() {
740 let mut tree = tree_with_one_widget();
741 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
742 let update = tree.sync_accessibility();
743 assert_eq!(
744 announced_by_consumer(&update),
745 vec![("Could not save".to_string(), accesskit::Live::Assertive)]
746 );
747 }
748
749 #[test]
752 fn the_two_politeness_levels_announce_independently() {
753 let mut tree = tree_with_one_widget();
754 tree.announce("Event added");
755 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
756
757 let mut spoken = announced_by_consumer(&tree.sync_accessibility());
758 spoken.sort_by(|a, b| a.0.cmp(&b.0));
759 assert_eq!(
760 spoken,
761 vec![
762 ("Could not save".to_string(), accesskit::Live::Assertive),
763 ("Event added".to_string(), accesskit::Live::Polite),
764 ],
765 "both levels must be exposed on the same update"
766 );
767 }
768
769 #[test]
773 fn announcing_requests_the_syncs_it_needs() {
774 let mut tree = tree_with_one_widget();
775 let _ = tree.sync_accessibility();
776 tree.a11y_update_requested.set(false);
777 tree.frame_tick_requested.set(false);
778
779 tree.announce("Event added");
780 assert!(
781 tree.a11y_update_requested.get(),
782 "an announcement must dirty the accessibility tree"
783 );
784 assert!(
785 tree.frame_requested(),
786 "an announcement must ask for the frame that carries it"
787 );
788
789 tree.a11y_update_requested.set(false);
791 tree.frame_tick_requested.set(false);
792 let _ = tree.sync_accessibility();
793 assert!(tree.a11y_update_requested.get());
794 assert!(tree.frame_requested());
795
796 tree.a11y_update_requested.set(false);
798 tree.frame_tick_requested.set(false);
799 let _ = tree.sync_accessibility();
800 assert!(!tree.a11y_update_requested.get());
801 assert!(!tree.frame_requested());
802 }
803
804 #[test]
807 fn an_announcement_reaches_the_automation_ring() {
808 let mut tree = tree_with_one_widget();
809 let before = tree.announcements_since(0).len() as u64;
810 tree.announce("Event added");
811 let _ = tree.sync_accessibility();
812 let got = tree.announcements_since(before);
813 assert_eq!(got.len(), 1);
814 assert_eq!(got[0].text, "Event added");
815 assert!(!got[0].assertive);
816 }
817
818 #[test]
822 fn a_tree_snapshot_does_not_consume_a_pending_announcement() {
823 let mut tree = tree_with_one_widget();
824 tree.announce("Event added");
825
826 let snapshot = tree.accessibility_tree_snapshot();
827 assert_eq!(
828 announced_by_consumer(&snapshot),
829 Vec::new(),
830 "a snapshot must not advance the announcer"
831 );
832
833 let delivered = tree.sync_accessibility();
834 assert_eq!(
835 announced_by_consumer(&delivered),
836 vec![("Event added".to_string(), accesskit::Live::Polite)],
837 "the message must still be there to deliver"
838 );
839 }
840
841 #[test]
844 fn an_empty_announcement_is_not_exposed() {
845 let mut tree = tree_with_one_widget();
846 let _ = tree.sync_accessibility();
847 tree.announce(" ");
848 let update = tree.sync_accessibility();
849 assert_eq!(announced_by_consumer(&update), Vec::new());
850 }
851
852 #[derive(Debug)]
853 struct ActionWidget;
854
855 impl Widget for ActionWidget {
856 fn layout_response(
857 &self,
858 proposal: SizeProposal,
859 _ctx: &LayoutContext,
860 ) -> crate::widget::LayoutResponse {
861 proposal.resolve(0.0, 0.0).into()
862 }
863
864 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
865 builder.set_role(accesskit::Role::Button);
866 builder.set_name("Save");
867 builder.add_action(accesskit::Action::Click);
868 builder.add_action(accesskit::Action::Focus);
869 }
870 }
871
872 #[derive(Debug)]
873 struct ClickableWidget;
874
875 impl Widget for ClickableWidget {
876 fn layout_response(
877 &self,
878 proposal: SizeProposal,
879 _ctx: &LayoutContext,
880 ) -> crate::widget::LayoutResponse {
881 proposal.resolve(0.0, 0.0).into()
882 }
883
884 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
885 builder.set_role(accesskit::Role::Button);
886 builder.set_name("Click Me");
887 builder.add_action(accesskit::Action::Click);
888 }
889 }
890
891 #[test]
896 fn a_focusable_node_advertises_focus_without_the_widget_saying_so() {
897 let mut tree = WidgetTree::new();
898 let id = tree.add(ClickableWidget.focusable(true));
900 tree.layout(SizeProposal::exact(100.0, 100.0));
901
902 let info = tree.accessibility_node(id);
903 assert!(
904 info.actions().contains(&accesskit::Action::Focus),
905 "the arena knows the node is focusable; the AT node must say so"
906 );
907
908 let mut ops = crate::window::NoopWindowOps;
910 assert!(tree.dispatch_access_action(
911 crate::accessibility::widget_id_to_node_id(id),
912 accesskit::Action::Focus,
913 None,
914 &mut ops,
915 ));
916 assert_eq!(tree.focused(), Some(id));
917 }
918
919 #[derive(Debug)]
925 struct CompositeWidget {
926 child_ids: Vec<WidgetId>,
927 }
928
929 impl Widget for CompositeWidget {
930 fn layout_response(
931 &self,
932 proposal: SizeProposal,
933 _ctx: &LayoutContext,
934 ) -> crate::widget::LayoutResponse {
935 proposal.resolve(0.0, 0.0).into()
936 }
937
938 fn place_children(
939 &self,
940 bounds: Rect,
941 _proposal: SizeProposal,
942 children: &mut [crate::widget::WidgetPlacement],
943 _ctx: &LayoutContext,
944 ) {
945 for child in children.iter_mut() {
946 child.origin = bounds.origin();
947 child.size = bounds.size();
948 }
949 }
950
951 fn children(&self) -> Vec<WidgetId> {
952 self.child_ids.clone()
953 }
954
955 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
956 builder.set_role(accesskit::Role::SpinButton);
957 builder.add_action(accesskit::Action::Focus);
958 }
959 }
960
961 #[test]
972 fn an_at_focus_on_a_non_focusable_composite_lands_on_its_focusable_leaf() {
973 let mut tree = WidgetTree::new();
974 let leaf = tree.add(ClickableWidget.focusable(true));
975 let root = tree.add(CompositeWidget {
976 child_ids: vec![leaf],
977 });
978 tree.layout(SizeProposal::exact(100.0, 100.0));
979
980 let mut ops = crate::window::NoopWindowOps;
981 assert!(tree.dispatch_access_action(
982 crate::accessibility::widget_id_to_node_id(root),
983 accesskit::Action::Focus,
984 None,
985 &mut ops,
986 ));
987 assert_eq!(
988 tree.focused(),
989 Some(leaf),
990 "focus must reach the focusable leaf, not the composite root"
991 );
992 }
993
994 #[test]
1003 fn an_at_focus_on_a_plain_container_does_not_walk_into_it() {
1004 let mut tree = WidgetTree::new();
1005 let leaf = tree.add(ClickableWidget.focusable(true));
1006 let root = tree.add(StackWidget::new().child(leaf));
1007 tree.layout(SizeProposal::exact(100.0, 100.0));
1008
1009 let mut ops = crate::window::NoopWindowOps;
1010 assert!(
1011 !tree.dispatch_access_action(
1012 crate::accessibility::widget_id_to_node_id(root),
1013 accesskit::Action::Focus,
1014 None,
1015 &mut ops,
1016 ),
1017 "a node that never advertised `Focus` must report the action unhandled"
1018 );
1019 assert_eq!(
1020 tree.focused(),
1021 None,
1022 "focus must stay where it was, not fall into the container's first control"
1023 );
1024 }
1025
1026 #[test]
1029 fn an_at_focus_on_a_focusable_node_still_lands_on_that_node() {
1030 let mut tree = WidgetTree::new();
1031 let outer = tree.add(ClickableWidget.focusable(true));
1032 tree.layout(SizeProposal::exact(100.0, 100.0));
1033
1034 let mut ops = crate::window::NoopWindowOps;
1035 assert!(tree.dispatch_access_action(
1036 crate::accessibility::widget_id_to_node_id(outer),
1037 accesskit::Action::Focus,
1038 None,
1039 &mut ops,
1040 ));
1041 assert_eq!(tree.focused(), Some(outer));
1042 }
1043
1044 #[test]
1047 fn a_derived_focus_action_is_still_removable_by_an_override() {
1048 let mut tree = WidgetTree::new();
1049 let id = tree.add(
1050 ClickableWidget
1051 .focusable(true)
1052 .access_remove_action(accesskit::Action::Focus),
1053 );
1054 tree.layout(SizeProposal::exact(100.0, 100.0));
1055 assert!(
1056 !tree
1057 .accessibility_node(id)
1058 .actions()
1059 .contains(&accesskit::Action::Focus)
1060 );
1061 }
1062
1063 #[test]
1066 fn a_non_focusable_node_does_not_advertise_focus() {
1067 let mut tree = WidgetTree::new();
1068 let id = tree.add(ClickableWidget);
1069 tree.layout(SizeProposal::exact(100.0, 100.0));
1070 assert!(
1071 !tree
1072 .accessibility_node(id)
1073 .actions()
1074 .contains(&accesskit::Action::Focus)
1075 );
1076 }
1077
1078 #[test]
1079 fn labeled_widget_has_accessibility() {
1080 let mut tree = WidgetTree::new();
1081 let widget = tree.add(FillWidget::new().label("Hello"));
1082 tree.layout(SizeProposal::exact(100.0, 20.0));
1083 let info = tree.accessibility_node(widget);
1084 assert_eq!(info.role(), accesskit::Role::Label);
1085 assert_eq!(info.name(), Some("Hello"));
1086 }
1087
1088 #[test]
1089 fn find_by_label_works() {
1090 let mut tree = WidgetTree::new();
1091 let widget = tree.add(FillWidget::new().label("Save"));
1092 tree.layout(SizeProposal::exact(100.0, 20.0));
1093 assert_eq!(tree.find_by_label("Save"), Some(widget));
1094 }
1095
1096 #[test]
1097 fn label_role_emits_text_as_value_not_label() {
1098 let mut tree = WidgetTree::new();
1105 let widget = tree.add(FillWidget::new().label("Section")); tree.layout(SizeProposal::exact(100.0, 20.0));
1107
1108 assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1110
1111 let update = tree.sync_accessibility();
1113 let node = &update
1114 .nodes
1115 .iter()
1116 .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1117 .expect("label node must be present in the tree update")
1118 .1;
1119 assert_eq!(node.role(), accesskit::Role::Label);
1120 assert_eq!(
1121 node.value(),
1122 Some("Section"),
1123 "Role::Label text must be exposed via `value`"
1124 );
1125 assert_eq!(
1126 node.label(),
1127 None,
1128 "Role::Label must not leave text in the `label` property"
1129 );
1130 }
1131
1132 #[test]
1133 fn find_by_role_works() {
1134 let mut tree = WidgetTree::new();
1135 tree.add(FillWidget::new().label("Text"));
1136 tree.layout(SizeProposal::exact(100.0, 20.0));
1137 assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1138 }
1139
1140 #[test]
1141 fn access_hidden_bound_to_signal_toggles_reactively() {
1142 use crate::signal::Signal;
1143 use crate::widget_builder::WidgetBuilder;
1144 let hidden = Signal::new(false);
1145 let mut tree = WidgetTree::new();
1146 let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1147 tree.layout(SizeProposal::exact(100.0, 30.0));
1148 assert!(
1149 !tree.accessibility_node(w).is_hidden(),
1150 "node should be visible to AT while the signal is false"
1151 );
1152 hidden.set(true);
1153 assert!(
1155 tree.accessibility_node(w).is_hidden(),
1156 "node should hide from AT when the bound signal flips to true"
1157 );
1158 hidden.set(false);
1159 assert!(!tree.accessibility_node(w).is_hidden());
1160 }
1161
1162 #[test]
1166 fn a_widget_with_a_context_menu_advertises_the_action() {
1167 let mut tree = WidgetTree::new();
1168 let plain = tree.add(FillWidget::new().label("no menu"));
1169 let with_menu = tree.add(
1170 FillWidget::new()
1171 .label("has menu")
1172 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1173 );
1174 tree.layout(SizeProposal::exact(100.0, 20.0));
1175
1176 assert!(
1177 tree.accessibility_node(with_menu)
1178 .actions()
1179 .contains(&Action::ShowContextMenu),
1180 "a node owning a context-menu factory must announce it"
1181 );
1182 assert!(
1183 !tree
1184 .accessibility_node(plain)
1185 .actions()
1186 .contains(&Action::ShowContextMenu),
1187 "and a node without one must not"
1188 );
1189 }
1190
1191 #[test]
1194 fn a_removed_context_menu_action_stays_removed() {
1195 let mut tree = WidgetTree::new();
1196 let id = tree.add(
1197 FillWidget::new()
1198 .label("suppressed")
1199 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1200 .access_remove_action(Action::ShowContextMenu),
1201 );
1202 tree.layout(SizeProposal::exact(100.0, 20.0));
1203 assert!(
1204 !tree
1205 .accessibility_node(id)
1206 .actions()
1207 .contains(&Action::ShowContextMenu)
1208 );
1209 }
1210
1211 #[test]
1216 fn a_child_does_not_advertise_its_parents_context_menu() {
1217 let mut tree = WidgetTree::new();
1218 let child = tree.add(FillWidget::new().label("inner"));
1219 let parent = tree.add(
1220 StackWidget::new()
1221 .child(child)
1222 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1223 );
1224 tree.layout(SizeProposal::exact(100.0, 20.0));
1225
1226 assert!(
1227 tree.accessibility_node(parent)
1228 .actions()
1229 .contains(&Action::ShowContextMenu)
1230 );
1231 assert!(
1232 !tree
1233 .accessibility_node(child)
1234 .actions()
1235 .contains(&Action::ShowContextMenu),
1236 "the menu belongs to the widget that declared it"
1237 );
1238 }
1239
1240 #[test]
1241 fn accessibility_node_collects_actions() {
1242 let mut tree = WidgetTree::new();
1243 let widget = tree.add(ActionWidget);
1244 tree.layout(SizeProposal::exact(100.0, 40.0));
1245
1246 let info = tree.accessibility_node(widget);
1247 assert_eq!(info.role(), accesskit::Role::Button);
1248 assert_eq!(info.name(), Some("Save"));
1249 assert_eq!(info.actions().len(), 2);
1250 assert!(info.actions().contains(&accesskit::Action::Click));
1251 assert!(info.actions().contains(&accesskit::Action::Focus));
1252 }
1253
1254 #[test]
1255 fn sync_accessibility_produces_tree_update() {
1256 let mut tree = WidgetTree::new();
1257 tree.add(FillWidget::new().label("First"));
1258 tree.add(FillWidget::new().label("Second"));
1259 tree.layout(SizeProposal::exact(200.0, 100.0));
1260
1261 let update = tree.sync_accessibility();
1262 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1265 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1266 assert!(update.tree.is_some());
1267 assert_a11y_tree_valid(&update);
1268 }
1269
1270 #[test]
1271 fn root_node_carries_locale_as_language() {
1272 let mut tree = WidgetTree::new();
1273 tree.add(FillWidget::new().label("Bonjour"));
1274
1275 tree.layout(SizeProposal::exact(200.0, 100.0));
1277 let update = tree.sync_accessibility();
1278 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1279 assert_eq!(
1280 update.nodes[0].1.language(),
1281 None,
1282 "no language before a locale is set"
1283 );
1284
1285 tree.set_locale("fr-FR".to_string());
1288 let update = tree.sync_accessibility();
1289 assert_eq!(
1290 update.nodes[0].1.language(),
1291 Some("fr-FR"),
1292 "root node must advertise the active locale as its language"
1293 );
1294 }
1295
1296 #[test]
1297 fn sync_accessibility_excludes_dormant_widgets() {
1298 let mut tree = WidgetTree::new();
1299 tree.add(FillWidget::new().label("Active"));
1300 let dormant = tree.add(FillWidget::new().label("Dormant"));
1301 tree.layout(SizeProposal::exact(200.0, 100.0));
1302
1303 tree.set_dormant(dormant);
1304
1305 let update = tree.sync_accessibility();
1306 assert_eq!(update.nodes.len(), 2 + ANNOUNCER_NODES);
1307 assert_a11y_tree_valid(&update);
1308 }
1309
1310 #[test]
1317 fn relayout_without_activation_change_does_not_dirty_a11y() {
1318 let mut tree = WidgetTree::new();
1319 let id = tree.add(FillWidget::new().label("Static"));
1320 tree.layout(SizeProposal::exact(200.0, 100.0));
1321
1322 let _ = tree.sync_accessibility();
1324 assert!(
1325 !tree.a11y_dirty,
1326 "sync_accessibility must clear the dirty flag"
1327 );
1328
1329 tree.arena.mark_needs_layout(id);
1333 tree.layout(SizeProposal::exact(200.0, 100.0));
1334
1335 assert!(
1336 !tree.a11y_dirty,
1337 "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1338 );
1339 }
1340
1341 #[derive(Debug)]
1344 struct Mover {
1345 offset: crate::signal::Signal<f32>,
1346 child: WidgetId,
1347 }
1348
1349 impl crate::widget::Widget for Mover {
1350 fn children(&self) -> Vec<WidgetId> {
1351 vec![self.child]
1352 }
1353 fn layout_response(
1354 &self,
1355 proposal: SizeProposal,
1356 _ctx: &crate::widget::LayoutContext,
1357 ) -> crate::widget::LayoutResponse {
1358 proposal.resolve(200.0, 100.0).into()
1359 }
1360 fn place_children(
1361 &self,
1362 bounds: teksilo_canvas::Rect,
1363 _proposal: SizeProposal,
1364 children: &mut [crate::widget::WidgetPlacement],
1365 _ctx: &crate::widget::LayoutContext,
1366 ) {
1367 if let Some(placement) = children.first_mut() {
1368 placement.origin =
1369 teksilo_canvas::Point::new(bounds.x + self.offset.get(), bounds.y);
1370 placement.size = teksilo_canvas::Size::new(50.0, 20.0);
1371 }
1372 }
1373 }
1374
1375 #[test]
1382 fn translating_a_widget_patches_the_cache_without_a_walk() {
1383 let mut tree = WidgetTree::new();
1384 let inner = tree.add(FillWidget::new().label("Row"));
1385 let offset = crate::signal::Signal::new(0.0f32);
1386 let root = tree.add(Mover {
1387 offset: offset.clone(),
1388 child: inner,
1389 });
1390 tree.layout(SizeProposal::exact(200.0, 100.0));
1391 let update = tree.sync_accessibility();
1392 let walks = tree.a11y_walk_generation();
1393 let before = find_node(&update, inner)
1394 .unwrap()
1395 .bounds()
1396 .expect("the node carries bounds");
1397
1398 offset.set(20.0);
1400 tree.arena.mark_needs_layout(root);
1401 tree.layout(SizeProposal::exact(200.0, 100.0));
1402
1403 let update = tree.sync_accessibility();
1404 assert_eq!(
1405 tree.a11y_walk_generation(),
1406 walks,
1407 "a pure translation must not cost a walk"
1408 );
1409 let after = find_node(&update, inner)
1410 .unwrap()
1411 .bounds()
1412 .expect("the node still carries bounds");
1413 assert!(
1414 (after.x0 - before.x0 - 20.0).abs() < 0.01,
1415 "the cached node must follow the move: {before:?} -> {after:?}"
1416 );
1417 }
1418
1419 #[test]
1420 fn at_version_does_not_bump_for_a_move() {
1421 let mut tree = WidgetTree::new();
1425 let inner = tree.add(FillWidget::new().label("Row"));
1426 let offset = crate::signal::Signal::new(0.0f32);
1427 let root = tree.add(Mover {
1428 offset: offset.clone(),
1429 child: inner,
1430 });
1431 tree.layout(SizeProposal::exact(200.0, 100.0));
1432 let _ = tree.sync_accessibility();
1433 let version = tree.at_version().get();
1434
1435 offset.set(20.0);
1436 tree.arena.mark_needs_layout(root);
1437 tree.layout(SizeProposal::exact(200.0, 100.0));
1438 let _ = tree.sync_accessibility();
1439
1440 assert_eq!(tree.at_version().get(), version);
1441 }
1442
1443 #[test]
1444 fn resizing_a_widget_dirties_the_accessibility_tree() {
1445 let mut tree = WidgetTree::new();
1449 let id = tree.add(FillWidget::new().label("Static"));
1450 tree.layout(SizeProposal::exact(200.0, 100.0));
1451 let _ = tree.sync_accessibility();
1452 let walks = tree.a11y_walk_generation();
1453
1454 tree.layout(SizeProposal::exact(300.0, 100.0));
1455 let _ = tree.sync_accessibility();
1456
1457 assert!(
1458 tree.a11y_walk_generation() > walks,
1459 "a resize must be walked, not patched"
1460 );
1461 let _ = id;
1462 }
1463
1464 #[test]
1465 fn changing_the_theme_dirties_the_accessibility_tree() {
1466 let mut tree = WidgetTree::new();
1471 tree.add(FillWidget::new().label("Static"));
1472 tree.layout(SizeProposal::exact(200.0, 100.0));
1473 let _ = tree.sync_accessibility();
1474 let walks = tree.a11y_walk_generation();
1475
1476 tree.set_theme(crate::presets::intui::dark());
1477 let _ = tree.sync_accessibility();
1478
1479 assert!(tree.a11y_walk_generation() > walks);
1480 }
1481
1482 #[test]
1483 fn changing_the_text_scale_dirties_the_accessibility_tree() {
1484 let mut tree = WidgetTree::new();
1485 tree.add(FillWidget::new().label("Static"));
1486 tree.layout(SizeProposal::exact(200.0, 100.0));
1487 let _ = tree.sync_accessibility();
1488 let walks = tree.a11y_walk_generation();
1489
1490 tree.set_user_text_scale(1.5);
1491 let _ = tree.sync_accessibility();
1492
1493 assert!(tree.a11y_walk_generation() > walks);
1494 }
1495
1496 #[test]
1497 fn a_moved_node_absent_from_the_cache_is_skipped() {
1498 use crate::widget_builder::WidgetBuilder;
1503 let mut tree = WidgetTree::new();
1504 let hidden = tree.add(FillWidget::new().label("Inner"));
1505 let offset = crate::signal::Signal::new(0.0f32);
1506 let root = tree.add(
1507 Mover {
1508 offset: offset.clone(),
1509 child: hidden,
1510 }
1511 .access_exclude_subtree(),
1512 );
1513 tree.layout(SizeProposal::exact(200.0, 100.0));
1514 let update = tree.sync_accessibility();
1515 assert!(
1516 find_node(&update, hidden).is_none(),
1517 "the excluded descendant must be absent for this test to mean anything"
1518 );
1519
1520 offset.set(20.0);
1521 tree.arena.mark_needs_layout(root);
1522 tree.layout(SizeProposal::exact(200.0, 100.0));
1523 let update = tree.sync_accessibility();
1524
1525 assert!(find_node(&update, hidden).is_none());
1526 assert_no_dangling_relationships(&update);
1527 }
1528
1529 #[test]
1530 fn focus_on_a_node_the_walk_did_not_emit_resolves_to_an_emitted_ancestor() {
1531 use crate::widget_builder::WidgetBuilder;
1542 let mut tree = WidgetTree::new();
1543 let inner = tree.add(ClickableWidget);
1544 let outer = tree.add(
1545 Mover {
1546 offset: crate::signal::Signal::new(0.0f32),
1547 child: inner,
1548 }
1549 .access_exclude_subtree(),
1550 );
1551 tree.layout(SizeProposal::exact(200.0, 100.0));
1552 tree.focus(inner);
1553
1554 let update = tree.sync_accessibility();
1555 assert!(
1556 find_node(&update, inner).is_none(),
1557 "precondition: the excluded descendant emits nothing"
1558 );
1559 let emitted: std::collections::HashSet<_> =
1560 update.nodes.iter().map(|(id, _)| *id).collect();
1561 assert!(
1562 emitted.contains(&update.focus),
1563 "the published focus must be a node in the published tree"
1564 );
1565 assert_eq!(
1566 update.focus,
1567 crate::accessibility::widget_id_to_node_id(outer),
1568 "…and specifically the nearest ancestor that did emit"
1569 );
1570 accesskit_consumer::Tree::new(update, false);
1572 }
1573
1574 #[test]
1578 fn activation_transition_does_dirty_a11y() {
1579 let mut tree = WidgetTree::new();
1580 let id = tree.add(FillWidget::new().label("Toggle"));
1581 tree.layout(SizeProposal::exact(200.0, 100.0));
1582 let _ = tree.sync_accessibility();
1583 assert!(!tree.a11y_dirty);
1584
1585 tree.set_dormant(id);
1586 tree.layout(SizeProposal::exact(200.0, 100.0));
1587 assert!(
1588 tree.a11y_dirty,
1589 "active→dormant transition must dirty the AT cache so the dormant node is removed"
1590 );
1591 }
1592
1593 #[test]
1594 fn sync_accessibility_includes_focus() {
1595 let mut tree = WidgetTree::new();
1596 let widget = tree.add(FillWidget::new().focusable().label("Focused"));
1597 tree.layout(SizeProposal::exact(100.0, 50.0));
1598 tree.focus(widget);
1599
1600 let update = tree.sync_accessibility();
1601 let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
1602 assert_eq!(update.focus, expected_focus);
1603 assert_a11y_tree_valid(&update);
1604 }
1605
1606 #[test]
1607 fn sync_accessibility_parent_child_relationship() {
1608 let mut tree = WidgetTree::new();
1609 let child = tree.add(FillWidget::new().label("Child"));
1610 let parent = tree.add(
1613 StackWidget::new()
1614 .child(child)
1615 .access_label_literal("Parent"),
1616 );
1617 tree.layout(SizeProposal::exact(100.0, 50.0));
1618
1619 let update = tree.sync_accessibility();
1620 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1621
1622 let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
1623 let parent_node = update
1624 .nodes
1625 .iter()
1626 .find(|(id, _)| *id == parent_node_id)
1627 .map(|(_, node)| node)
1628 .unwrap();
1629
1630 let child_node_id = crate::accessibility::widget_id_to_node_id(child);
1631 assert!(parent_node.children().contains(&child_node_id));
1632 assert_a11y_tree_valid(&update);
1633 }
1634
1635 #[test]
1636 fn presentational_containers_collapse_and_promote_children() {
1637 let mut tree = WidgetTree::new();
1643 let leaf = tree.add(FillWidget::new().label("Leaf")); let inner = tree.add(StackWidget::new().child(leaf)); let outer = tree.add(StackWidget::new().child(inner)); let labeled = tree.add(
1647 StackWidget::new()
1648 .child(outer)
1649 .access_label_literal("Group"),
1650 );
1651 tree.layout(SizeProposal::exact(100.0, 50.0));
1652 let update = tree.sync_accessibility();
1653
1654 assert!(find_node(&update, inner).is_none(), "bare inner pruned");
1655 assert!(find_node(&update, outer).is_none(), "bare outer pruned");
1656
1657 let labeled_node = find_node(&update, labeled).expect("labeled group survives");
1658 let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
1659 assert!(
1660 labeled_node.children().contains(&leaf_nid),
1661 "leaf promoted past both bare containers to the labeled ancestor"
1662 );
1663 assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
1664 assert_a11y_tree_valid(&update);
1665 }
1666
1667 #[test]
1668 fn find_by_action_finds_clickable() {
1669 let mut tree = WidgetTree::new();
1670 let widget = tree.add(ClickableWidget);
1671 tree.layout(SizeProposal::exact(100.0, 40.0));
1672
1673 assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
1674 assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
1675 }
1676
1677 #[test]
1678 fn text_content_returns_accessibility_name() {
1679 let mut tree = WidgetTree::new();
1680 let widget = tree.add(FillWidget::new().label("Hello World"));
1681 tree.layout(SizeProposal::exact(100.0, 50.0));
1682
1683 assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
1684 }
1685
1686 #[test]
1687 fn text_content_returns_none_without_label() {
1688 let mut tree = WidgetTree::new();
1689 let widget = tree.add(FillWidget::new());
1690 tree.layout(SizeProposal::exact(100.0, 50.0));
1691
1692 assert_eq!(tree.text_content(widget), None);
1693 }
1694
1695 #[test]
1696 fn descendant_of_disabled_ancestor_reports_disabled() {
1697 use crate::signal::Signal;
1698
1699 let mut tree = WidgetTree::new();
1700 let child = tree.add(FillWidget::new().label("Child"));
1701 let parent = tree.add(StackWidget::new().child(child));
1702 tree.enabled_when(parent, Signal::new(false));
1703 tree.layout(SizeProposal::exact(100.0, 50.0));
1704
1705 assert!(
1706 tree.accessibility_node(child).is_disabled(),
1707 "descendant should report disabled when ancestor is disabled"
1708 );
1709 }
1710
1711 #[test]
1712 fn text_value_returns_accessibility_value() {
1713 #[derive(Debug)]
1714 struct ValueWidget;
1715
1716 impl Widget for ValueWidget {
1717 fn layout_response(
1718 &self,
1719 proposal: SizeProposal,
1720 _ctx: &LayoutContext,
1721 ) -> crate::widget::LayoutResponse {
1722 proposal.resolve(0.0, 0.0).into()
1723 }
1724
1725 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1726 builder.set_role(accesskit::Role::Slider);
1727 builder.set_name("Volume");
1728 builder.set_value("75%");
1729 }
1730 }
1731
1732 let mut tree = WidgetTree::new();
1733 let widget = tree.add(ValueWidget);
1734 tree.layout(SizeProposal::exact(100.0, 40.0));
1735
1736 assert_eq!(tree.text_value(widget), Some("75%".to_string()));
1737 assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
1738 }
1739
1740 #[test]
1741 fn sync_accessibility_has_no_duplicate_children() {
1742 let mut tree = WidgetTree::new();
1746 let grandchild = tree.add(FillWidget::new().label("Grandchild"));
1747 let child_a = tree.add(StackWidget::new().child(grandchild));
1748 let child_b = tree.add(FillWidget::new().label("Sibling"));
1749 let _root = tree.add(StackWidget::new().child(child_a).child(child_b));
1750 tree.layout(SizeProposal::exact(200.0, 100.0));
1751
1752 let update = tree.sync_accessibility();
1753
1754 let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
1755 std::collections::HashMap::new();
1756 for (parent_id, node) in &update.nodes {
1757 for &child_id in node.children() {
1758 let prev = all_children.insert(child_id, *parent_id);
1759 assert!(
1760 prev.is_none(),
1761 "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
1762 );
1763 }
1764 }
1765 assert_a11y_tree_valid(&update);
1766 }
1767
1768 #[test]
1769 fn no_dangling_relationships_in_basic_tree() {
1770 let mut tree = WidgetTree::new();
1771 let child = tree.add(FillWidget::new().label("Child"));
1772 let _parent = tree.add(StackWidget::new().child(child));
1773 tree.layout(SizeProposal::exact(100.0, 50.0));
1774
1775 let update = tree.sync_accessibility();
1776 assert_no_dangling_relationships(&update);
1777 assert_a11y_tree_valid(&update);
1778 }
1779
1780 use crate::widget_builder::WidgetBuilder;
1785 use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
1786
1787 fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
1789 let nid = crate::accessibility::widget_id_to_node_id(id);
1790 update
1791 .nodes
1792 .iter()
1793 .find(|(node_id, _)| *node_id == nid)
1794 .map(|(_, n)| n)
1795 }
1796
1797 #[derive(Debug)]
1800 struct AlwaysHiddenWidget;
1801 impl Widget for AlwaysHiddenWidget {
1802 fn layout_response(
1803 &self,
1804 proposal: SizeProposal,
1805 _ctx: &LayoutContext,
1806 ) -> crate::widget::LayoutResponse {
1807 proposal.resolve(0.0, 0.0).into()
1808 }
1809 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1810 builder.set_role(Role::GenericContainer);
1811 builder.set_hidden();
1812 }
1813 }
1814
1815 #[test]
1817 fn access_label_replaces_widget_label() {
1818 let mut tree = WidgetTree::new();
1819 let id = tree.add(ClickableWidget.access_label_literal("Publish"));
1820 tree.layout(SizeProposal::exact(100.0, 40.0));
1821 assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
1822 }
1823
1824 #[test]
1826 fn access_description_appears_on_bare_widget() {
1827 let mut tree = WidgetTree::new();
1828 let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
1829 tree.layout(SizeProposal::exact(50.0, 50.0));
1830 let update = tree.sync_accessibility();
1831 let node = find_node(&update, id).expect("node present");
1832 assert_eq!(node.description(), Some("Decorative"));
1833 }
1834
1835 #[test]
1837 fn access_value_replaces_widget_value() {
1838 #[derive(Debug)]
1839 struct SliderWidget;
1840 impl Widget for SliderWidget {
1841 fn layout_response(
1842 &self,
1843 proposal: SizeProposal,
1844 _ctx: &LayoutContext,
1845 ) -> crate::widget::LayoutResponse {
1846 proposal.resolve(0.0, 0.0).into()
1847 }
1848 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1849 builder.set_role(Role::Slider);
1850 builder.set_value("50");
1851 }
1852 }
1853 let mut tree = WidgetTree::new();
1854 let id = tree.add(SliderWidget.access_value_literal("Custom"));
1855 tree.layout(SizeProposal::exact(100.0, 40.0));
1856 assert_eq!(tree.text_value(id), Some("Custom".to_string()));
1857 }
1858
1859 #[test]
1861 fn access_role_overrides_widget_role() {
1862 let mut tree = WidgetTree::new();
1863 let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
1864 tree.layout(SizeProposal::exact(100.0, 40.0));
1865 assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
1866 }
1867
1868 #[test]
1869 fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
1870 let mut tree = WidgetTree::new();
1877 let anchor = tree.add(FillWidget::new().label("Save"));
1878 let tip = tree.add(FillWidget::new().label("Save the file"));
1879 tree.layout(SizeProposal::exact(200.0, 100.0));
1880 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
1881
1882 assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
1883 let update = tree.sync_accessibility();
1884 let node = find_node(&update, anchor).expect("anchor node present");
1885 assert_eq!(
1886 node.description(),
1887 Some("Save the file"),
1888 "the anchor must describe itself with its tooltip text while unshown"
1889 );
1890 }
1891
1892 #[test]
1893 fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
1894 let mut tree = WidgetTree::new();
1897 let anchor = tree.add(FillWidget::new().label("Save"));
1898 let tip = tree.add(FillWidget::new().label("Save the file"));
1899 tree.layout(SizeProposal::exact(200.0, 100.0));
1900 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
1901
1902 tree.pointer_move(tree.bounds(anchor).center());
1903 tree.advance_time(std::time::Duration::from_millis(150));
1904 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
1905
1906 let update = tree.sync_accessibility();
1907 let node = find_node(&update, anchor).expect("anchor node present");
1908 assert!(
1909 !node.described_by().is_empty(),
1910 "a shown tooltip is wired as a described_by relation"
1911 );
1912 }
1913
1914 #[test]
1916 fn access_hint_alias_writes_description_field() {
1917 let mut tree = WidgetTree::new();
1918 let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
1919 tree.layout(SizeProposal::exact(50.0, 50.0));
1920 let update = tree.sync_accessibility();
1921 let node = find_node(&update, id).unwrap();
1922 assert_eq!(node.description(), Some("Tip"));
1923 }
1924
1925 #[test]
1927 fn access_identifier_writes_author_id() {
1928 let mut tree = WidgetTree::new();
1929 let id = tree.add(FillWidget::new().access_identifier("save-button"));
1930 tree.layout(SizeProposal::exact(50.0, 50.0));
1931 let update = tree.sync_accessibility();
1932 let node = find_node(&update, id).unwrap();
1933 assert_eq!(node.author_id(), Some("save-button"));
1934 }
1935
1936 #[test]
1938 fn access_shortcut_literal_set() {
1939 let mut tree = WidgetTree::new();
1940 let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
1941 tree.layout(SizeProposal::exact(50.0, 50.0));
1942 let update = tree.sync_accessibility();
1943 let node = find_node(&update, id).unwrap();
1944 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
1945 }
1946
1947 #[test]
1950 fn access_shortcut_id_resolves_and_tracks_rebinds() {
1951 use crate::event::Key;
1952 use crate::shortcut::{KeyStroke, Shortcut};
1953
1954 let mut tree = WidgetTree::new();
1955 tree.shortcut_registry_mut().register(
1956 Shortcut::new("app.save")
1957 .name("Save")
1958 .primary(KeyStroke::command(Key::S))
1959 .build(),
1960 );
1961 let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
1962 tree.layout(SizeProposal::exact(50.0, 50.0));
1963
1964 let accel = if cfg!(target_os = "macos") {
1968 "Cmd"
1969 } else {
1970 "Ctrl"
1971 };
1972
1973 let update = tree.sync_accessibility();
1975 let node = find_node(&update, id).unwrap();
1976 assert_eq!(
1977 node.keyboard_shortcut(),
1978 Some(format!("{accel}+S").as_str())
1979 );
1980
1981 tree.shortcut_registry_mut()
1983 .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
1984 let update = tree.sync_accessibility();
1985 let node = find_node(&update, id).unwrap();
1986 assert_eq!(
1987 node.keyboard_shortcut(),
1988 Some(format!("{accel}+Q").as_str())
1989 );
1990 }
1991
1992 #[test]
1995 fn access_shortcut_id_unknown_id_omits_announcement() {
1996 let mut tree = WidgetTree::new();
1997 let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
1998 tree.layout(SizeProposal::exact(50.0, 50.0));
1999 let update = tree.sync_accessibility();
2000 let node = find_node(&update, id).unwrap();
2001 assert_eq!(node.keyboard_shortcut(), None);
2002 }
2003
2004 #[test]
2006 fn access_hidden_true_hides_widget() {
2007 let mut tree = WidgetTree::new();
2008 let id = tree.add(ClickableWidget.access_hidden(true));
2009 tree.layout(SizeProposal::exact(50.0, 50.0));
2010 assert!(tree.accessibility_node(id).is_hidden());
2011 }
2012
2013 #[test]
2015 fn access_hidden_false_clears_widget_set_hidden() {
2016 let mut tree = WidgetTree::new();
2017 let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
2018 tree.layout(SizeProposal::exact(50.0, 50.0));
2019 assert!(
2020 !tree.accessibility_node(id).is_hidden(),
2021 "access_hidden(false) should clear widget-emitted hidden"
2022 );
2023 }
2024
2025 #[test]
2027 fn access_disabled_true_marks_disabled() {
2028 let mut tree = WidgetTree::new();
2029 let id = tree.add(FillWidget::new().access_disabled(true));
2030 tree.layout(SizeProposal::exact(50.0, 50.0));
2031 assert!(tree.accessibility_node(id).is_disabled());
2032 }
2033
2034 #[test]
2036 fn access_disabled_false_clears_arena_driven_disabled() {
2037 use crate::signal::Signal;
2038 let mut tree = WidgetTree::new();
2039 let id = tree.add(FillWidget::new().label("X").access_disabled(false));
2040 tree.enabled_when(id, Signal::new(false));
2041 tree.layout(SizeProposal::exact(50.0, 50.0));
2042 assert!(
2043 !tree.accessibility_node(id).is_disabled(),
2044 "access_disabled(false) should clear even arena-driven disabled"
2045 );
2046 }
2047
2048 #[test]
2050 fn access_controls_appends() {
2051 let mut tree = WidgetTree::new();
2052 let target = tree.add(FillWidget::new().label("Target"));
2053 let controller = tree.add(
2054 FillWidget::new()
2055 .label("Controller")
2056 .access_controls(target),
2057 );
2058 tree.layout(SizeProposal::exact(100.0, 50.0));
2059 let update = tree.sync_accessibility();
2060 let node = find_node(&update, controller).unwrap();
2061 let target_nid = crate::accessibility::widget_id_to_node_id(target);
2062 assert!(
2063 node.controls().contains(&target_nid),
2064 "controls list should contain the target NodeId"
2065 );
2066 }
2067
2068 #[test]
2070 fn access_described_by_appends() {
2071 let mut tree = WidgetTree::new();
2072 let other = tree.add(FillWidget::new().label("Desc"));
2073 let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
2074 tree.layout(SizeProposal::exact(100.0, 50.0));
2075 let update = tree.sync_accessibility();
2076 let node = find_node(&update, id).unwrap();
2077 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2078 assert!(node.described_by().contains(&other_nid));
2079 }
2080
2081 #[test]
2083 fn access_labelled_by_appends() {
2084 let mut tree = WidgetTree::new();
2085 let other = tree.add(FillWidget::new().label("Lbl"));
2086 let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
2087 tree.layout(SizeProposal::exact(100.0, 50.0));
2088 let update = tree.sync_accessibility();
2089 let node = find_node(&update, id).unwrap();
2090 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2091 assert!(node.labelled_by().contains(&other_nid));
2092 }
2093
2094 #[test]
2106 fn a_child_attached_to_a_node_that_never_reached_the_tree_is_stripped() {
2107 use crate::accessibility::{AccessNodeBuilder, SyntheticKind, widget_id_to_node_id};
2108
2109 #[derive(Debug)]
2112 struct Grafter {
2113 child: std::cell::Cell<Option<WidgetId>>,
2114 }
2115
2116 impl Widget for Grafter {
2117 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2118 let child = ctx.add(FillWidget::new().label("Card"));
2119 self.child.set(Some(child));
2120 vec![child]
2121 }
2122 fn layout_response(
2123 &self,
2124 _p: SizeProposal,
2125 _c: &crate::widget::LayoutContext,
2126 ) -> crate::widget::LayoutResponse {
2127 teksilo_canvas::Size::new(100.0, 100.0).into()
2128 }
2129 fn children(&self) -> Vec<WidgetId> {
2130 self.child.get().into_iter().collect()
2131 }
2132 fn wants_descendant_redirects(&self) -> bool {
2133 true
2134 }
2135 fn a11y_redirect_descendant(
2136 &self,
2137 _self_id: WidgetId,
2138 descendant: WidgetId,
2139 ) -> Option<accesskit::NodeId> {
2140 (self.child.get() == Some(descendant)).then(|| widget_id_to_node_id(descendant))
2143 }
2144 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2145 builder.set_role(accesskit::Role::Pane);
2146 let group =
2147 builder.push_scene_child_under(None, 7, SyntheticKind::SceneGroup, |c| {
2148 c.set_role(accesskit::Role::GenericContainer);
2149 c.set_name("Group".to_string());
2150 });
2151 if let Some(child) = self.child.get() {
2152 builder.attach_scene_child_under(group, widget_id_to_node_id(child));
2153 }
2154 }
2155 }
2156
2157 let mut tree = WidgetTree::new();
2158 let grafter = tree.add(Grafter {
2159 child: std::cell::Cell::new(None),
2160 });
2161 tree.layout(SizeProposal::exact(200.0, 200.0));
2162 let child = tree.children(grafter)[0];
2163
2164 let update = tree.sync_accessibility();
2166 let child_nid = widget_id_to_node_id(child);
2167 assert!(
2168 update.nodes.iter().any(|(id, _)| *id == child_nid),
2169 "precondition: the grafted child is in the tree"
2170 );
2171 assert!(
2172 update
2173 .nodes
2174 .iter()
2175 .any(|(_, n)| n.children().contains(&child_nid)),
2176 "precondition: something names it as a child"
2177 );
2178 assert_a11y_tree_valid(&update);
2179
2180 tree.set_dormant(child);
2184 let update = tree.sync_accessibility();
2185 assert!(
2186 !update.nodes.iter().any(|(id, _)| *id == child_nid),
2187 "a dormant widget emits no node"
2188 );
2189 let emitted: std::collections::HashSet<accesskit::NodeId> =
2190 update.nodes.iter().map(|(id, _)| *id).collect();
2191 for (parent, node) in &update.nodes {
2192 for c in node.children() {
2193 assert!(
2194 emitted.contains(c),
2195 "node {parent:?} names child {c:?}, absent from the tree"
2196 );
2197 }
2198 }
2199 assert_a11y_tree_valid(&update);
2202 }
2203
2204 #[test]
2205 fn a_labelled_by_target_that_went_dormant_is_stripped() {
2206 let mut tree = WidgetTree::new();
2212 let title = tree.add(FillWidget::new().label("Preferences"));
2213 let dialog = tree.add(FillWidget::new().access_labelled_by(title));
2214 tree.layout(SizeProposal::exact(200.0, 100.0));
2215
2216 let update = tree.sync_accessibility();
2217 assert!(
2218 !find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2219 "the relation is live while the target is in the tree"
2220 );
2221
2222 tree.set_dormant(title);
2223 let update = tree.sync_accessibility();
2224 assert!(
2225 find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2226 "a target absent from the tree must not survive in the relation"
2227 );
2228 assert_no_dangling_relationships(&update);
2229 }
2230
2231 #[test]
2232 fn re_registering_a_labelled_by_relation_does_not_duplicate_it() {
2233 let mut tree = WidgetTree::new();
2237 let title = tree.add(FillWidget::new().label("Name"));
2238 let field = tree.add(FillWidget::new());
2239 tree.layout(SizeProposal::exact(200.0, 100.0));
2240
2241 for _ in 0..3 {
2242 tree.push_access_labelled_by(field, title);
2243 }
2244 let update = tree.sync_accessibility();
2245 assert_eq!(
2246 find_node(&update, field).unwrap().labelled_by().len(),
2247 1,
2248 "the same target registered repeatedly is one relation"
2249 );
2250 }
2251
2252 #[test]
2253 fn re_registering_a_described_by_relation_does_not_duplicate_it() {
2254 let mut tree = WidgetTree::new();
2255 let hint = tree.add(FillWidget::new().label("Must be unique"));
2256 let field = tree.add(FillWidget::new());
2257 tree.layout(SizeProposal::exact(200.0, 100.0));
2258
2259 for _ in 0..3 {
2260 tree.push_access_described_by(field, hint);
2261 }
2262 let update = tree.sync_accessibility();
2263 assert_eq!(
2264 find_node(&update, field).unwrap().described_by().len(),
2265 1,
2266 "the same target registered repeatedly is one relation"
2267 );
2268 }
2269
2270 #[test]
2272 fn access_live_assertive_set() {
2273 let mut tree = WidgetTree::new();
2274 let id = tree.add(FillWidget::new().access_live(Live::Assertive));
2275 tree.layout(SizeProposal::exact(50.0, 50.0));
2276 let update = tree.sync_accessibility();
2277 let node = find_node(&update, id).unwrap();
2278 assert_eq!(node.live(), Some(Live::Assertive));
2279 }
2280
2281 #[test]
2283 fn access_aria_current_set() {
2284 let mut tree = WidgetTree::new();
2285 let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
2286 tree.layout(SizeProposal::exact(50.0, 50.0));
2287 let update = tree.sync_accessibility();
2288 let node = find_node(&update, id).unwrap();
2289 assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
2290 }
2291
2292 #[test]
2294 fn access_has_popup_set() {
2295 let mut tree = WidgetTree::new();
2296 let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
2297 tree.layout(SizeProposal::exact(50.0, 50.0));
2298 let update = tree.sync_accessibility();
2299 let node = find_node(&update, id).unwrap();
2300 assert_eq!(node.has_popup(), Some(HasPopup::Menu));
2301 }
2302
2303 #[test]
2305 fn access_orientation_set() {
2306 let mut tree = WidgetTree::new();
2307 let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
2308 tree.layout(SizeProposal::exact(50.0, 50.0));
2309 let update = tree.sync_accessibility();
2310 let node = find_node(&update, id).unwrap();
2311 assert_eq!(node.orientation(), Some(Orientation::Vertical));
2312 }
2313
2314 #[test]
2316 fn access_numeric_value_and_range() {
2317 let mut tree = WidgetTree::new();
2318 let id = tree.add(
2319 FillWidget::new()
2320 .access_role(Role::Slider)
2321 .access_numeric_value(50.0)
2322 .access_numeric_range(0.0, 100.0)
2323 .access_numeric_step(5.0),
2324 );
2325 tree.layout(SizeProposal::exact(50.0, 50.0));
2326 let update = tree.sync_accessibility();
2327 let node = find_node(&update, id).unwrap();
2328 assert_eq!(node.numeric_value(), Some(50.0));
2329 assert_eq!(node.min_numeric_value(), Some(0.0));
2330 assert_eq!(node.max_numeric_value(), Some(100.0));
2331 assert_eq!(node.numeric_value_step(), Some(5.0));
2332 }
2333
2334 #[test]
2336 fn access_action_advertises_and_routes() {
2337 use crate::signal::Signal;
2338 let flag = Signal::new(false);
2339 let flag_for_cb = flag.clone();
2340 let mut tree = WidgetTree::new();
2341 let id = tree.add(
2342 FillWidget::new()
2343 .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
2344 );
2345 tree.layout(SizeProposal::exact(50.0, 50.0));
2346 let info = tree.accessibility_node(id);
2347 assert!(info.actions().contains(&Action::ShowContextMenu));
2348 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2349 action: Action::ShowContextMenu,
2350 target: Some(id),
2351 target_node: crate::accessibility::widget_id_to_node_id(id),
2352 data: None,
2353 });
2354 assert!(flag.get(), "callback should have been invoked");
2355 }
2356
2357 #[test]
2359 fn access_two_actions_both_route() {
2360 use crate::signal::Signal;
2361 let click = Signal::new(false);
2362 let increment = Signal::new(false);
2363 let click_cb = click.clone();
2364 let inc_cb = increment.clone();
2365 let mut tree = WidgetTree::new();
2366 let id = tree.add(
2367 FillWidget::new()
2368 .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
2369 .access_action(Action::Increment, move |_| inc_cb.set(true)),
2370 );
2371 tree.layout(SizeProposal::exact(50.0, 50.0));
2372 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2373 action: Action::ShowContextMenu,
2374 target: Some(id),
2375 target_node: crate::accessibility::widget_id_to_node_id(id),
2376 data: None,
2377 });
2378 assert!(click.get() && !increment.get(), "only first action fired");
2379 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2380 action: Action::Increment,
2381 target: Some(id),
2382 target_node: crate::accessibility::widget_id_to_node_id(id),
2383 data: None,
2384 });
2385 assert!(
2386 click.get() && increment.get(),
2387 "both actions fired exactly once each"
2388 );
2389 }
2390
2391 #[test]
2393 fn access_remove_action_suppresses_widget_action() {
2394 let mut tree = WidgetTree::new();
2395 let id = tree.add(ActionWidget.access_remove_action(Action::Click));
2396 tree.layout(SizeProposal::exact(50.0, 50.0));
2397 let info = tree.accessibility_node(id);
2398 assert!(!info.actions().contains(&Action::Click));
2399 assert!(info.actions().contains(&Action::Focus));
2400 }
2401
2402 #[test]
2404 fn access_custom_action_uses_localized_label() {
2405 let mut tree = WidgetTree::new();
2406 let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
2407 tree.layout(SizeProposal::exact(50.0, 50.0));
2408 let update = tree.sync_accessibility();
2409 let node = find_node(&update, id).unwrap();
2410 let actions = node.custom_actions();
2411 assert_eq!(actions.len(), 1);
2412 assert_eq!(actions[0].id, 0);
2413 assert_eq!(actions[0].description.as_str(), "Reply");
2414 }
2415
2416 #[test]
2418 fn access_custom_action_routes_by_index() {
2419 use crate::signal::Signal;
2420 let first = Signal::new(false);
2421 let second = Signal::new(false);
2422 let f = first.clone();
2423 let s = second.clone();
2424 let mut tree = WidgetTree::new();
2425 let id = tree.add(
2426 FillWidget::new()
2427 .access_custom_action_literal("First", move |_| f.set(true))
2428 .access_custom_action_literal("Second", move |_| s.set(true)),
2429 );
2430 tree.layout(SizeProposal::exact(50.0, 50.0));
2431 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2432 action: Action::CustomAction,
2433 target: Some(id),
2434 target_node: crate::accessibility::widget_id_to_node_id(id),
2435 data: Some(accesskit::ActionData::CustomAction(1)),
2436 });
2437 assert!(!first.get(), "first should not fire");
2438 assert!(second.get(), "second should fire (index 1)");
2439 }
2440
2441 #[test]
2443 fn access_action_layered_with_on_access_action() {
2444 use crate::signal::Signal;
2445 let from_override = Signal::new(false);
2446 let from_user = Signal::new(false);
2447 let ov_cb = from_override.clone();
2448 let user_cb = from_user.clone();
2449 let mut tree = WidgetTree::new();
2450 let id = tree.add(
2451 FillWidget::new()
2452 .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
2453 .on_access_action(move |_action, _ctx| {
2454 user_cb.set(true);
2455 crate::event::EventResponse::Handled
2456 }),
2457 );
2458 tree.layout(SizeProposal::exact(50.0, 50.0));
2459 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2460 action: Action::ShowContextMenu,
2461 target: Some(id),
2462 target_node: crate::accessibility::widget_id_to_node_id(id),
2463 data: None,
2464 });
2465 assert!(from_override.get(), "override callback fired");
2466 assert!(from_user.get(), "user catch-all fired");
2467 }
2468
2469 #[test]
2475 fn access_label_accepts_resolved_string_via_into() {
2476 struct ResolvedAtCall(String);
2480 impl From<ResolvedAtCall> for crate::signal::Prop<String> {
2481 fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
2482 crate::signal::Prop::Static(v.0)
2483 }
2484 }
2485 let mut tree = WidgetTree::new();
2486 let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
2487 tree.layout(SizeProposal::exact(50.0, 50.0));
2488 assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
2489 }
2490
2491 #[test]
2493 fn access_exclude_subtree_prunes_children_from_at_tree() {
2494 let mut tree = WidgetTree::new();
2495 let inner1 = tree.add(FillWidget::new().label("A"));
2496 let inner2 = tree.add(FillWidget::new().label("B"));
2497 let outer = tree.add(
2498 StackWidget::new()
2499 .child(inner1)
2500 .child(inner2)
2501 .access_label_literal("Section")
2505 .access_exclude_subtree(),
2506 );
2507 tree.layout(SizeProposal::exact(100.0, 50.0));
2508 let update = tree.sync_accessibility();
2509 assert!(find_node(&update, outer).is_some());
2511 assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
2512 assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
2513 let outer_node = find_node(&update, outer).unwrap();
2514 assert!(
2515 outer_node.children().is_empty(),
2516 "outer should have no AT children when excluded"
2517 );
2518 }
2519
2520 #[test]
2522 fn access_merge_subtree_concatenates_descendant_labels() {
2523 let mut tree = WidgetTree::new();
2524 let title = tree.add(FillWidget::new().label("Title"));
2525 let subtitle = tree.add(FillWidget::new().label("Subtitle"));
2526 let card = tree.add(
2527 StackWidget::new()
2528 .child(title)
2529 .child(subtitle)
2530 .access_merge_subtree(),
2531 );
2532 tree.layout(SizeProposal::exact(100.0, 50.0));
2533 assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
2535 let update = tree.sync_accessibility();
2536 assert!(find_node(&update, title).is_none());
2537 assert!(find_node(&update, subtitle).is_none());
2538 }
2539
2540 #[test]
2542 fn access_merge_subtree_unions_actions() {
2543 let mut tree = WidgetTree::new();
2544 let click_a = tree.add(ClickableWidget);
2545 let click_b = tree.add(ClickableWidget);
2546 let card = tree.add(
2547 StackWidget::new()
2548 .child(click_a)
2549 .child(click_b)
2550 .access_merge_subtree(),
2551 );
2552 tree.layout(SizeProposal::exact(100.0, 50.0));
2553 let actions = tree.accessibility_node(card).actions().to_vec();
2554 let click_count = actions.iter().filter(|a| **a == Action::Click).count();
2555 assert_eq!(
2556 click_count, 1,
2557 "Click should be present exactly once after merge (deduplicated)"
2558 );
2559 }
2560
2561 #[test]
2571 fn access_merge_subtree_first_nonempty_value_wins() {
2572 #[derive(Debug)]
2573 struct ValueWidget(&'static str);
2574 impl Widget for ValueWidget {
2575 fn layout_response(
2576 &self,
2577 proposal: SizeProposal,
2578 _ctx: &LayoutContext,
2579 ) -> crate::widget::LayoutResponse {
2580 proposal.resolve(0.0, 0.0).into()
2581 }
2582 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2583 builder.set_role(Role::Slider);
2584 builder.set_value(self.0);
2585 }
2586 }
2587 let mut tree = WidgetTree::new();
2588 let v1 = tree.add(ValueWidget("first"));
2589 let v2 = tree.add(ValueWidget("second"));
2590 let card = tree.add(
2591 StackWidget::new()
2592 .child(v1)
2593 .child(v2)
2594 .access_merge_subtree(),
2595 );
2596 tree.layout(SizeProposal::exact(100.0, 50.0));
2597 assert_eq!(tree.text_value(card), Some("first".to_string()));
2598 }
2599
2600 #[test]
2602 fn access_exclude_inside_merge() {
2603 let mut tree = WidgetTree::new();
2604 let visible = tree.add(FillWidget::new().label("VISIBLE"));
2605 let pruned = tree.add(FillWidget::new().label("PRUNED"));
2606 let inner_excluded = tree.add(StackWidget::new().child(pruned).access_exclude_subtree());
2607 let card = tree.add(
2608 StackWidget::new()
2609 .child(visible)
2610 .child(inner_excluded)
2611 .access_merge_subtree(),
2612 );
2613 tree.layout(SizeProposal::exact(100.0, 50.0));
2614 let merged = tree.text_content(card).unwrap_or_default();
2615 assert!(merged.contains("VISIBLE"));
2618 assert!(
2619 !merged.contains("PRUNED"),
2620 "excluded subtree should not contribute to merge"
2621 );
2622 }
2623
2624 #[test]
2626 fn access_merge_inside_merge() {
2627 let mut tree = WidgetTree::new();
2628 let inner_a = tree.add(FillWidget::new().label("a"));
2629 let inner_b = tree.add(FillWidget::new().label("b"));
2630 let inner_card = tree.add(
2631 StackWidget::new()
2632 .child(inner_a)
2633 .child(inner_b)
2634 .access_merge_subtree(),
2635 );
2636 let outer_extra = tree.add(FillWidget::new().label("X"));
2637 let outer = tree.add(
2638 StackWidget::new()
2639 .child(inner_card)
2640 .child(outer_extra)
2641 .access_merge_subtree(),
2642 );
2643 tree.layout(SizeProposal::exact(200.0, 100.0));
2644 let merged = tree.text_content(outer).unwrap_or_default();
2649 assert!(merged.contains("a"));
2650 assert!(merged.contains("b"));
2651 assert!(merged.contains("X"));
2652 let update = tree.sync_accessibility();
2654 assert!(find_node(&update, inner_a).is_none());
2655 assert!(find_node(&update, inner_b).is_none());
2656 assert!(find_node(&update, inner_card).is_none());
2657 assert!(find_node(&update, outer_extra).is_none());
2658 }
2659
2660 #[test]
2662 fn access_customize_runs_last() {
2663 let mut tree = WidgetTree::new();
2664 let id = tree.add(
2665 FillWidget::new()
2666 .access_label_literal("A")
2667 .access_customize(|b| b.set_name("B")),
2668 );
2669 tree.layout(SizeProposal::exact(50.0, 50.0));
2670 assert_eq!(tree.accessibility_node(id).name(), Some("B"));
2671 }
2672
2673 #[test]
2675 fn access_customize_can_reach_inner_mut() {
2676 let mut tree = WidgetTree::new();
2677 let id = tree.add(FillWidget::new().access_customize(|b| {
2678 b.inner_mut().set_author_id("from-customize");
2679 }));
2680 tree.layout(SizeProposal::exact(50.0, 50.0));
2681 let update = tree.sync_accessibility();
2682 let node = find_node(&update, id).unwrap();
2683 assert_eq!(node.author_id(), Some("from-customize"));
2684 }
2685
2686 #[test]
2688 fn access_overrides_zero_cost_when_unused() {
2689 use std::mem::size_of;
2696 assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
2697 assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
2698 }
2699
2700 #[test]
2701 fn accessibility_children_overrides_at_reading_order() {
2702 #[derive(Debug)]
2707 struct NamedLeaf(&'static str);
2708 impl Widget for NamedLeaf {
2709 fn layout_response(
2710 &self,
2711 proposal: SizeProposal,
2712 _ctx: &LayoutContext,
2713 ) -> crate::widget::LayoutResponse {
2714 proposal.resolve(10.0, 10.0).into()
2715 }
2716 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2717 builder.set_role(accesskit::Role::Button);
2721 builder.set_name(self.0);
2722 }
2723 }
2724
2725 #[derive(Debug, Default)]
2726 struct ReorderContainer {
2727 a: Option<WidgetId>,
2728 b: Option<WidgetId>,
2729 }
2730 impl Widget for ReorderContainer {
2731 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2732 let a = ctx.add(NamedLeaf("A"));
2733 let b = ctx.add(NamedLeaf("B"));
2734 self.a = Some(a);
2735 self.b = Some(b);
2736 vec![a, b]
2737 }
2738 fn layout_response(
2739 &self,
2740 proposal: SizeProposal,
2741 _ctx: &LayoutContext,
2742 ) -> crate::widget::LayoutResponse {
2743 proposal.resolve(100.0, 100.0).into()
2744 }
2745 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2746 builder.set_role(accesskit::Role::Group);
2749 builder.set_name("Container");
2750 }
2751 fn children(&self) -> Vec<WidgetId> {
2752 [self.a, self.b].into_iter().flatten().collect()
2753 }
2754 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2755 Some([self.b, self.a].into_iter().flatten().collect())
2757 }
2758 }
2759
2760 let mut tree = WidgetTree::new();
2761 let container = tree.add(ReorderContainer::default());
2762 tree.layout(SizeProposal::exact(100.0, 100.0));
2763 let update = tree.sync_accessibility();
2764
2765 let a_nid = update
2766 .nodes
2767 .iter()
2768 .find(|(_, n)| n.label() == Some("A"))
2769 .map(|(id, _)| *id)
2770 .expect("A node present");
2771 let b_nid = update
2772 .nodes
2773 .iter()
2774 .find(|(_, n)| n.label() == Some("B"))
2775 .map(|(id, _)| *id)
2776 .expect("B node present");
2777 let cnid = crate::accessibility::widget_id_to_node_id(container);
2778 let cnode = update
2779 .nodes
2780 .iter()
2781 .find(|(id, _)| *id == cnid)
2782 .map(|(_, n)| n)
2783 .expect("container node present");
2784 let kids = cnode.children();
2785 let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
2786 let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
2787 assert!(
2788 pb < pa,
2789 "accessibility_children() must set AT order B-before-A; got {kids:?}"
2790 );
2791 }
2792
2793 fn consumer_bounding_box(update: &accesskit::TreeUpdate, label: &str) -> accesskit::Rect {
2807 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
2808 let state = consumer.state();
2809 let mut stack = vec![state.root()];
2810 while let Some(node) = stack.pop() {
2811 let text = node.value().or_else(|| node.label());
2814 if text.as_deref() == Some(label) {
2815 return node.bounding_box().expect("node has bounds");
2816 }
2817 for child in node.children() {
2818 stack.push(child);
2819 }
2820 }
2821 panic!("no node labelled {label:?} in the consumer tree");
2822 }
2823
2824 fn tree_at_scale(scale: f32) -> (WidgetTree, WidgetId) {
2825 let mut tree = WidgetTree::new();
2826 let id = tree.add(FillWidget::new().label("content"));
2827 tree.set_device_scale_factor(scale);
2828 tree.layout(SizeProposal::exact(200.0, 100.0));
2829 (tree, id)
2830 }
2831
2832 #[test]
2833 fn the_root_carries_the_device_scale_as_its_transform() {
2834 let (mut tree, _) = tree_at_scale(2.0);
2835 let update = tree.sync_accessibility();
2836 let root = update
2837 .nodes
2838 .iter()
2839 .find(|(id, _)| *id == crate::accessibility::root_node_id())
2840 .map(|(_, n)| n)
2841 .expect("root node present");
2842 assert_eq!(
2843 root.transform().copied(),
2844 Some(accesskit::Affine::scale(2.0)),
2845 "AccessKit wants physical coordinates; the tree emits logical ones"
2846 );
2847 assert_a11y_tree_valid(&update);
2848 }
2849
2850 #[test]
2851 fn emitted_bounds_stay_logical_at_scale_two() {
2852 let (mut tree, id) = tree_at_scale(2.0);
2861 let update = tree.sync_accessibility();
2862 let layout = tree.bounds(id);
2863 let nid = crate::accessibility::widget_id_to_node_id(id);
2864 let raw = update
2865 .nodes
2866 .iter()
2867 .find(|(n, _)| *n == nid)
2868 .and_then(|(_, n)| n.bounds())
2869 .expect("widget node has bounds");
2870 assert_eq!(
2871 (raw.x0, raw.y0, raw.x1, raw.y1),
2872 (
2873 layout.x as f64,
2874 layout.y as f64,
2875 (layout.x + layout.width) as f64,
2876 (layout.y + layout.height) as f64,
2877 ),
2878 "an emitted rectangle must be the logical layout rectangle, unscaled"
2879 );
2880 }
2881
2882 #[test]
2901 fn an_interactive_node_emits_its_exact_layout_rectangle() {
2902 let mut tree = WidgetTree::new();
2903 let id = tree.add(ActionWidget);
2904 tree.layout(SizeProposal::exact(200.0, 100.0));
2905 let update = tree.sync_accessibility();
2906 let nid = crate::accessibility::widget_id_to_node_id(id);
2907 let node = update
2908 .nodes
2909 .iter()
2910 .find(|(n, _)| *n == nid)
2911 .map(|(_, n)| n)
2912 .expect("the interactive node is in the update");
2913 assert!(
2914 node.supports_action(accesskit::Action::Click),
2915 "the fixture has to be the clickable shape the rule is about, \
2916 or this test guards nothing"
2917 );
2918 let layout = tree.bounds(id);
2919 assert!(
2920 layout.width > 0.0 && layout.height > 0.0,
2921 "a real, non-degenerate rectangle to compare against: {layout:?}"
2922 );
2923 let raw = node.bounds().expect("the interactive node has bounds");
2924 assert_eq!(
2925 (raw.x0, raw.y0, raw.x1, raw.y1),
2926 (
2927 layout.x as f64,
2928 layout.y as f64,
2929 (layout.x + layout.width) as f64,
2930 (layout.y + layout.height) as f64,
2931 ),
2932 "a clickable node's emitted rectangle is its layout rectangle, \
2933 not an outset one"
2934 );
2935 }
2936
2937 #[test]
2938 fn what_an_assistive_technology_reads_is_the_physical_rectangle() {
2939 let (mut one, _) = tree_at_scale(1.0);
2943 let at_one = consumer_bounding_box(&one.sync_accessibility(), "content");
2944 let (mut two, _) = tree_at_scale(2.0);
2945 let at_two = consumer_bounding_box(&two.sync_accessibility(), "content");
2946 assert_eq!(at_two.x0, at_one.x0 * 2.0);
2947 assert_eq!(at_two.y0, at_one.y0 * 2.0);
2948 assert_eq!(at_two.x1, at_one.x1 * 2.0);
2949 assert_eq!(at_two.y1, at_one.y1 * 2.0);
2950 assert!(at_two.x1 > at_two.x0, "a real, non-degenerate rectangle");
2951 }
2952
2953 #[test]
2954 fn a_scale_change_invalidates_the_accessibility_cache() {
2955 let (mut tree, _) = tree_at_scale(1.0);
2961 let _ = tree.sync_accessibility();
2962 tree.set_device_scale_factor(2.0);
2963 let update = tree.sync_accessibility();
2964 let root = update
2965 .nodes
2966 .iter()
2967 .find(|(id, _)| *id == crate::accessibility::root_node_id())
2968 .map(|(_, n)| n)
2969 .expect("root node present");
2970 assert_eq!(
2971 root.transform().copied(),
2972 Some(accesskit::Affine::scale(2.0))
2973 );
2974 }
2975
2976 #[test]
2977 fn setting_the_same_scale_does_not_dirty_the_tree() {
2978 let (mut tree, _) = tree_at_scale(2.0);
2982 let first = tree.sync_accessibility();
2983 tree.set_device_scale_factor(2.0);
2984 let second = tree.sync_accessibility();
2985 assert_eq!(first, second);
2986 }
2987
2988 #[test]
2991 fn explore_by_touch_is_off_by_default() {
2992 let tree = WidgetTree::new();
2993 assert_eq!(tree.explore_by_touch(), crate::ExploreByTouch::Off);
2994 assert_eq!(
2995 tree.screen_reader_state(),
2996 crate::ScreenReaderState::Unknown
2997 );
2998 assert!(!tree.explore_by_touch_active());
2999 }
3000
3001 #[test]
3002 fn auto_follows_the_operating_systems_screen_reader_flag() {
3003 let mut tree = WidgetTree::new();
3004 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3005 assert!(!tree.explore_by_touch_active(), "Unknown is not a yes");
3006 tree.set_screen_reader_state(crate::ScreenReaderState::Inactive);
3007 assert!(!tree.explore_by_touch_active());
3008 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3009 assert!(tree.explore_by_touch_active());
3010 }
3011
3012 #[test]
3013 fn on_ignores_the_screen_reader_flag_and_off_ignores_everything() {
3014 let mut tree = WidgetTree::new();
3015 tree.set_explore_by_touch(crate::ExploreByTouch::On);
3016 assert!(tree.explore_by_touch_active(), "On is the app's own choice");
3017 tree.set_explore_by_touch(crate::ExploreByTouch::Off);
3018 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3019 assert!(!tree.explore_by_touch_active());
3020 }
3021
3022 #[test]
3023 fn an_attached_accesskit_client_does_not_switch_exploring_on() {
3024 let mut tree = WidgetTree::new();
3030 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3031 tree.set_at_client_attached(true);
3032 assert!(tree.at_client_attached());
3033 assert_eq!(
3034 tree.screen_reader_state(),
3035 crate::ScreenReaderState::Unknown,
3036 "attaching says nothing about screen readers"
3037 );
3038 assert!(!tree.explore_by_touch_active());
3039 }
3040
3041 #[test]
3042 fn touch_still_reaches_a_handler_with_an_accesskit_client_attached() {
3043 use crate::Modifiers;
3049 use crate::pointer::{
3050 BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase,
3051 PointerSample,
3052 };
3053 use crate::widget_builder::WidgetBuilder;
3054 use std::cell::Cell;
3055 use std::rc::Rc;
3056
3057 let taps = Rc::new(Cell::new(0u32));
3058 let seen = Rc::clone(&taps);
3059 let mut tree = WidgetTree::new();
3060 let id = tree.add(
3061 FillWidget::new()
3062 .label("target")
3063 .on_tap(move |_e, _ctx| seen.set(seen.get() + 1)),
3064 );
3065 tree.layout(SizeProposal::exact(200.0, 100.0));
3066 tree.set_at_client_attached(true);
3067 assert!(!tree.explore_by_touch_active());
3068
3069 let at = Point::new(50.0, 50.0);
3070 let pid = PointerIdAllocator::global().begin(BackendDeviceKey::new(0x0A11), 1);
3071 let contact = |phase| PointerSample {
3072 pointer: PointerInfo::touch(pid, EventTime::ZERO),
3073 phase,
3074 position: at,
3075 button: None,
3076 modifiers: Modifiers::NONE,
3077 coalesced: Vec::new(),
3078 };
3079 tree.dispatch_pointer(contact(PointerPhase::Down));
3080 tree.dispatch_pointer(contact(PointerPhase::Up));
3081 assert!(tree.bounds(id).width > 0.0);
3082 assert_eq!(taps.get(), 1, "a touch tap must still activate its handler");
3083 }
3084
3085 #[test]
3086 fn a_detaching_client_is_evidence_that_no_screen_reader_is_reading() {
3087 let mut tree = WidgetTree::new();
3091 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3092 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3093 tree.set_at_client_attached(true);
3094 assert!(tree.explore_by_touch_active());
3095 tree.set_at_client_attached(false);
3096 assert_eq!(
3097 tree.screen_reader_state(),
3098 crate::ScreenReaderState::Inactive
3099 );
3100 assert!(!tree.explore_by_touch_active());
3101 }
3102
3103 #[test]
3104 fn a_repeated_detach_notice_does_not_clobber_a_fresh_os_reading() {
3105 let mut tree = WidgetTree::new();
3109 tree.set_at_client_attached(false);
3110 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3111 tree.set_at_client_attached(false);
3112 assert_eq!(tree.screen_reader_state(), crate::ScreenReaderState::Active);
3113 }
3114
3115 #[test]
3118 fn a_density_switch_announces_once_in_the_applications_own_words() {
3119 use teksilo_tokens::TargetDensity;
3120 let mut tree = tree_with_one_widget();
3121 tree.set_density_announcement(Some(std::rc::Rc::new(|d: TargetDensity| {
3122 format!("layout {d:?}")
3123 })));
3124 let _ = tree.sync_accessibility();
3125
3126 tree.set_input_density(TargetDensity::Comfortable);
3127 let update = tree.sync_accessibility();
3128 assert_eq!(
3129 announced_by_consumer(&update),
3130 vec![("layout Comfortable".to_string(), accesskit::Live::Polite)]
3131 );
3132
3133 let _ = tree.sync_accessibility(); tree.set_input_density(TargetDensity::Comfortable);
3136 let update = tree.sync_accessibility();
3137 assert_eq!(announced_by_consumer(&update), Vec::new());
3138 }
3139
3140 #[test]
3141 fn a_density_switch_is_silent_without_a_registered_wording() {
3142 use teksilo_tokens::TargetDensity;
3147 let mut tree = tree_with_one_widget();
3148 let _ = tree.sync_accessibility();
3149 tree.set_input_density(TargetDensity::Comfortable);
3150 let update = tree.sync_accessibility();
3151 assert_eq!(announced_by_consumer(&update), Vec::new());
3152 }
3153
3154 #[test]
3155 fn an_announcement_stays_quiet_when_the_widget_already_speaks() {
3156 let mut tree = WidgetTree::new();
3157 use crate::widget_builder::WidgetBuilder;
3158 let widget = tree.add(
3159 FillWidget::new()
3160 .label("Saved")
3161 .access_live(accesskit::Live::Polite),
3162 );
3163 tree.layout(SizeProposal::exact(200.0, 100.0));
3164 let _ = tree.sync_accessibility();
3166
3167 assert!(
3168 !tree.announce_unless_widget_speaks(widget, "Saved"),
3169 "a widget with its own live region must not be doubled"
3170 );
3171 let update = tree.sync_accessibility();
3172 let live = live_nodes_in_filtered_tree(&update);
3176 assert_eq!(live.len(), 1, "exactly one voice, not two: {live:?}");
3177 }
3178
3179 #[test]
3180 fn an_announcement_goes_ahead_when_the_widget_is_silent() {
3181 let mut tree = WidgetTree::new();
3182 let widget = tree.add(FillWidget::new().label("Row"));
3183 tree.layout(SizeProposal::exact(200.0, 100.0));
3184 let _ = tree.sync_accessibility();
3185
3186 assert!(tree.announce_unless_widget_speaks(widget, "Moved to 3 of 12"));
3187 let update = tree.sync_accessibility();
3188 assert_eq!(
3189 announced_by_consumer(&update),
3190 vec![("Moved to 3 of 12".to_string(), accesskit::Live::Polite)]
3191 );
3192 }
3193
3194 #[test]
3195 fn a_live_region_with_no_text_does_not_count_as_speaking() {
3196 let mut tree = WidgetTree::new();
3199 use crate::widget_builder::WidgetBuilder;
3200 let widget = tree.add(FillWidget::new().access_live(accesskit::Live::Polite));
3201 tree.layout(SizeProposal::exact(200.0, 100.0));
3202 let _ = tree.sync_accessibility();
3203 assert!(tree.announce_unless_widget_speaks(widget, "Something happened"));
3204 }
3205
3206 #[derive(Debug)]
3209 struct SpeakingSynthetic;
3210
3211 impl crate::widget::Widget for SpeakingSynthetic {
3212 fn layout_response(
3213 &self,
3214 proposal: SizeProposal,
3215 _ctx: &crate::widget::LayoutContext,
3216 ) -> crate::widget::LayoutResponse {
3217 proposal.resolve(0.0, 0.0).into()
3218 }
3219
3220 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3221 builder.set_role(accesskit::Role::Group);
3222 let child = builder.push_annotation_child(1, "2 items selected");
3223 builder.with_collected_node(child, |node| node.set_live(accesskit::Live::Polite));
3224 }
3225 }
3226
3227 #[test]
3228 fn a_live_synthetic_child_counts_as_the_widget_speaking() {
3229 let mut tree = WidgetTree::new();
3234 let widget = tree.add(SpeakingSynthetic);
3235 tree.layout(SizeProposal::exact(200.0, 100.0));
3236 let update = tree.sync_accessibility();
3237 assert!(
3238 update
3239 .nodes
3240 .iter()
3241 .any(|(id, _)| crate::accessibility::is_synthetic(*id)),
3242 "the fixture must actually emit a synthetic child"
3243 );
3244 assert!(!tree.announce_unless_widget_speaks(widget, "2 items selected"));
3245 }
3246
3247 #[test]
3248 fn a_descendants_live_region_silences_its_ancestor_too() {
3249 let mut tree = WidgetTree::new();
3252 let container = tree.add(StackWidget::new());
3253 use crate::widget_builder::WidgetBuilder;
3254 let inner = FillWidget::new()
3255 .label("3 results")
3256 .access_live(accesskit::Live::Polite);
3257 tree.add_child(container, inner);
3258 tree.layout(SizeProposal::exact(200.0, 100.0));
3259 let _ = tree.sync_accessibility();
3260 assert!(!tree.announce_unless_widget_speaks(container, "3 results"));
3261 }
3262}