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> {
355 self.synthetic_parent_map.get(&node_id).copied()
356 }
357
358 pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
376 self.arena
377 .get(content_id)
378 .and_then(|node| node.widget.as_any())
379 .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
380 .and_then(|d| d.materialized_child())
381 .unwrap_or(content_id)
382 }
383
384 pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
385 let content_id = self.tooltip_content_node(content_id);
386 let node = self.arena.get(content_id)?;
387 let mut probe = AccessNodeBuilder::for_name_probe(content_id);
388 node.widget.accessibility(&mut probe);
389 if let Some(ov) = node.access_overrides.as_deref() {
390 ov.apply(&mut probe);
391 }
392 probe
393 .name()
394 .map(str::trim)
395 .filter(|name| !name.is_empty())
396 .map(str::to_owned)
397 }
398
399 pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
400 let node = self.arena.get(id).expect("widget id is active in arena");
401 let builder = self.build_overridden_builder(id);
402 let role = builder.role();
403 let name = builder.name().map(|s| s.to_string());
404 let actions = builder.actions().to_vec();
405 let mut info = AccessibilityInfo::new(role, name, actions);
406 if let Some(toggled) = builder.toggled() {
407 info = info.with_toggled(toggled);
408 }
409 if let Some(expanded) = builder.expanded() {
410 info = info.with_expanded(expanded);
411 }
412 if let Some(selected) = builder.selected() {
413 info = info.with_selected(selected);
414 }
415 let force_clear_disabled =
419 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
420 let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
421 let disabled_override =
426 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(true);
427 if disabled_arena || disabled_override {
428 info = info.with_disabled(true);
429 }
430 if builder.is_hidden() {
431 info = info.with_hidden(true);
432 }
433 info
434 }
435
436 pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
437 self.arena
438 .active_ids_iter()
439 .find(|&id| self.build_overridden_builder(id).role() == role)
440 }
441
442 pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
443 self.arena
444 .active_ids_iter()
445 .find(|&id| self.build_overridden_builder(id).name() == Some(label))
446 }
447
448 pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
449 self.arena.active_ids_iter().find(|&id| {
450 self.build_overridden_builder(id)
451 .actions()
452 .contains(&action)
453 })
454 }
455
456 pub fn text_content(&self, id: WidgetId) -> Option<String> {
460 self.arena.get(id)?;
461 self.build_overridden_builder(id)
462 .name()
463 .map(|s| s.to_string())
464 }
465
466 pub fn text_value(&self, id: WidgetId) -> Option<String> {
470 self.arena.get(id)?;
471 self.build_overridden_builder(id)
472 .value()
473 .map(|s| s.to_string())
474 }
475}
476
477pub(crate) fn emit_root_transform(root: &mut accesskit::Node, device_scale_factor: f32) {
506 root.set_transform(accesskit::Affine::scale(device_scale_factor as f64));
507}
508
509#[cfg(test)]
510pub(crate) mod test_helpers {
511 pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
517 accesskit_consumer::Tree::new(update.clone(), false);
518 }
519
520 pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
529 let emitted: std::collections::HashSet<accesskit::NodeId> =
530 update.nodes.iter().map(|(id, _)| *id).collect();
531 for (parent_id, node) in &update.nodes {
532 for &target in node.controls() {
533 assert!(
534 emitted.contains(&target),
535 "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
536 );
537 }
538 for &target in node.described_by() {
539 assert!(
540 emitted.contains(&target),
541 "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
542 );
543 }
544 for &target in node.labelled_by() {
545 assert!(
546 emitted.contains(&target),
547 "node {parent_id:?} has labelled_by() → {target:?} which is absent from the tree"
548 );
549 }
550 if let Some(target) = node.next_on_line() {
551 assert!(
552 emitted.contains(&target),
553 "node {parent_id:?} has next_on_line() → {target:?} which is absent from the tree"
554 );
555 }
556 if let Some(target) = node.previous_on_line() {
557 assert!(
558 emitted.contains(&target),
559 "node {parent_id:?} has previous_on_line() → {target:?} which is absent from the tree"
560 );
561 }
562 }
563 }
564
565 #[allow(dead_code)]
567 pub(crate) fn nodes_with_role(
568 update: &accesskit::TreeUpdate,
569 role: accesskit::Role,
570 ) -> Vec<accesskit::NodeId> {
571 update
572 .nodes
573 .iter()
574 .filter(|(_, node)| node.role() == role)
575 .map(|(id, _)| *id)
576 .collect()
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::test_helpers::*;
583 use super::*;
584 use crate::test_widgets::{FillWidget, StackWidget};
585
586 const ANNOUNCER_NODES: usize = 2;
590
591 fn announced_by_consumer(update: &accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
607 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
608 let state = consumer.state();
609 let mut found = Vec::new();
610 let mut stack = vec![state.root()];
611 while let Some(node) = stack.pop() {
612 if node.live() != accesskit::Live::Off
613 && let Some(label) = node.label()
614 {
615 found.push((label, node.live()));
616 }
617 for child in node.filtered_children(&accesskit_consumer::common_filter) {
618 stack.push(child);
619 }
620 }
621 found
622 }
623
624 fn live_nodes_in_filtered_tree(update: &accesskit::TreeUpdate) -> Vec<String> {
633 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
634 let state = consumer.state();
635 let mut found = Vec::new();
636 let mut stack = vec![state.root()];
637 while let Some(node) = stack.pop() {
638 if node.live() != accesskit::Live::Off {
639 found.push(format!("{:?}", node.id()));
640 }
641 for child in node.filtered_children(&accesskit_consumer::common_filter) {
642 stack.push(child);
643 }
644 }
645 found.sort();
646 found
647 }
648
649 fn tree_with_one_widget() -> WidgetTree {
650 let mut tree = WidgetTree::new();
651 tree.add(FillWidget::new().label("content"));
652 tree.layout(SizeProposal::exact(200.0, 100.0));
653 tree
654 }
655
656 #[test]
659 fn an_announcement_enters_the_filtered_tree_then_leaves_it() {
660 let mut tree = tree_with_one_widget();
661 let update = tree.sync_accessibility();
664 assert_eq!(announced_by_consumer(&update), Vec::new());
665 assert_eq!(
666 live_nodes_in_filtered_tree(&update),
667 Vec::<String>::new(),
668 "an idle announcer must be outside the filtered tree"
669 );
670 assert_a11y_tree_valid(&update);
671
672 tree.announce("Event added");
673
674 let exposed = tree.sync_accessibility();
675 assert_eq!(
676 announced_by_consumer(&exposed),
677 vec![("Event added".to_string(), accesskit::Live::Polite)],
678 "the message must be a label on a live node inside the filtered tree"
679 );
680 assert_eq!(
681 live_nodes_in_filtered_tree(&exposed).len(),
682 1,
683 "exactly the polite announcer must have entered the filtered tree"
684 );
685 assert_a11y_tree_valid(&exposed);
686
687 let retracted = tree.sync_accessibility();
688 assert_eq!(
689 announced_by_consumer(&retracted),
690 Vec::new(),
691 "the node must stop carrying the message"
692 );
693 assert_eq!(
699 live_nodes_in_filtered_tree(&retracted),
700 Vec::<String>::new(),
701 "the live node must leave the filtered tree entirely, not merely \
702 lose its label"
703 );
704 assert_a11y_tree_valid(&retracted);
705 }
706
707 #[test]
712 fn the_same_message_announced_twice_is_exposed_twice() {
713 let mut tree = tree_with_one_widget();
714 tree.announce("Saved");
715 tree.announce("Saved");
716
717 let mut exposures = 0;
718 for _ in 0..6 {
719 let update = tree.sync_accessibility();
720 if announced_by_consumer(&update)
721 .iter()
722 .any(|(text, _)| text == "Saved")
723 {
724 exposures += 1;
725 }
726 }
727 assert_eq!(exposures, 2, "each announcement needs its own exposure");
728 }
729
730 #[test]
731 fn an_assertive_announcement_uses_the_assertive_live_setting() {
732 let mut tree = tree_with_one_widget();
733 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
734 let update = tree.sync_accessibility();
735 assert_eq!(
736 announced_by_consumer(&update),
737 vec![("Could not save".to_string(), accesskit::Live::Assertive)]
738 );
739 }
740
741 #[test]
744 fn the_two_politeness_levels_announce_independently() {
745 let mut tree = tree_with_one_widget();
746 tree.announce("Event added");
747 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
748
749 let mut spoken = announced_by_consumer(&tree.sync_accessibility());
750 spoken.sort_by(|a, b| a.0.cmp(&b.0));
751 assert_eq!(
752 spoken,
753 vec![
754 ("Could not save".to_string(), accesskit::Live::Assertive),
755 ("Event added".to_string(), accesskit::Live::Polite),
756 ],
757 "both levels must be exposed on the same update"
758 );
759 }
760
761 #[test]
765 fn announcing_requests_the_syncs_it_needs() {
766 let mut tree = tree_with_one_widget();
767 let _ = tree.sync_accessibility();
768 tree.a11y_update_requested.set(false);
769 tree.frame_tick_requested.set(false);
770
771 tree.announce("Event added");
772 assert!(
773 tree.a11y_update_requested.get(),
774 "an announcement must dirty the accessibility tree"
775 );
776 assert!(
777 tree.frame_requested(),
778 "an announcement must ask for the frame that carries it"
779 );
780
781 tree.a11y_update_requested.set(false);
783 tree.frame_tick_requested.set(false);
784 let _ = tree.sync_accessibility();
785 assert!(tree.a11y_update_requested.get());
786 assert!(tree.frame_requested());
787
788 tree.a11y_update_requested.set(false);
790 tree.frame_tick_requested.set(false);
791 let _ = tree.sync_accessibility();
792 assert!(!tree.a11y_update_requested.get());
793 assert!(!tree.frame_requested());
794 }
795
796 #[test]
799 fn an_announcement_reaches_the_automation_ring() {
800 let mut tree = tree_with_one_widget();
801 let before = tree.announcements_since(0).len() as u64;
802 tree.announce("Event added");
803 let _ = tree.sync_accessibility();
804 let got = tree.announcements_since(before);
805 assert_eq!(got.len(), 1);
806 assert_eq!(got[0].text, "Event added");
807 assert!(!got[0].assertive);
808 }
809
810 #[test]
814 fn a_tree_snapshot_does_not_consume_a_pending_announcement() {
815 let mut tree = tree_with_one_widget();
816 tree.announce("Event added");
817
818 let snapshot = tree.accessibility_tree_snapshot();
819 assert_eq!(
820 announced_by_consumer(&snapshot),
821 Vec::new(),
822 "a snapshot must not advance the announcer"
823 );
824
825 let delivered = tree.sync_accessibility();
826 assert_eq!(
827 announced_by_consumer(&delivered),
828 vec![("Event added".to_string(), accesskit::Live::Polite)],
829 "the message must still be there to deliver"
830 );
831 }
832
833 #[test]
836 fn an_empty_announcement_is_not_exposed() {
837 let mut tree = tree_with_one_widget();
838 let _ = tree.sync_accessibility();
839 tree.announce(" ");
840 let update = tree.sync_accessibility();
841 assert_eq!(announced_by_consumer(&update), Vec::new());
842 }
843
844 #[derive(Debug)]
845 struct ActionWidget;
846
847 impl Widget for ActionWidget {
848 fn layout_response(
849 &self,
850 proposal: SizeProposal,
851 _ctx: &LayoutContext,
852 ) -> crate::widget::LayoutResponse {
853 proposal.resolve(0.0, 0.0).into()
854 }
855
856 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
857 builder.set_role(accesskit::Role::Button);
858 builder.set_name("Save");
859 builder.add_action(accesskit::Action::Click);
860 builder.add_action(accesskit::Action::Focus);
861 }
862 }
863
864 #[derive(Debug)]
865 struct ClickableWidget;
866
867 impl Widget for ClickableWidget {
868 fn layout_response(
869 &self,
870 proposal: SizeProposal,
871 _ctx: &LayoutContext,
872 ) -> crate::widget::LayoutResponse {
873 proposal.resolve(0.0, 0.0).into()
874 }
875
876 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
877 builder.set_role(accesskit::Role::Button);
878 builder.set_name("Click Me");
879 builder.add_action(accesskit::Action::Click);
880 }
881 }
882
883 #[test]
888 fn a_focusable_node_advertises_focus_without_the_widget_saying_so() {
889 let mut tree = WidgetTree::new();
890 let id = tree.add(ClickableWidget.focusable(true));
892 tree.layout(SizeProposal::exact(100.0, 100.0));
893
894 let info = tree.accessibility_node(id);
895 assert!(
896 info.actions().contains(&accesskit::Action::Focus),
897 "the arena knows the node is focusable; the AT node must say so"
898 );
899
900 let mut ops = crate::window::NoopWindowOps;
902 assert!(tree.dispatch_access_action(
903 crate::accessibility::widget_id_to_node_id(id),
904 accesskit::Action::Focus,
905 None,
906 &mut ops,
907 ));
908 assert_eq!(tree.focused(), Some(id));
909 }
910
911 #[derive(Debug)]
917 struct CompositeWidget {
918 child_ids: Vec<WidgetId>,
919 }
920
921 impl Widget for CompositeWidget {
922 fn layout_response(
923 &self,
924 proposal: SizeProposal,
925 _ctx: &LayoutContext,
926 ) -> crate::widget::LayoutResponse {
927 proposal.resolve(0.0, 0.0).into()
928 }
929
930 fn place_children(
931 &self,
932 bounds: Rect,
933 _proposal: SizeProposal,
934 children: &mut [crate::widget::WidgetPlacement],
935 _ctx: &LayoutContext,
936 ) {
937 for child in children.iter_mut() {
938 child.origin = bounds.origin();
939 child.size = bounds.size();
940 }
941 }
942
943 fn children(&self) -> Vec<WidgetId> {
944 self.child_ids.clone()
945 }
946
947 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
948 builder.set_role(accesskit::Role::SpinButton);
949 builder.add_action(accesskit::Action::Focus);
950 }
951 }
952
953 #[test]
964 fn an_at_focus_on_a_non_focusable_composite_lands_on_its_focusable_leaf() {
965 let mut tree = WidgetTree::new();
966 let leaf = tree.add(ClickableWidget.focusable(true));
967 let root = tree.add(CompositeWidget {
968 child_ids: vec![leaf],
969 });
970 tree.layout(SizeProposal::exact(100.0, 100.0));
971
972 let mut ops = crate::window::NoopWindowOps;
973 assert!(tree.dispatch_access_action(
974 crate::accessibility::widget_id_to_node_id(root),
975 accesskit::Action::Focus,
976 None,
977 &mut ops,
978 ));
979 assert_eq!(
980 tree.focused(),
981 Some(leaf),
982 "focus must reach the focusable leaf, not the composite root"
983 );
984 }
985
986 #[test]
995 fn an_at_focus_on_a_plain_container_does_not_walk_into_it() {
996 let mut tree = WidgetTree::new();
997 let leaf = tree.add(ClickableWidget.focusable(true));
998 let root = tree.add(StackWidget::new().child(leaf));
999 tree.layout(SizeProposal::exact(100.0, 100.0));
1000
1001 let mut ops = crate::window::NoopWindowOps;
1002 assert!(
1003 !tree.dispatch_access_action(
1004 crate::accessibility::widget_id_to_node_id(root),
1005 accesskit::Action::Focus,
1006 None,
1007 &mut ops,
1008 ),
1009 "a node that never advertised `Focus` must report the action unhandled"
1010 );
1011 assert_eq!(
1012 tree.focused(),
1013 None,
1014 "focus must stay where it was, not fall into the container's first control"
1015 );
1016 }
1017
1018 #[test]
1021 fn an_at_focus_on_a_focusable_node_still_lands_on_that_node() {
1022 let mut tree = WidgetTree::new();
1023 let outer = tree.add(ClickableWidget.focusable(true));
1024 tree.layout(SizeProposal::exact(100.0, 100.0));
1025
1026 let mut ops = crate::window::NoopWindowOps;
1027 assert!(tree.dispatch_access_action(
1028 crate::accessibility::widget_id_to_node_id(outer),
1029 accesskit::Action::Focus,
1030 None,
1031 &mut ops,
1032 ));
1033 assert_eq!(tree.focused(), Some(outer));
1034 }
1035
1036 #[test]
1039 fn a_derived_focus_action_is_still_removable_by_an_override() {
1040 let mut tree = WidgetTree::new();
1041 let id = tree.add(
1042 ClickableWidget
1043 .focusable(true)
1044 .access_remove_action(accesskit::Action::Focus),
1045 );
1046 tree.layout(SizeProposal::exact(100.0, 100.0));
1047 assert!(
1048 !tree
1049 .accessibility_node(id)
1050 .actions()
1051 .contains(&accesskit::Action::Focus)
1052 );
1053 }
1054
1055 #[test]
1058 fn a_non_focusable_node_does_not_advertise_focus() {
1059 let mut tree = WidgetTree::new();
1060 let id = tree.add(ClickableWidget);
1061 tree.layout(SizeProposal::exact(100.0, 100.0));
1062 assert!(
1063 !tree
1064 .accessibility_node(id)
1065 .actions()
1066 .contains(&accesskit::Action::Focus)
1067 );
1068 }
1069
1070 #[test]
1071 fn labeled_widget_has_accessibility() {
1072 let mut tree = WidgetTree::new();
1073 let widget = tree.add(FillWidget::new().label("Hello"));
1074 tree.layout(SizeProposal::exact(100.0, 20.0));
1075 let info = tree.accessibility_node(widget);
1076 assert_eq!(info.role(), accesskit::Role::Label);
1077 assert_eq!(info.name(), Some("Hello"));
1078 }
1079
1080 #[test]
1081 fn find_by_label_works() {
1082 let mut tree = WidgetTree::new();
1083 let widget = tree.add(FillWidget::new().label("Save"));
1084 tree.layout(SizeProposal::exact(100.0, 20.0));
1085 assert_eq!(tree.find_by_label("Save"), Some(widget));
1086 }
1087
1088 #[test]
1089 fn label_role_emits_text_as_value_not_label() {
1090 let mut tree = WidgetTree::new();
1097 let widget = tree.add(FillWidget::new().label("Section")); tree.layout(SizeProposal::exact(100.0, 20.0));
1099
1100 assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1102
1103 let update = tree.sync_accessibility();
1105 let node = &update
1106 .nodes
1107 .iter()
1108 .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1109 .expect("label node must be present in the tree update")
1110 .1;
1111 assert_eq!(node.role(), accesskit::Role::Label);
1112 assert_eq!(
1113 node.value(),
1114 Some("Section"),
1115 "Role::Label text must be exposed via `value`"
1116 );
1117 assert_eq!(
1118 node.label(),
1119 None,
1120 "Role::Label must not leave text in the `label` property"
1121 );
1122 }
1123
1124 #[test]
1125 fn find_by_role_works() {
1126 let mut tree = WidgetTree::new();
1127 tree.add(FillWidget::new().label("Text"));
1128 tree.layout(SizeProposal::exact(100.0, 20.0));
1129 assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1130 }
1131
1132 #[test]
1133 fn access_hidden_bound_to_signal_toggles_reactively() {
1134 use crate::signal::Signal;
1135 use crate::widget_builder::WidgetBuilder;
1136 let hidden = Signal::new(false);
1137 let mut tree = WidgetTree::new();
1138 let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1139 tree.layout(SizeProposal::exact(100.0, 30.0));
1140 assert!(
1141 !tree.accessibility_node(w).is_hidden(),
1142 "node should be visible to AT while the signal is false"
1143 );
1144 hidden.set(true);
1145 assert!(
1147 tree.accessibility_node(w).is_hidden(),
1148 "node should hide from AT when the bound signal flips to true"
1149 );
1150 hidden.set(false);
1151 assert!(!tree.accessibility_node(w).is_hidden());
1152 }
1153
1154 #[test]
1158 fn a_widget_with_a_context_menu_advertises_the_action() {
1159 let mut tree = WidgetTree::new();
1160 let plain = tree.add(FillWidget::new().label("no menu"));
1161 let with_menu = tree.add(
1162 FillWidget::new()
1163 .label("has menu")
1164 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1165 );
1166 tree.layout(SizeProposal::exact(100.0, 20.0));
1167
1168 assert!(
1169 tree.accessibility_node(with_menu)
1170 .actions()
1171 .contains(&Action::ShowContextMenu),
1172 "a node owning a context-menu factory must announce it"
1173 );
1174 assert!(
1175 !tree
1176 .accessibility_node(plain)
1177 .actions()
1178 .contains(&Action::ShowContextMenu),
1179 "and a node without one must not"
1180 );
1181 }
1182
1183 #[test]
1186 fn a_removed_context_menu_action_stays_removed() {
1187 let mut tree = WidgetTree::new();
1188 let id = tree.add(
1189 FillWidget::new()
1190 .label("suppressed")
1191 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1192 .access_remove_action(Action::ShowContextMenu),
1193 );
1194 tree.layout(SizeProposal::exact(100.0, 20.0));
1195 assert!(
1196 !tree
1197 .accessibility_node(id)
1198 .actions()
1199 .contains(&Action::ShowContextMenu)
1200 );
1201 }
1202
1203 #[test]
1208 fn a_child_does_not_advertise_its_parents_context_menu() {
1209 let mut tree = WidgetTree::new();
1210 let child = tree.add(FillWidget::new().label("inner"));
1211 let parent = tree.add(
1212 StackWidget::new()
1213 .child(child)
1214 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1215 );
1216 tree.layout(SizeProposal::exact(100.0, 20.0));
1217
1218 assert!(
1219 tree.accessibility_node(parent)
1220 .actions()
1221 .contains(&Action::ShowContextMenu)
1222 );
1223 assert!(
1224 !tree
1225 .accessibility_node(child)
1226 .actions()
1227 .contains(&Action::ShowContextMenu),
1228 "the menu belongs to the widget that declared it"
1229 );
1230 }
1231
1232 #[test]
1233 fn accessibility_node_collects_actions() {
1234 let mut tree = WidgetTree::new();
1235 let widget = tree.add(ActionWidget);
1236 tree.layout(SizeProposal::exact(100.0, 40.0));
1237
1238 let info = tree.accessibility_node(widget);
1239 assert_eq!(info.role(), accesskit::Role::Button);
1240 assert_eq!(info.name(), Some("Save"));
1241 assert_eq!(info.actions().len(), 2);
1242 assert!(info.actions().contains(&accesskit::Action::Click));
1243 assert!(info.actions().contains(&accesskit::Action::Focus));
1244 }
1245
1246 #[test]
1247 fn sync_accessibility_produces_tree_update() {
1248 let mut tree = WidgetTree::new();
1249 tree.add(FillWidget::new().label("First"));
1250 tree.add(FillWidget::new().label("Second"));
1251 tree.layout(SizeProposal::exact(200.0, 100.0));
1252
1253 let update = tree.sync_accessibility();
1254 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1257 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1258 assert!(update.tree.is_some());
1259 assert_a11y_tree_valid(&update);
1260 }
1261
1262 #[test]
1263 fn root_node_carries_locale_as_language() {
1264 let mut tree = WidgetTree::new();
1265 tree.add(FillWidget::new().label("Bonjour"));
1266
1267 tree.layout(SizeProposal::exact(200.0, 100.0));
1269 let update = tree.sync_accessibility();
1270 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1271 assert_eq!(
1272 update.nodes[0].1.language(),
1273 None,
1274 "no language before a locale is set"
1275 );
1276
1277 tree.set_locale("fr-FR".to_string());
1280 let update = tree.sync_accessibility();
1281 assert_eq!(
1282 update.nodes[0].1.language(),
1283 Some("fr-FR"),
1284 "root node must advertise the active locale as its language"
1285 );
1286 }
1287
1288 #[test]
1289 fn sync_accessibility_excludes_dormant_widgets() {
1290 let mut tree = WidgetTree::new();
1291 tree.add(FillWidget::new().label("Active"));
1292 let dormant = tree.add(FillWidget::new().label("Dormant"));
1293 tree.layout(SizeProposal::exact(200.0, 100.0));
1294
1295 tree.set_dormant(dormant);
1296
1297 let update = tree.sync_accessibility();
1298 assert_eq!(update.nodes.len(), 2 + ANNOUNCER_NODES);
1299 assert_a11y_tree_valid(&update);
1300 }
1301
1302 #[test]
1309 fn relayout_without_activation_change_does_not_dirty_a11y() {
1310 let mut tree = WidgetTree::new();
1311 let id = tree.add(FillWidget::new().label("Static"));
1312 tree.layout(SizeProposal::exact(200.0, 100.0));
1313
1314 let _ = tree.sync_accessibility();
1316 assert!(
1317 !tree.a11y_dirty,
1318 "sync_accessibility must clear the dirty flag"
1319 );
1320
1321 tree.arena.mark_needs_layout(id);
1325 tree.layout(SizeProposal::exact(200.0, 100.0));
1326
1327 assert!(
1328 !tree.a11y_dirty,
1329 "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1330 );
1331 }
1332
1333 #[derive(Debug)]
1336 struct Mover {
1337 offset: crate::signal::Signal<f32>,
1338 child: WidgetId,
1339 }
1340
1341 impl crate::widget::Widget for Mover {
1342 fn children(&self) -> Vec<WidgetId> {
1343 vec![self.child]
1344 }
1345 fn layout_response(
1346 &self,
1347 proposal: SizeProposal,
1348 _ctx: &crate::widget::LayoutContext,
1349 ) -> crate::widget::LayoutResponse {
1350 proposal.resolve(200.0, 100.0).into()
1351 }
1352 fn place_children(
1353 &self,
1354 bounds: teksilo_canvas::Rect,
1355 _proposal: SizeProposal,
1356 children: &mut [crate::widget::WidgetPlacement],
1357 _ctx: &crate::widget::LayoutContext,
1358 ) {
1359 if let Some(placement) = children.first_mut() {
1360 placement.origin =
1361 teksilo_canvas::Point::new(bounds.x + self.offset.get(), bounds.y);
1362 placement.size = teksilo_canvas::Size::new(50.0, 20.0);
1363 }
1364 }
1365 }
1366
1367 #[test]
1374 fn translating_a_widget_patches_the_cache_without_a_walk() {
1375 let mut tree = WidgetTree::new();
1376 let inner = tree.add(FillWidget::new().label("Row"));
1377 let offset = crate::signal::Signal::new(0.0f32);
1378 let root = tree.add(Mover {
1379 offset: offset.clone(),
1380 child: inner,
1381 });
1382 tree.layout(SizeProposal::exact(200.0, 100.0));
1383 let update = tree.sync_accessibility();
1384 let walks = tree.a11y_walk_generation();
1385 let before = find_node(&update, inner)
1386 .unwrap()
1387 .bounds()
1388 .expect("the node carries bounds");
1389
1390 offset.set(20.0);
1392 tree.arena.mark_needs_layout(root);
1393 tree.layout(SizeProposal::exact(200.0, 100.0));
1394
1395 let update = tree.sync_accessibility();
1396 assert_eq!(
1397 tree.a11y_walk_generation(),
1398 walks,
1399 "a pure translation must not cost a walk"
1400 );
1401 let after = find_node(&update, inner)
1402 .unwrap()
1403 .bounds()
1404 .expect("the node still carries bounds");
1405 assert!(
1406 (after.x0 - before.x0 - 20.0).abs() < 0.01,
1407 "the cached node must follow the move: {before:?} -> {after:?}"
1408 );
1409 }
1410
1411 #[test]
1412 fn at_version_does_not_bump_for_a_move() {
1413 let mut tree = WidgetTree::new();
1417 let inner = tree.add(FillWidget::new().label("Row"));
1418 let offset = crate::signal::Signal::new(0.0f32);
1419 let root = tree.add(Mover {
1420 offset: offset.clone(),
1421 child: inner,
1422 });
1423 tree.layout(SizeProposal::exact(200.0, 100.0));
1424 let _ = tree.sync_accessibility();
1425 let version = tree.at_version().get();
1426
1427 offset.set(20.0);
1428 tree.arena.mark_needs_layout(root);
1429 tree.layout(SizeProposal::exact(200.0, 100.0));
1430 let _ = tree.sync_accessibility();
1431
1432 assert_eq!(tree.at_version().get(), version);
1433 }
1434
1435 #[test]
1436 fn resizing_a_widget_dirties_the_accessibility_tree() {
1437 let mut tree = WidgetTree::new();
1441 let id = tree.add(FillWidget::new().label("Static"));
1442 tree.layout(SizeProposal::exact(200.0, 100.0));
1443 let _ = tree.sync_accessibility();
1444 let walks = tree.a11y_walk_generation();
1445
1446 tree.layout(SizeProposal::exact(300.0, 100.0));
1447 let _ = tree.sync_accessibility();
1448
1449 assert!(
1450 tree.a11y_walk_generation() > walks,
1451 "a resize must be walked, not patched"
1452 );
1453 let _ = id;
1454 }
1455
1456 #[test]
1457 fn changing_the_theme_dirties_the_accessibility_tree() {
1458 let mut tree = WidgetTree::new();
1463 tree.add(FillWidget::new().label("Static"));
1464 tree.layout(SizeProposal::exact(200.0, 100.0));
1465 let _ = tree.sync_accessibility();
1466 let walks = tree.a11y_walk_generation();
1467
1468 tree.set_theme(crate::presets::intui::dark());
1469 let _ = tree.sync_accessibility();
1470
1471 assert!(tree.a11y_walk_generation() > walks);
1472 }
1473
1474 #[test]
1475 fn changing_the_text_scale_dirties_the_accessibility_tree() {
1476 let mut tree = WidgetTree::new();
1477 tree.add(FillWidget::new().label("Static"));
1478 tree.layout(SizeProposal::exact(200.0, 100.0));
1479 let _ = tree.sync_accessibility();
1480 let walks = tree.a11y_walk_generation();
1481
1482 tree.set_user_text_scale(1.5);
1483 let _ = tree.sync_accessibility();
1484
1485 assert!(tree.a11y_walk_generation() > walks);
1486 }
1487
1488 #[test]
1489 fn a_moved_node_absent_from_the_cache_is_skipped() {
1490 use crate::widget_builder::WidgetBuilder;
1495 let mut tree = WidgetTree::new();
1496 let hidden = tree.add(FillWidget::new().label("Inner"));
1497 let offset = crate::signal::Signal::new(0.0f32);
1498 let root = tree.add(
1499 Mover {
1500 offset: offset.clone(),
1501 child: hidden,
1502 }
1503 .access_exclude_subtree(),
1504 );
1505 tree.layout(SizeProposal::exact(200.0, 100.0));
1506 let update = tree.sync_accessibility();
1507 assert!(
1508 find_node(&update, hidden).is_none(),
1509 "the excluded descendant must be absent for this test to mean anything"
1510 );
1511
1512 offset.set(20.0);
1513 tree.arena.mark_needs_layout(root);
1514 tree.layout(SizeProposal::exact(200.0, 100.0));
1515 let update = tree.sync_accessibility();
1516
1517 assert!(find_node(&update, hidden).is_none());
1518 assert_no_dangling_relationships(&update);
1519 }
1520
1521 #[test]
1522 fn focus_on_a_node_the_walk_did_not_emit_resolves_to_an_emitted_ancestor() {
1523 use crate::widget_builder::WidgetBuilder;
1534 let mut tree = WidgetTree::new();
1535 let inner = tree.add(ClickableWidget);
1536 let outer = tree.add(
1537 Mover {
1538 offset: crate::signal::Signal::new(0.0f32),
1539 child: inner,
1540 }
1541 .access_exclude_subtree(),
1542 );
1543 tree.layout(SizeProposal::exact(200.0, 100.0));
1544 tree.focus(inner);
1545
1546 let update = tree.sync_accessibility();
1547 assert!(
1548 find_node(&update, inner).is_none(),
1549 "precondition: the excluded descendant emits nothing"
1550 );
1551 let emitted: std::collections::HashSet<_> =
1552 update.nodes.iter().map(|(id, _)| *id).collect();
1553 assert!(
1554 emitted.contains(&update.focus),
1555 "the published focus must be a node in the published tree"
1556 );
1557 assert_eq!(
1558 update.focus,
1559 crate::accessibility::widget_id_to_node_id(outer),
1560 "…and specifically the nearest ancestor that did emit"
1561 );
1562 accesskit_consumer::Tree::new(update, false);
1564 }
1565
1566 #[test]
1570 fn activation_transition_does_dirty_a11y() {
1571 let mut tree = WidgetTree::new();
1572 let id = tree.add(FillWidget::new().label("Toggle"));
1573 tree.layout(SizeProposal::exact(200.0, 100.0));
1574 let _ = tree.sync_accessibility();
1575 assert!(!tree.a11y_dirty);
1576
1577 tree.set_dormant(id);
1578 tree.layout(SizeProposal::exact(200.0, 100.0));
1579 assert!(
1580 tree.a11y_dirty,
1581 "active→dormant transition must dirty the AT cache so the dormant node is removed"
1582 );
1583 }
1584
1585 #[test]
1586 fn sync_accessibility_includes_focus() {
1587 let mut tree = WidgetTree::new();
1588 let widget = tree.add(FillWidget::new().focusable().label("Focused"));
1589 tree.layout(SizeProposal::exact(100.0, 50.0));
1590 tree.focus(widget);
1591
1592 let update = tree.sync_accessibility();
1593 let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
1594 assert_eq!(update.focus, expected_focus);
1595 assert_a11y_tree_valid(&update);
1596 }
1597
1598 #[test]
1599 fn sync_accessibility_parent_child_relationship() {
1600 let mut tree = WidgetTree::new();
1601 let child = tree.add(FillWidget::new().label("Child"));
1602 let parent = tree.add(
1605 StackWidget::new()
1606 .child(child)
1607 .access_label_literal("Parent"),
1608 );
1609 tree.layout(SizeProposal::exact(100.0, 50.0));
1610
1611 let update = tree.sync_accessibility();
1612 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1613
1614 let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
1615 let parent_node = update
1616 .nodes
1617 .iter()
1618 .find(|(id, _)| *id == parent_node_id)
1619 .map(|(_, node)| node)
1620 .unwrap();
1621
1622 let child_node_id = crate::accessibility::widget_id_to_node_id(child);
1623 assert!(parent_node.children().contains(&child_node_id));
1624 assert_a11y_tree_valid(&update);
1625 }
1626
1627 #[test]
1628 fn presentational_containers_collapse_and_promote_children() {
1629 let mut tree = WidgetTree::new();
1635 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(
1639 StackWidget::new()
1640 .child(outer)
1641 .access_label_literal("Group"),
1642 );
1643 tree.layout(SizeProposal::exact(100.0, 50.0));
1644 let update = tree.sync_accessibility();
1645
1646 assert!(find_node(&update, inner).is_none(), "bare inner pruned");
1647 assert!(find_node(&update, outer).is_none(), "bare outer pruned");
1648
1649 let labeled_node = find_node(&update, labeled).expect("labeled group survives");
1650 let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
1651 assert!(
1652 labeled_node.children().contains(&leaf_nid),
1653 "leaf promoted past both bare containers to the labeled ancestor"
1654 );
1655 assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
1656 assert_a11y_tree_valid(&update);
1657 }
1658
1659 #[test]
1660 fn find_by_action_finds_clickable() {
1661 let mut tree = WidgetTree::new();
1662 let widget = tree.add(ClickableWidget);
1663 tree.layout(SizeProposal::exact(100.0, 40.0));
1664
1665 assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
1666 assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
1667 }
1668
1669 #[test]
1670 fn text_content_returns_accessibility_name() {
1671 let mut tree = WidgetTree::new();
1672 let widget = tree.add(FillWidget::new().label("Hello World"));
1673 tree.layout(SizeProposal::exact(100.0, 50.0));
1674
1675 assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
1676 }
1677
1678 #[test]
1679 fn text_content_returns_none_without_label() {
1680 let mut tree = WidgetTree::new();
1681 let widget = tree.add(FillWidget::new());
1682 tree.layout(SizeProposal::exact(100.0, 50.0));
1683
1684 assert_eq!(tree.text_content(widget), None);
1685 }
1686
1687 #[test]
1688 fn descendant_of_disabled_ancestor_reports_disabled() {
1689 use crate::signal::Signal;
1690
1691 let mut tree = WidgetTree::new();
1692 let child = tree.add(FillWidget::new().label("Child"));
1693 let parent = tree.add(StackWidget::new().child(child));
1694 tree.enabled_when(parent, Signal::new(false));
1695 tree.layout(SizeProposal::exact(100.0, 50.0));
1696
1697 assert!(
1698 tree.accessibility_node(child).is_disabled(),
1699 "descendant should report disabled when ancestor is disabled"
1700 );
1701 }
1702
1703 #[test]
1704 fn text_value_returns_accessibility_value() {
1705 #[derive(Debug)]
1706 struct ValueWidget;
1707
1708 impl Widget for ValueWidget {
1709 fn layout_response(
1710 &self,
1711 proposal: SizeProposal,
1712 _ctx: &LayoutContext,
1713 ) -> crate::widget::LayoutResponse {
1714 proposal.resolve(0.0, 0.0).into()
1715 }
1716
1717 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1718 builder.set_role(accesskit::Role::Slider);
1719 builder.set_name("Volume");
1720 builder.set_value("75%");
1721 }
1722 }
1723
1724 let mut tree = WidgetTree::new();
1725 let widget = tree.add(ValueWidget);
1726 tree.layout(SizeProposal::exact(100.0, 40.0));
1727
1728 assert_eq!(tree.text_value(widget), Some("75%".to_string()));
1729 assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
1730 }
1731
1732 #[test]
1733 fn sync_accessibility_has_no_duplicate_children() {
1734 let mut tree = WidgetTree::new();
1738 let grandchild = tree.add(FillWidget::new().label("Grandchild"));
1739 let child_a = tree.add(StackWidget::new().child(grandchild));
1740 let child_b = tree.add(FillWidget::new().label("Sibling"));
1741 let _root = tree.add(StackWidget::new().child(child_a).child(child_b));
1742 tree.layout(SizeProposal::exact(200.0, 100.0));
1743
1744 let update = tree.sync_accessibility();
1745
1746 let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
1747 std::collections::HashMap::new();
1748 for (parent_id, node) in &update.nodes {
1749 for &child_id in node.children() {
1750 let prev = all_children.insert(child_id, *parent_id);
1751 assert!(
1752 prev.is_none(),
1753 "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
1754 );
1755 }
1756 }
1757 assert_a11y_tree_valid(&update);
1758 }
1759
1760 #[test]
1761 fn no_dangling_relationships_in_basic_tree() {
1762 let mut tree = WidgetTree::new();
1763 let child = tree.add(FillWidget::new().label("Child"));
1764 let _parent = tree.add(StackWidget::new().child(child));
1765 tree.layout(SizeProposal::exact(100.0, 50.0));
1766
1767 let update = tree.sync_accessibility();
1768 assert_no_dangling_relationships(&update);
1769 assert_a11y_tree_valid(&update);
1770 }
1771
1772 use crate::widget_builder::WidgetBuilder;
1777 use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
1778
1779 fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
1781 let nid = crate::accessibility::widget_id_to_node_id(id);
1782 update
1783 .nodes
1784 .iter()
1785 .find(|(node_id, _)| *node_id == nid)
1786 .map(|(_, n)| n)
1787 }
1788
1789 #[derive(Debug)]
1792 struct AlwaysHiddenWidget;
1793 impl Widget for AlwaysHiddenWidget {
1794 fn layout_response(
1795 &self,
1796 proposal: SizeProposal,
1797 _ctx: &LayoutContext,
1798 ) -> crate::widget::LayoutResponse {
1799 proposal.resolve(0.0, 0.0).into()
1800 }
1801 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1802 builder.set_role(Role::GenericContainer);
1803 builder.set_hidden();
1804 }
1805 }
1806
1807 #[test]
1809 fn access_label_replaces_widget_label() {
1810 let mut tree = WidgetTree::new();
1811 let id = tree.add(ClickableWidget.access_label_literal("Publish"));
1812 tree.layout(SizeProposal::exact(100.0, 40.0));
1813 assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
1814 }
1815
1816 #[test]
1818 fn access_description_appears_on_bare_widget() {
1819 let mut tree = WidgetTree::new();
1820 let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
1821 tree.layout(SizeProposal::exact(50.0, 50.0));
1822 let update = tree.sync_accessibility();
1823 let node = find_node(&update, id).expect("node present");
1824 assert_eq!(node.description(), Some("Decorative"));
1825 }
1826
1827 #[test]
1829 fn access_value_replaces_widget_value() {
1830 #[derive(Debug)]
1831 struct SliderWidget;
1832 impl Widget for SliderWidget {
1833 fn layout_response(
1834 &self,
1835 proposal: SizeProposal,
1836 _ctx: &LayoutContext,
1837 ) -> crate::widget::LayoutResponse {
1838 proposal.resolve(0.0, 0.0).into()
1839 }
1840 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1841 builder.set_role(Role::Slider);
1842 builder.set_value("50");
1843 }
1844 }
1845 let mut tree = WidgetTree::new();
1846 let id = tree.add(SliderWidget.access_value_literal("Custom"));
1847 tree.layout(SizeProposal::exact(100.0, 40.0));
1848 assert_eq!(tree.text_value(id), Some("Custom".to_string()));
1849 }
1850
1851 #[test]
1853 fn access_role_overrides_widget_role() {
1854 let mut tree = WidgetTree::new();
1855 let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
1856 tree.layout(SizeProposal::exact(100.0, 40.0));
1857 assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
1858 }
1859
1860 #[test]
1861 fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
1862 let mut tree = WidgetTree::new();
1869 let anchor = tree.add(FillWidget::new().label("Save"));
1870 let tip = tree.add(FillWidget::new().label("Save the file"));
1871 tree.layout(SizeProposal::exact(200.0, 100.0));
1872 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
1873
1874 assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
1875 let update = tree.sync_accessibility();
1876 let node = find_node(&update, anchor).expect("anchor node present");
1877 assert_eq!(
1878 node.description(),
1879 Some("Save the file"),
1880 "the anchor must describe itself with its tooltip text while unshown"
1881 );
1882 }
1883
1884 #[test]
1885 fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
1886 let mut tree = WidgetTree::new();
1889 let anchor = tree.add(FillWidget::new().label("Save"));
1890 let tip = tree.add(FillWidget::new().label("Save the file"));
1891 tree.layout(SizeProposal::exact(200.0, 100.0));
1892 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
1893
1894 tree.pointer_move(tree.bounds(anchor).center());
1895 tree.advance_time(std::time::Duration::from_millis(150));
1896 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
1897
1898 let update = tree.sync_accessibility();
1899 let node = find_node(&update, anchor).expect("anchor node present");
1900 assert!(
1901 !node.described_by().is_empty(),
1902 "a shown tooltip is wired as a described_by relation"
1903 );
1904 }
1905
1906 #[test]
1908 fn access_hint_alias_writes_description_field() {
1909 let mut tree = WidgetTree::new();
1910 let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
1911 tree.layout(SizeProposal::exact(50.0, 50.0));
1912 let update = tree.sync_accessibility();
1913 let node = find_node(&update, id).unwrap();
1914 assert_eq!(node.description(), Some("Tip"));
1915 }
1916
1917 #[test]
1919 fn access_identifier_writes_author_id() {
1920 let mut tree = WidgetTree::new();
1921 let id = tree.add(FillWidget::new().access_identifier("save-button"));
1922 tree.layout(SizeProposal::exact(50.0, 50.0));
1923 let update = tree.sync_accessibility();
1924 let node = find_node(&update, id).unwrap();
1925 assert_eq!(node.author_id(), Some("save-button"));
1926 }
1927
1928 #[test]
1930 fn access_shortcut_literal_set() {
1931 let mut tree = WidgetTree::new();
1932 let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
1933 tree.layout(SizeProposal::exact(50.0, 50.0));
1934 let update = tree.sync_accessibility();
1935 let node = find_node(&update, id).unwrap();
1936 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
1937 }
1938
1939 #[test]
1942 fn access_shortcut_id_resolves_and_tracks_rebinds() {
1943 use crate::event::Key;
1944 use crate::shortcut::{KeyStroke, Shortcut};
1945
1946 let mut tree = WidgetTree::new();
1947 tree.shortcut_registry_mut().register(
1948 Shortcut::new("app.save")
1949 .name("Save")
1950 .primary(KeyStroke::command(Key::S))
1951 .build(),
1952 );
1953 let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
1954 tree.layout(SizeProposal::exact(50.0, 50.0));
1955
1956 let accel = if cfg!(target_os = "macos") {
1960 "Cmd"
1961 } else {
1962 "Ctrl"
1963 };
1964
1965 let update = tree.sync_accessibility();
1967 let node = find_node(&update, id).unwrap();
1968 assert_eq!(
1969 node.keyboard_shortcut(),
1970 Some(format!("{accel}+S").as_str())
1971 );
1972
1973 tree.shortcut_registry_mut()
1975 .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
1976 let update = tree.sync_accessibility();
1977 let node = find_node(&update, id).unwrap();
1978 assert_eq!(
1979 node.keyboard_shortcut(),
1980 Some(format!("{accel}+Q").as_str())
1981 );
1982 }
1983
1984 #[test]
1987 fn access_shortcut_id_unknown_id_omits_announcement() {
1988 let mut tree = WidgetTree::new();
1989 let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
1990 tree.layout(SizeProposal::exact(50.0, 50.0));
1991 let update = tree.sync_accessibility();
1992 let node = find_node(&update, id).unwrap();
1993 assert_eq!(node.keyboard_shortcut(), None);
1994 }
1995
1996 #[test]
1998 fn access_hidden_true_hides_widget() {
1999 let mut tree = WidgetTree::new();
2000 let id = tree.add(ClickableWidget.access_hidden(true));
2001 tree.layout(SizeProposal::exact(50.0, 50.0));
2002 assert!(tree.accessibility_node(id).is_hidden());
2003 }
2004
2005 #[test]
2007 fn access_hidden_false_clears_widget_set_hidden() {
2008 let mut tree = WidgetTree::new();
2009 let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
2010 tree.layout(SizeProposal::exact(50.0, 50.0));
2011 assert!(
2012 !tree.accessibility_node(id).is_hidden(),
2013 "access_hidden(false) should clear widget-emitted hidden"
2014 );
2015 }
2016
2017 #[test]
2019 fn access_disabled_true_marks_disabled() {
2020 let mut tree = WidgetTree::new();
2021 let id = tree.add(FillWidget::new().access_disabled(true));
2022 tree.layout(SizeProposal::exact(50.0, 50.0));
2023 assert!(tree.accessibility_node(id).is_disabled());
2024 }
2025
2026 #[test]
2028 fn access_disabled_false_clears_arena_driven_disabled() {
2029 use crate::signal::Signal;
2030 let mut tree = WidgetTree::new();
2031 let id = tree.add(FillWidget::new().label("X").access_disabled(false));
2032 tree.enabled_when(id, Signal::new(false));
2033 tree.layout(SizeProposal::exact(50.0, 50.0));
2034 assert!(
2035 !tree.accessibility_node(id).is_disabled(),
2036 "access_disabled(false) should clear even arena-driven disabled"
2037 );
2038 }
2039
2040 #[test]
2042 fn access_controls_appends() {
2043 let mut tree = WidgetTree::new();
2044 let target = tree.add(FillWidget::new().label("Target"));
2045 let controller = tree.add(
2046 FillWidget::new()
2047 .label("Controller")
2048 .access_controls(target),
2049 );
2050 tree.layout(SizeProposal::exact(100.0, 50.0));
2051 let update = tree.sync_accessibility();
2052 let node = find_node(&update, controller).unwrap();
2053 let target_nid = crate::accessibility::widget_id_to_node_id(target);
2054 assert!(
2055 node.controls().contains(&target_nid),
2056 "controls list should contain the target NodeId"
2057 );
2058 }
2059
2060 #[test]
2062 fn access_described_by_appends() {
2063 let mut tree = WidgetTree::new();
2064 let other = tree.add(FillWidget::new().label("Desc"));
2065 let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
2066 tree.layout(SizeProposal::exact(100.0, 50.0));
2067 let update = tree.sync_accessibility();
2068 let node = find_node(&update, id).unwrap();
2069 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2070 assert!(node.described_by().contains(&other_nid));
2071 }
2072
2073 #[test]
2075 fn access_labelled_by_appends() {
2076 let mut tree = WidgetTree::new();
2077 let other = tree.add(FillWidget::new().label("Lbl"));
2078 let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
2079 tree.layout(SizeProposal::exact(100.0, 50.0));
2080 let update = tree.sync_accessibility();
2081 let node = find_node(&update, id).unwrap();
2082 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2083 assert!(node.labelled_by().contains(&other_nid));
2084 }
2085
2086 #[test]
2098 fn a_child_attached_to_a_node_that_never_reached_the_tree_is_stripped() {
2099 use crate::accessibility::{AccessNodeBuilder, SyntheticKind, widget_id_to_node_id};
2100
2101 #[derive(Debug)]
2104 struct Grafter {
2105 child: std::cell::Cell<Option<WidgetId>>,
2106 }
2107
2108 impl Widget for Grafter {
2109 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2110 let child = ctx.add(FillWidget::new().label("Card"));
2111 self.child.set(Some(child));
2112 vec![child]
2113 }
2114 fn layout_response(
2115 &self,
2116 _p: SizeProposal,
2117 _c: &crate::widget::LayoutContext,
2118 ) -> crate::widget::LayoutResponse {
2119 teksilo_canvas::Size::new(100.0, 100.0).into()
2120 }
2121 fn children(&self) -> Vec<WidgetId> {
2122 self.child.get().into_iter().collect()
2123 }
2124 fn wants_descendant_redirects(&self) -> bool {
2125 true
2126 }
2127 fn a11y_redirect_descendant(
2128 &self,
2129 _self_id: WidgetId,
2130 descendant: WidgetId,
2131 ) -> Option<accesskit::NodeId> {
2132 (self.child.get() == Some(descendant)).then(|| widget_id_to_node_id(descendant))
2135 }
2136 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2137 builder.set_role(accesskit::Role::Pane);
2138 let group =
2139 builder.push_scene_child_under(None, 7, SyntheticKind::SceneGroup, |c| {
2140 c.set_role(accesskit::Role::GenericContainer);
2141 c.set_name("Group".to_string());
2142 });
2143 if let Some(child) = self.child.get() {
2144 builder.attach_scene_child_under(group, widget_id_to_node_id(child));
2145 }
2146 }
2147 }
2148
2149 let mut tree = WidgetTree::new();
2150 let grafter = tree.add(Grafter {
2151 child: std::cell::Cell::new(None),
2152 });
2153 tree.layout(SizeProposal::exact(200.0, 200.0));
2154 let child = tree.children(grafter)[0];
2155
2156 let update = tree.sync_accessibility();
2158 let child_nid = widget_id_to_node_id(child);
2159 assert!(
2160 update.nodes.iter().any(|(id, _)| *id == child_nid),
2161 "precondition: the grafted child is in the tree"
2162 );
2163 assert!(
2164 update
2165 .nodes
2166 .iter()
2167 .any(|(_, n)| n.children().contains(&child_nid)),
2168 "precondition: something names it as a child"
2169 );
2170 assert_a11y_tree_valid(&update);
2171
2172 tree.set_dormant(child);
2176 let update = tree.sync_accessibility();
2177 assert!(
2178 !update.nodes.iter().any(|(id, _)| *id == child_nid),
2179 "a dormant widget emits no node"
2180 );
2181 let emitted: std::collections::HashSet<accesskit::NodeId> =
2182 update.nodes.iter().map(|(id, _)| *id).collect();
2183 for (parent, node) in &update.nodes {
2184 for c in node.children() {
2185 assert!(
2186 emitted.contains(c),
2187 "node {parent:?} names child {c:?}, absent from the tree"
2188 );
2189 }
2190 }
2191 assert_a11y_tree_valid(&update);
2194 }
2195
2196 #[test]
2197 fn a_labelled_by_target_that_went_dormant_is_stripped() {
2198 let mut tree = WidgetTree::new();
2204 let title = tree.add(FillWidget::new().label("Preferences"));
2205 let dialog = tree.add(FillWidget::new().access_labelled_by(title));
2206 tree.layout(SizeProposal::exact(200.0, 100.0));
2207
2208 let update = tree.sync_accessibility();
2209 assert!(
2210 !find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2211 "the relation is live while the target is in the tree"
2212 );
2213
2214 tree.set_dormant(title);
2215 let update = tree.sync_accessibility();
2216 assert!(
2217 find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2218 "a target absent from the tree must not survive in the relation"
2219 );
2220 assert_no_dangling_relationships(&update);
2221 }
2222
2223 #[test]
2224 fn re_registering_a_labelled_by_relation_does_not_duplicate_it() {
2225 let mut tree = WidgetTree::new();
2229 let title = tree.add(FillWidget::new().label("Name"));
2230 let field = tree.add(FillWidget::new());
2231 tree.layout(SizeProposal::exact(200.0, 100.0));
2232
2233 for _ in 0..3 {
2234 tree.push_access_labelled_by(field, title);
2235 }
2236 let update = tree.sync_accessibility();
2237 assert_eq!(
2238 find_node(&update, field).unwrap().labelled_by().len(),
2239 1,
2240 "the same target registered repeatedly is one relation"
2241 );
2242 }
2243
2244 #[test]
2245 fn re_registering_a_described_by_relation_does_not_duplicate_it() {
2246 let mut tree = WidgetTree::new();
2247 let hint = tree.add(FillWidget::new().label("Must be unique"));
2248 let field = tree.add(FillWidget::new());
2249 tree.layout(SizeProposal::exact(200.0, 100.0));
2250
2251 for _ in 0..3 {
2252 tree.push_access_described_by(field, hint);
2253 }
2254 let update = tree.sync_accessibility();
2255 assert_eq!(
2256 find_node(&update, field).unwrap().described_by().len(),
2257 1,
2258 "the same target registered repeatedly is one relation"
2259 );
2260 }
2261
2262 #[test]
2264 fn access_live_assertive_set() {
2265 let mut tree = WidgetTree::new();
2266 let id = tree.add(FillWidget::new().access_live(Live::Assertive));
2267 tree.layout(SizeProposal::exact(50.0, 50.0));
2268 let update = tree.sync_accessibility();
2269 let node = find_node(&update, id).unwrap();
2270 assert_eq!(node.live(), Some(Live::Assertive));
2271 }
2272
2273 #[test]
2275 fn access_aria_current_set() {
2276 let mut tree = WidgetTree::new();
2277 let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
2278 tree.layout(SizeProposal::exact(50.0, 50.0));
2279 let update = tree.sync_accessibility();
2280 let node = find_node(&update, id).unwrap();
2281 assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
2282 }
2283
2284 #[test]
2286 fn access_has_popup_set() {
2287 let mut tree = WidgetTree::new();
2288 let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
2289 tree.layout(SizeProposal::exact(50.0, 50.0));
2290 let update = tree.sync_accessibility();
2291 let node = find_node(&update, id).unwrap();
2292 assert_eq!(node.has_popup(), Some(HasPopup::Menu));
2293 }
2294
2295 #[test]
2297 fn access_orientation_set() {
2298 let mut tree = WidgetTree::new();
2299 let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
2300 tree.layout(SizeProposal::exact(50.0, 50.0));
2301 let update = tree.sync_accessibility();
2302 let node = find_node(&update, id).unwrap();
2303 assert_eq!(node.orientation(), Some(Orientation::Vertical));
2304 }
2305
2306 #[test]
2308 fn access_numeric_value_and_range() {
2309 let mut tree = WidgetTree::new();
2310 let id = tree.add(
2311 FillWidget::new()
2312 .access_role(Role::Slider)
2313 .access_numeric_value(50.0)
2314 .access_numeric_range(0.0, 100.0)
2315 .access_numeric_step(5.0),
2316 );
2317 tree.layout(SizeProposal::exact(50.0, 50.0));
2318 let update = tree.sync_accessibility();
2319 let node = find_node(&update, id).unwrap();
2320 assert_eq!(node.numeric_value(), Some(50.0));
2321 assert_eq!(node.min_numeric_value(), Some(0.0));
2322 assert_eq!(node.max_numeric_value(), Some(100.0));
2323 assert_eq!(node.numeric_value_step(), Some(5.0));
2324 }
2325
2326 #[test]
2328 fn access_action_advertises_and_routes() {
2329 use crate::signal::Signal;
2330 let flag = Signal::new(false);
2331 let flag_for_cb = flag.clone();
2332 let mut tree = WidgetTree::new();
2333 let id = tree.add(
2334 FillWidget::new()
2335 .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
2336 );
2337 tree.layout(SizeProposal::exact(50.0, 50.0));
2338 let info = tree.accessibility_node(id);
2339 assert!(info.actions().contains(&Action::ShowContextMenu));
2340 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2341 action: Action::ShowContextMenu,
2342 target: Some(id),
2343 target_node: crate::accessibility::widget_id_to_node_id(id),
2344 data: None,
2345 });
2346 assert!(flag.get(), "callback should have been invoked");
2347 }
2348
2349 #[test]
2351 fn access_two_actions_both_route() {
2352 use crate::signal::Signal;
2353 let click = Signal::new(false);
2354 let increment = Signal::new(false);
2355 let click_cb = click.clone();
2356 let inc_cb = increment.clone();
2357 let mut tree = WidgetTree::new();
2358 let id = tree.add(
2359 FillWidget::new()
2360 .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
2361 .access_action(Action::Increment, move |_| inc_cb.set(true)),
2362 );
2363 tree.layout(SizeProposal::exact(50.0, 50.0));
2364 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2365 action: Action::ShowContextMenu,
2366 target: Some(id),
2367 target_node: crate::accessibility::widget_id_to_node_id(id),
2368 data: None,
2369 });
2370 assert!(click.get() && !increment.get(), "only first action fired");
2371 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2372 action: Action::Increment,
2373 target: Some(id),
2374 target_node: crate::accessibility::widget_id_to_node_id(id),
2375 data: None,
2376 });
2377 assert!(
2378 click.get() && increment.get(),
2379 "both actions fired exactly once each"
2380 );
2381 }
2382
2383 #[test]
2385 fn access_remove_action_suppresses_widget_action() {
2386 let mut tree = WidgetTree::new();
2387 let id = tree.add(ActionWidget.access_remove_action(Action::Click));
2388 tree.layout(SizeProposal::exact(50.0, 50.0));
2389 let info = tree.accessibility_node(id);
2390 assert!(!info.actions().contains(&Action::Click));
2391 assert!(info.actions().contains(&Action::Focus));
2392 }
2393
2394 #[test]
2396 fn access_custom_action_uses_localized_label() {
2397 let mut tree = WidgetTree::new();
2398 let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
2399 tree.layout(SizeProposal::exact(50.0, 50.0));
2400 let update = tree.sync_accessibility();
2401 let node = find_node(&update, id).unwrap();
2402 let actions = node.custom_actions();
2403 assert_eq!(actions.len(), 1);
2404 assert_eq!(actions[0].id, 0);
2405 assert_eq!(actions[0].description.as_str(), "Reply");
2406 }
2407
2408 #[test]
2410 fn access_custom_action_routes_by_index() {
2411 use crate::signal::Signal;
2412 let first = Signal::new(false);
2413 let second = Signal::new(false);
2414 let f = first.clone();
2415 let s = second.clone();
2416 let mut tree = WidgetTree::new();
2417 let id = tree.add(
2418 FillWidget::new()
2419 .access_custom_action_literal("First", move |_| f.set(true))
2420 .access_custom_action_literal("Second", move |_| s.set(true)),
2421 );
2422 tree.layout(SizeProposal::exact(50.0, 50.0));
2423 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2424 action: Action::CustomAction,
2425 target: Some(id),
2426 target_node: crate::accessibility::widget_id_to_node_id(id),
2427 data: Some(accesskit::ActionData::CustomAction(1)),
2428 });
2429 assert!(!first.get(), "first should not fire");
2430 assert!(second.get(), "second should fire (index 1)");
2431 }
2432
2433 #[test]
2435 fn access_action_layered_with_on_access_action() {
2436 use crate::signal::Signal;
2437 let from_override = Signal::new(false);
2438 let from_user = Signal::new(false);
2439 let ov_cb = from_override.clone();
2440 let user_cb = from_user.clone();
2441 let mut tree = WidgetTree::new();
2442 let id = tree.add(
2443 FillWidget::new()
2444 .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
2445 .on_access_action(move |_action, _ctx| {
2446 user_cb.set(true);
2447 crate::event::EventResponse::Handled
2448 }),
2449 );
2450 tree.layout(SizeProposal::exact(50.0, 50.0));
2451 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2452 action: Action::ShowContextMenu,
2453 target: Some(id),
2454 target_node: crate::accessibility::widget_id_to_node_id(id),
2455 data: None,
2456 });
2457 assert!(from_override.get(), "override callback fired");
2458 assert!(from_user.get(), "user catch-all fired");
2459 }
2460
2461 #[test]
2467 fn access_label_accepts_resolved_string_via_into() {
2468 struct ResolvedAtCall(String);
2472 impl From<ResolvedAtCall> for crate::signal::Prop<String> {
2473 fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
2474 crate::signal::Prop::Static(v.0)
2475 }
2476 }
2477 let mut tree = WidgetTree::new();
2478 let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
2479 tree.layout(SizeProposal::exact(50.0, 50.0));
2480 assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
2481 }
2482
2483 #[test]
2485 fn access_exclude_subtree_prunes_children_from_at_tree() {
2486 let mut tree = WidgetTree::new();
2487 let inner1 = tree.add(FillWidget::new().label("A"));
2488 let inner2 = tree.add(FillWidget::new().label("B"));
2489 let outer = tree.add(
2490 StackWidget::new()
2491 .child(inner1)
2492 .child(inner2)
2493 .access_label_literal("Section")
2497 .access_exclude_subtree(),
2498 );
2499 tree.layout(SizeProposal::exact(100.0, 50.0));
2500 let update = tree.sync_accessibility();
2501 assert!(find_node(&update, outer).is_some());
2503 assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
2504 assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
2505 let outer_node = find_node(&update, outer).unwrap();
2506 assert!(
2507 outer_node.children().is_empty(),
2508 "outer should have no AT children when excluded"
2509 );
2510 }
2511
2512 #[test]
2514 fn access_merge_subtree_concatenates_descendant_labels() {
2515 let mut tree = WidgetTree::new();
2516 let title = tree.add(FillWidget::new().label("Title"));
2517 let subtitle = tree.add(FillWidget::new().label("Subtitle"));
2518 let card = tree.add(
2519 StackWidget::new()
2520 .child(title)
2521 .child(subtitle)
2522 .access_merge_subtree(),
2523 );
2524 tree.layout(SizeProposal::exact(100.0, 50.0));
2525 assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
2527 let update = tree.sync_accessibility();
2528 assert!(find_node(&update, title).is_none());
2529 assert!(find_node(&update, subtitle).is_none());
2530 }
2531
2532 #[test]
2534 fn access_merge_subtree_unions_actions() {
2535 let mut tree = WidgetTree::new();
2536 let click_a = tree.add(ClickableWidget);
2537 let click_b = tree.add(ClickableWidget);
2538 let card = tree.add(
2539 StackWidget::new()
2540 .child(click_a)
2541 .child(click_b)
2542 .access_merge_subtree(),
2543 );
2544 tree.layout(SizeProposal::exact(100.0, 50.0));
2545 let actions = tree.accessibility_node(card).actions().to_vec();
2546 let click_count = actions.iter().filter(|a| **a == Action::Click).count();
2547 assert_eq!(
2548 click_count, 1,
2549 "Click should be present exactly once after merge (deduplicated)"
2550 );
2551 }
2552
2553 #[test]
2563 fn access_merge_subtree_first_nonempty_value_wins() {
2564 #[derive(Debug)]
2565 struct ValueWidget(&'static str);
2566 impl Widget for ValueWidget {
2567 fn layout_response(
2568 &self,
2569 proposal: SizeProposal,
2570 _ctx: &LayoutContext,
2571 ) -> crate::widget::LayoutResponse {
2572 proposal.resolve(0.0, 0.0).into()
2573 }
2574 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2575 builder.set_role(Role::Slider);
2576 builder.set_value(self.0);
2577 }
2578 }
2579 let mut tree = WidgetTree::new();
2580 let v1 = tree.add(ValueWidget("first"));
2581 let v2 = tree.add(ValueWidget("second"));
2582 let card = tree.add(
2583 StackWidget::new()
2584 .child(v1)
2585 .child(v2)
2586 .access_merge_subtree(),
2587 );
2588 tree.layout(SizeProposal::exact(100.0, 50.0));
2589 assert_eq!(tree.text_value(card), Some("first".to_string()));
2590 }
2591
2592 #[test]
2594 fn access_exclude_inside_merge() {
2595 let mut tree = WidgetTree::new();
2596 let visible = tree.add(FillWidget::new().label("VISIBLE"));
2597 let pruned = tree.add(FillWidget::new().label("PRUNED"));
2598 let inner_excluded = tree.add(StackWidget::new().child(pruned).access_exclude_subtree());
2599 let card = tree.add(
2600 StackWidget::new()
2601 .child(visible)
2602 .child(inner_excluded)
2603 .access_merge_subtree(),
2604 );
2605 tree.layout(SizeProposal::exact(100.0, 50.0));
2606 let merged = tree.text_content(card).unwrap_or_default();
2607 assert!(merged.contains("VISIBLE"));
2610 assert!(
2611 !merged.contains("PRUNED"),
2612 "excluded subtree should not contribute to merge"
2613 );
2614 }
2615
2616 #[test]
2618 fn access_merge_inside_merge() {
2619 let mut tree = WidgetTree::new();
2620 let inner_a = tree.add(FillWidget::new().label("a"));
2621 let inner_b = tree.add(FillWidget::new().label("b"));
2622 let inner_card = tree.add(
2623 StackWidget::new()
2624 .child(inner_a)
2625 .child(inner_b)
2626 .access_merge_subtree(),
2627 );
2628 let outer_extra = tree.add(FillWidget::new().label("X"));
2629 let outer = tree.add(
2630 StackWidget::new()
2631 .child(inner_card)
2632 .child(outer_extra)
2633 .access_merge_subtree(),
2634 );
2635 tree.layout(SizeProposal::exact(200.0, 100.0));
2636 let merged = tree.text_content(outer).unwrap_or_default();
2641 assert!(merged.contains("a"));
2642 assert!(merged.contains("b"));
2643 assert!(merged.contains("X"));
2644 let update = tree.sync_accessibility();
2646 assert!(find_node(&update, inner_a).is_none());
2647 assert!(find_node(&update, inner_b).is_none());
2648 assert!(find_node(&update, inner_card).is_none());
2649 assert!(find_node(&update, outer_extra).is_none());
2650 }
2651
2652 #[test]
2654 fn access_customize_runs_last() {
2655 let mut tree = WidgetTree::new();
2656 let id = tree.add(
2657 FillWidget::new()
2658 .access_label_literal("A")
2659 .access_customize(|b| b.set_name("B")),
2660 );
2661 tree.layout(SizeProposal::exact(50.0, 50.0));
2662 assert_eq!(tree.accessibility_node(id).name(), Some("B"));
2663 }
2664
2665 #[test]
2667 fn access_customize_can_reach_inner_mut() {
2668 let mut tree = WidgetTree::new();
2669 let id = tree.add(FillWidget::new().access_customize(|b| {
2670 b.inner_mut().set_author_id("from-customize");
2671 }));
2672 tree.layout(SizeProposal::exact(50.0, 50.0));
2673 let update = tree.sync_accessibility();
2674 let node = find_node(&update, id).unwrap();
2675 assert_eq!(node.author_id(), Some("from-customize"));
2676 }
2677
2678 #[test]
2680 fn access_overrides_zero_cost_when_unused() {
2681 use std::mem::size_of;
2688 assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
2689 assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
2690 }
2691
2692 #[test]
2693 fn accessibility_children_overrides_at_reading_order() {
2694 #[derive(Debug)]
2699 struct NamedLeaf(&'static str);
2700 impl Widget for NamedLeaf {
2701 fn layout_response(
2702 &self,
2703 proposal: SizeProposal,
2704 _ctx: &LayoutContext,
2705 ) -> crate::widget::LayoutResponse {
2706 proposal.resolve(10.0, 10.0).into()
2707 }
2708 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2709 builder.set_role(accesskit::Role::Button);
2713 builder.set_name(self.0);
2714 }
2715 }
2716
2717 #[derive(Debug, Default)]
2718 struct ReorderContainer {
2719 a: Option<WidgetId>,
2720 b: Option<WidgetId>,
2721 }
2722 impl Widget for ReorderContainer {
2723 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2724 let a = ctx.add(NamedLeaf("A"));
2725 let b = ctx.add(NamedLeaf("B"));
2726 self.a = Some(a);
2727 self.b = Some(b);
2728 vec![a, b]
2729 }
2730 fn layout_response(
2731 &self,
2732 proposal: SizeProposal,
2733 _ctx: &LayoutContext,
2734 ) -> crate::widget::LayoutResponse {
2735 proposal.resolve(100.0, 100.0).into()
2736 }
2737 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2738 builder.set_role(accesskit::Role::Group);
2741 builder.set_name("Container");
2742 }
2743 fn children(&self) -> Vec<WidgetId> {
2744 [self.a, self.b].into_iter().flatten().collect()
2745 }
2746 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2747 Some([self.b, self.a].into_iter().flatten().collect())
2749 }
2750 }
2751
2752 let mut tree = WidgetTree::new();
2753 let container = tree.add(ReorderContainer::default());
2754 tree.layout(SizeProposal::exact(100.0, 100.0));
2755 let update = tree.sync_accessibility();
2756
2757 let a_nid = update
2758 .nodes
2759 .iter()
2760 .find(|(_, n)| n.label() == Some("A"))
2761 .map(|(id, _)| *id)
2762 .expect("A node present");
2763 let b_nid = update
2764 .nodes
2765 .iter()
2766 .find(|(_, n)| n.label() == Some("B"))
2767 .map(|(id, _)| *id)
2768 .expect("B node present");
2769 let cnid = crate::accessibility::widget_id_to_node_id(container);
2770 let cnode = update
2771 .nodes
2772 .iter()
2773 .find(|(id, _)| *id == cnid)
2774 .map(|(_, n)| n)
2775 .expect("container node present");
2776 let kids = cnode.children();
2777 let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
2778 let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
2779 assert!(
2780 pb < pa,
2781 "accessibility_children() must set AT order B-before-A; got {kids:?}"
2782 );
2783 }
2784
2785 fn consumer_bounding_box(update: &accesskit::TreeUpdate, label: &str) -> accesskit::Rect {
2799 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
2800 let state = consumer.state();
2801 let mut stack = vec![state.root()];
2802 while let Some(node) = stack.pop() {
2803 let text = node.value().or_else(|| node.label());
2806 if text.as_deref() == Some(label) {
2807 return node.bounding_box().expect("node has bounds");
2808 }
2809 for child in node.children() {
2810 stack.push(child);
2811 }
2812 }
2813 panic!("no node labelled {label:?} in the consumer tree");
2814 }
2815
2816 fn tree_at_scale(scale: f32) -> (WidgetTree, WidgetId) {
2817 let mut tree = WidgetTree::new();
2818 let id = tree.add(FillWidget::new().label("content"));
2819 tree.set_device_scale_factor(scale);
2820 tree.layout(SizeProposal::exact(200.0, 100.0));
2821 (tree, id)
2822 }
2823
2824 #[test]
2825 fn the_root_carries_the_device_scale_as_its_transform() {
2826 let (mut tree, _) = tree_at_scale(2.0);
2827 let update = tree.sync_accessibility();
2828 let root = update
2829 .nodes
2830 .iter()
2831 .find(|(id, _)| *id == crate::accessibility::root_node_id())
2832 .map(|(_, n)| n)
2833 .expect("root node present");
2834 assert_eq!(
2835 root.transform().copied(),
2836 Some(accesskit::Affine::scale(2.0)),
2837 "AccessKit wants physical coordinates; the tree emits logical ones"
2838 );
2839 assert_a11y_tree_valid(&update);
2840 }
2841
2842 #[test]
2843 fn emitted_bounds_stay_logical_at_scale_two() {
2844 let (mut tree, id) = tree_at_scale(2.0);
2853 let update = tree.sync_accessibility();
2854 let layout = tree.bounds(id);
2855 let nid = crate::accessibility::widget_id_to_node_id(id);
2856 let raw = update
2857 .nodes
2858 .iter()
2859 .find(|(n, _)| *n == nid)
2860 .and_then(|(_, n)| n.bounds())
2861 .expect("widget node has bounds");
2862 assert_eq!(
2863 (raw.x0, raw.y0, raw.x1, raw.y1),
2864 (
2865 layout.x as f64,
2866 layout.y as f64,
2867 (layout.x + layout.width) as f64,
2868 (layout.y + layout.height) as f64,
2869 ),
2870 "an emitted rectangle must be the logical layout rectangle, unscaled"
2871 );
2872 }
2873
2874 #[test]
2893 fn an_interactive_node_emits_its_exact_layout_rectangle() {
2894 let mut tree = WidgetTree::new();
2895 let id = tree.add(ActionWidget);
2896 tree.layout(SizeProposal::exact(200.0, 100.0));
2897 let update = tree.sync_accessibility();
2898 let nid = crate::accessibility::widget_id_to_node_id(id);
2899 let node = update
2900 .nodes
2901 .iter()
2902 .find(|(n, _)| *n == nid)
2903 .map(|(_, n)| n)
2904 .expect("the interactive node is in the update");
2905 assert!(
2906 node.supports_action(accesskit::Action::Click),
2907 "the fixture has to be the clickable shape the rule is about, \
2908 or this test guards nothing"
2909 );
2910 let layout = tree.bounds(id);
2911 assert!(
2912 layout.width > 0.0 && layout.height > 0.0,
2913 "a real, non-degenerate rectangle to compare against: {layout:?}"
2914 );
2915 let raw = node.bounds().expect("the interactive node has bounds");
2916 assert_eq!(
2917 (raw.x0, raw.y0, raw.x1, raw.y1),
2918 (
2919 layout.x as f64,
2920 layout.y as f64,
2921 (layout.x + layout.width) as f64,
2922 (layout.y + layout.height) as f64,
2923 ),
2924 "a clickable node's emitted rectangle is its layout rectangle, \
2925 not an outset one"
2926 );
2927 }
2928
2929 #[test]
2930 fn what_an_assistive_technology_reads_is_the_physical_rectangle() {
2931 let (mut one, _) = tree_at_scale(1.0);
2935 let at_one = consumer_bounding_box(&one.sync_accessibility(), "content");
2936 let (mut two, _) = tree_at_scale(2.0);
2937 let at_two = consumer_bounding_box(&two.sync_accessibility(), "content");
2938 assert_eq!(at_two.x0, at_one.x0 * 2.0);
2939 assert_eq!(at_two.y0, at_one.y0 * 2.0);
2940 assert_eq!(at_two.x1, at_one.x1 * 2.0);
2941 assert_eq!(at_two.y1, at_one.y1 * 2.0);
2942 assert!(at_two.x1 > at_two.x0, "a real, non-degenerate rectangle");
2943 }
2944
2945 #[test]
2946 fn a_scale_change_invalidates_the_accessibility_cache() {
2947 let (mut tree, _) = tree_at_scale(1.0);
2953 let _ = tree.sync_accessibility();
2954 tree.set_device_scale_factor(2.0);
2955 let update = tree.sync_accessibility();
2956 let root = update
2957 .nodes
2958 .iter()
2959 .find(|(id, _)| *id == crate::accessibility::root_node_id())
2960 .map(|(_, n)| n)
2961 .expect("root node present");
2962 assert_eq!(
2963 root.transform().copied(),
2964 Some(accesskit::Affine::scale(2.0))
2965 );
2966 }
2967
2968 #[test]
2969 fn setting_the_same_scale_does_not_dirty_the_tree() {
2970 let (mut tree, _) = tree_at_scale(2.0);
2974 let first = tree.sync_accessibility();
2975 tree.set_device_scale_factor(2.0);
2976 let second = tree.sync_accessibility();
2977 assert_eq!(first, second);
2978 }
2979
2980 #[test]
2983 fn explore_by_touch_is_off_by_default() {
2984 let tree = WidgetTree::new();
2985 assert_eq!(tree.explore_by_touch(), crate::ExploreByTouch::Off);
2986 assert_eq!(
2987 tree.screen_reader_state(),
2988 crate::ScreenReaderState::Unknown
2989 );
2990 assert!(!tree.explore_by_touch_active());
2991 }
2992
2993 #[test]
2994 fn auto_follows_the_operating_systems_screen_reader_flag() {
2995 let mut tree = WidgetTree::new();
2996 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
2997 assert!(!tree.explore_by_touch_active(), "Unknown is not a yes");
2998 tree.set_screen_reader_state(crate::ScreenReaderState::Inactive);
2999 assert!(!tree.explore_by_touch_active());
3000 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3001 assert!(tree.explore_by_touch_active());
3002 }
3003
3004 #[test]
3005 fn on_ignores_the_screen_reader_flag_and_off_ignores_everything() {
3006 let mut tree = WidgetTree::new();
3007 tree.set_explore_by_touch(crate::ExploreByTouch::On);
3008 assert!(tree.explore_by_touch_active(), "On is the app's own choice");
3009 tree.set_explore_by_touch(crate::ExploreByTouch::Off);
3010 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3011 assert!(!tree.explore_by_touch_active());
3012 }
3013
3014 #[test]
3015 fn an_attached_accesskit_client_does_not_switch_exploring_on() {
3016 let mut tree = WidgetTree::new();
3022 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3023 tree.set_at_client_attached(true);
3024 assert!(tree.at_client_attached());
3025 assert_eq!(
3026 tree.screen_reader_state(),
3027 crate::ScreenReaderState::Unknown,
3028 "attaching says nothing about screen readers"
3029 );
3030 assert!(!tree.explore_by_touch_active());
3031 }
3032
3033 #[test]
3034 fn touch_still_reaches_a_handler_with_an_accesskit_client_attached() {
3035 use crate::Modifiers;
3041 use crate::pointer::{
3042 BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase,
3043 PointerSample,
3044 };
3045 use crate::widget_builder::WidgetBuilder;
3046 use std::cell::Cell;
3047 use std::rc::Rc;
3048
3049 let taps = Rc::new(Cell::new(0u32));
3050 let seen = Rc::clone(&taps);
3051 let mut tree = WidgetTree::new();
3052 let id = tree.add(
3053 FillWidget::new()
3054 .label("target")
3055 .on_tap(move |_e, _ctx| seen.set(seen.get() + 1)),
3056 );
3057 tree.layout(SizeProposal::exact(200.0, 100.0));
3058 tree.set_at_client_attached(true);
3059 assert!(!tree.explore_by_touch_active());
3060
3061 let at = Point::new(50.0, 50.0);
3062 let pid = PointerIdAllocator::global().begin(BackendDeviceKey::new(0x0A11), 1);
3063 let contact = |phase| PointerSample {
3064 pointer: PointerInfo::touch(pid, EventTime::ZERO),
3065 phase,
3066 position: at,
3067 button: None,
3068 modifiers: Modifiers::NONE,
3069 coalesced: Vec::new(),
3070 };
3071 tree.dispatch_pointer(contact(PointerPhase::Down));
3072 tree.dispatch_pointer(contact(PointerPhase::Up));
3073 assert!(tree.bounds(id).width > 0.0);
3074 assert_eq!(taps.get(), 1, "a touch tap must still activate its handler");
3075 }
3076
3077 #[test]
3078 fn a_detaching_client_is_evidence_that_no_screen_reader_is_reading() {
3079 let mut tree = WidgetTree::new();
3083 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3084 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3085 tree.set_at_client_attached(true);
3086 assert!(tree.explore_by_touch_active());
3087 tree.set_at_client_attached(false);
3088 assert_eq!(
3089 tree.screen_reader_state(),
3090 crate::ScreenReaderState::Inactive
3091 );
3092 assert!(!tree.explore_by_touch_active());
3093 }
3094
3095 #[test]
3096 fn a_repeated_detach_notice_does_not_clobber_a_fresh_os_reading() {
3097 let mut tree = WidgetTree::new();
3101 tree.set_at_client_attached(false);
3102 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3103 tree.set_at_client_attached(false);
3104 assert_eq!(tree.screen_reader_state(), crate::ScreenReaderState::Active);
3105 }
3106
3107 #[test]
3110 fn a_density_switch_announces_once_in_the_applications_own_words() {
3111 use teksilo_tokens::TargetDensity;
3112 let mut tree = tree_with_one_widget();
3113 tree.set_density_announcement(Some(std::rc::Rc::new(|d: TargetDensity| {
3114 format!("layout {d:?}")
3115 })));
3116 let _ = tree.sync_accessibility();
3117
3118 tree.set_input_density(TargetDensity::Comfortable);
3119 let update = tree.sync_accessibility();
3120 assert_eq!(
3121 announced_by_consumer(&update),
3122 vec![("layout Comfortable".to_string(), accesskit::Live::Polite)]
3123 );
3124
3125 let _ = tree.sync_accessibility(); tree.set_input_density(TargetDensity::Comfortable);
3128 let update = tree.sync_accessibility();
3129 assert_eq!(announced_by_consumer(&update), Vec::new());
3130 }
3131
3132 #[test]
3133 fn a_density_switch_is_silent_without_a_registered_wording() {
3134 use teksilo_tokens::TargetDensity;
3139 let mut tree = tree_with_one_widget();
3140 let _ = tree.sync_accessibility();
3141 tree.set_input_density(TargetDensity::Comfortable);
3142 let update = tree.sync_accessibility();
3143 assert_eq!(announced_by_consumer(&update), Vec::new());
3144 }
3145
3146 #[test]
3147 fn an_announcement_stays_quiet_when_the_widget_already_speaks() {
3148 let mut tree = WidgetTree::new();
3149 use crate::widget_builder::WidgetBuilder;
3150 let widget = tree.add(
3151 FillWidget::new()
3152 .label("Saved")
3153 .access_live(accesskit::Live::Polite),
3154 );
3155 tree.layout(SizeProposal::exact(200.0, 100.0));
3156 let _ = tree.sync_accessibility();
3158
3159 assert!(
3160 !tree.announce_unless_widget_speaks(widget, "Saved"),
3161 "a widget with its own live region must not be doubled"
3162 );
3163 let update = tree.sync_accessibility();
3164 let live = live_nodes_in_filtered_tree(&update);
3168 assert_eq!(live.len(), 1, "exactly one voice, not two: {live:?}");
3169 }
3170
3171 #[test]
3172 fn an_announcement_goes_ahead_when_the_widget_is_silent() {
3173 let mut tree = WidgetTree::new();
3174 let widget = tree.add(FillWidget::new().label("Row"));
3175 tree.layout(SizeProposal::exact(200.0, 100.0));
3176 let _ = tree.sync_accessibility();
3177
3178 assert!(tree.announce_unless_widget_speaks(widget, "Moved to 3 of 12"));
3179 let update = tree.sync_accessibility();
3180 assert_eq!(
3181 announced_by_consumer(&update),
3182 vec![("Moved to 3 of 12".to_string(), accesskit::Live::Polite)]
3183 );
3184 }
3185
3186 #[test]
3187 fn a_live_region_with_no_text_does_not_count_as_speaking() {
3188 let mut tree = WidgetTree::new();
3191 use crate::widget_builder::WidgetBuilder;
3192 let widget = tree.add(FillWidget::new().access_live(accesskit::Live::Polite));
3193 tree.layout(SizeProposal::exact(200.0, 100.0));
3194 let _ = tree.sync_accessibility();
3195 assert!(tree.announce_unless_widget_speaks(widget, "Something happened"));
3196 }
3197
3198 #[derive(Debug)]
3201 struct SpeakingSynthetic;
3202
3203 impl crate::widget::Widget for SpeakingSynthetic {
3204 fn layout_response(
3205 &self,
3206 proposal: SizeProposal,
3207 _ctx: &crate::widget::LayoutContext,
3208 ) -> crate::widget::LayoutResponse {
3209 proposal.resolve(0.0, 0.0).into()
3210 }
3211
3212 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3213 builder.set_role(accesskit::Role::Group);
3214 let child = builder.push_annotation_child(1, "2 items selected");
3215 builder.with_collected_node(child, |node| node.set_live(accesskit::Live::Polite));
3216 }
3217 }
3218
3219 #[test]
3220 fn a_live_synthetic_child_counts_as_the_widget_speaking() {
3221 let mut tree = WidgetTree::new();
3226 let widget = tree.add(SpeakingSynthetic);
3227 tree.layout(SizeProposal::exact(200.0, 100.0));
3228 let update = tree.sync_accessibility();
3229 assert!(
3230 update
3231 .nodes
3232 .iter()
3233 .any(|(id, _)| crate::accessibility::is_synthetic(*id)),
3234 "the fixture must actually emit a synthetic child"
3235 );
3236 assert!(!tree.announce_unless_widget_speaks(widget, "2 items selected"));
3237 }
3238
3239 #[test]
3240 fn a_descendants_live_region_silences_its_ancestor_too() {
3241 let mut tree = WidgetTree::new();
3244 let container = tree.add(StackWidget::new());
3245 use crate::widget_builder::WidgetBuilder;
3246 let inner = FillWidget::new()
3247 .label("3 results")
3248 .access_live(accesskit::Live::Polite);
3249 tree.add_child(container, inner);
3250 tree.layout(SizeProposal::exact(200.0, 100.0));
3251 let _ = tree.sync_accessibility();
3252 assert!(!tree.announce_unless_widget_speaks(container, "3 results"));
3253 }
3254}