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 {
48 self.refresh_accessibility();
49 self.cached_a11y
50 .as_ref()
51 .expect("refresh_accessibility leaves an update behind")
52 .clone()
53 }
54
55 #[doc(hidden)]
63 pub fn refresh_accessibility(&mut self) {
64 if self.a11y_update_requested.replace(false) {
69 self.a11y_dirty = true;
70 }
71 let current_shortcut_version = self.shortcut_registry.version().get();
79 if current_shortcut_version != self.last_synced_shortcut_version {
80 self.a11y_dirty = true;
81 self.last_synced_shortcut_version = current_shortcut_version;
82 }
83
84 let current_locale = self.locale_signal.get();
93 if current_locale != self.last_synced_locale {
94 self.a11y_dirty = true;
95 self.last_synced_locale = current_locale;
96 }
97
98 let current_title = self.window_title_for_accessibility();
101 if current_title != self.last_synced_window_title {
102 self.a11y_dirty = true;
103 self.last_synced_window_title = current_title;
104 }
105
106 self.step_announcers();
117
118 if !self.a11y_dirty
119 && let Some(cached) = &self.cached_a11y
120 {
121 let _ = cached;
134 let moved = self.patch_accessibility_bounds();
135 if moved
149 && self.announcement_ring.is_recording()
150 && let Some(patched) = &self.cached_a11y
151 && patched.nodes.iter().any(|(id, node)| {
152 !crate::announcer::is_announcer_node(*id)
153 && node.live().is_some_and(|live| live != accesskit::Live::Off)
154 })
155 {
156 self.announcement_ring.observe(patched);
157 }
158 return;
159 }
160
161 let (update, parents, local_bounds, descriptions, admitted) =
162 self.build_accessibility_tree(true);
163 let announcers_busy = self.settle_announcers(admitted);
164 self.announcement_ring.observe(&update);
169 let content_changed = self.cached_a11y.as_ref() != Some(&update);
176 self.cached_a11y = Some(update);
177 if content_changed {
178 self.adapter_ids.source_changed();
179 }
180 self.synthetic_parent_map = parents;
181 self.synthetic_local_bounds = local_bounds;
182 let descriptions_catching_up = descriptions.catching_up;
189 self.description_memory = descriptions;
190 self.a11y_dirty = false;
191 self.arena.take_a11y_moved();
194 self.arena.take_a11y_resized();
195 self.a11y_walk_generation = self.a11y_walk_generation.saturating_add(1);
196 if content_changed {
197 self.at_version.set(self.at_version.get().saturating_add(1));
200 }
201 if announcers_busy || descriptions_catching_up {
202 self.request_accessibility_update();
203 self.request_frame();
204 }
205 }
206
207 pub fn deliver_accessibility(&mut self, window_focused: bool) -> accesskit::TreeUpdate {
229 if self.cached_a11y.is_none() {
230 let _ = self.sync_accessibility();
231 }
232 let arena = &self.arena;
233 let source = self
234 .cached_a11y
235 .as_ref()
236 .expect("sync_accessibility leaves an update behind");
237 self.adapter_ids.deliver(source, window_focused, |id| {
238 crate::accessibility::node_id_to_widget_id_maybe(id)
239 .is_none_or(|widget| arena.get(widget).is_some())
240 })
241 }
242
243 #[doc(hidden)]
252 pub fn accessibility_delivery_owed(&self, window_focused: bool) -> bool {
253 self.cached_a11y.is_none() || self.adapter_ids.owes(window_focused)
254 }
255
256 #[doc(hidden)]
266 pub fn accessibility_unchanged(&mut self, window_focused: bool) -> accesskit::TreeUpdate {
267 if !self.accessibility_delivery_owed(window_focused)
268 && let Some(update) = self.adapter_ids.unchanged()
269 {
270 return update;
271 }
272 self.deliver_accessibility(window_focused)
273 }
274
275 #[doc(hidden)]
284 pub fn accessibility_held(&self, window_focused: bool) -> Option<accesskit::TreeUpdate> {
285 self.cached_a11y.as_ref()?;
286 self.adapter_ids.held(window_focused)
287 }
288
289 pub fn resolve_adapter_action(
294 &self,
295 request: accesskit::ActionRequest,
296 ) -> Option<accesskit::ActionRequest> {
297 self.adapter_ids.own_request(request)
298 }
299
300 pub fn a11y_walk_generation(&self) -> u64 {
306 self.a11y_walk_generation
307 }
308
309 fn patch_accessibility_bounds(&mut self) -> bool {
323 let moved = self.arena.take_a11y_moved();
324 if moved.is_empty() {
325 return false;
326 }
327 let Some(cached) = self.cached_a11y.as_mut() else {
328 return false;
329 };
330 let index: std::collections::HashMap<accesskit::NodeId, usize> = cached
331 .nodes
332 .iter()
333 .enumerate()
334 .map(|(i, (id, _))| (*id, i))
335 .collect();
336
337 for &id in moved.keys() {
338 let bounds = self.arena.bounds(id);
339 if let Some(&slot) = index.get(&crate::accessibility::widget_id_to_node_id(id)) {
340 cached.nodes[slot].1.set_bounds(to_accesskit_rect(bounds));
341 }
342 }
343
344 for (&syn_id, &owner) in &self.synthetic_parent_map {
349 let Some(delta) = moved.get(&owner) else {
350 continue;
351 };
352 let Some(&slot) = index.get(&syn_id) else {
353 continue;
354 };
355 let node = &mut cached.nodes[slot].1;
356 if node.transform().is_some() {
364 continue;
365 }
366 if let Some(local) = self.synthetic_local_bounds.get(&syn_id) {
367 let origin = self.arena.bounds(owner).origin();
368 node.set_bounds(to_accesskit_rect(teksilo_canvas::Rect::new(
369 origin.x + local.x,
370 origin.y + local.y,
371 local.width,
372 local.height,
373 )));
374 } else if let Some(rect) = node.bounds() {
375 node.set_bounds(accesskit::Rect {
376 x0: rect.x0 + delta.x as f64,
377 y0: rect.y0 + delta.y as f64,
378 x1: rect.x1 + delta.x as f64,
379 y1: rect.y1 + delta.y as f64,
380 });
381 }
382 }
383 self.adapter_ids.source_changed();
386 true
387 }
388
389 pub fn accessibility_tree_snapshot(&self) -> accesskit::TreeUpdate {
403 self.build_accessibility_tree(false).0
404 }
405
406 fn step_announcers(&mut self) {
409 let now = self.animation_clock();
413 let polite = self.announcer_polite.step(now, &mut self.announcer_ids);
414 let assertive = self.announcer_assertive.step(now, &mut self.announcer_ids);
415 if polite || assertive {
416 self.a11y_dirty = true;
417 }
418 self.wake_for_retirement();
419 }
420
421 fn settle_announcers(&mut self, admitted: bool) -> bool {
425 if admitted {
426 let now = self.animation_clock();
427 self.announcer_polite.admit(now);
428 self.announcer_assertive.admit(now);
429 self.wake_for_retirement();
430 }
431 self.announcer_polite.busy() || self.announcer_assertive.busy()
432 }
433
434 fn wake_for_retirement(&self) {
439 if !self.sim_time_frozen
440 && let Some(at) = [
441 self.announcer_polite.next_retirement(),
442 self.announcer_assertive.next_retirement(),
443 ]
444 .into_iter()
445 .flatten()
446 .min()
447 {
448 self.request_wake_at(at);
449 }
450 }
451
452 pub fn dispatch_access_action(
475 &mut self,
476 node_id: accesskit::NodeId,
477 action: accesskit::Action,
478 data: Option<accesskit::ActionData>,
479 ops: &mut dyn crate::window::WindowOps,
480 ) -> bool {
481 let target = crate::accessibility::node_id_to_widget_id_maybe(node_id)
482 .or_else(|| self.widget_for_synthetic(node_id));
483 let event = crate::event::WidgetEvent::AccessAction {
484 action,
485 target,
486 target_node: node_id,
487 data,
488 };
489 self.access_action_handled = false;
490 self.dispatch_event_with_ops(event, ops);
491 self.access_action_handled
492 }
493
494 pub fn widget_for_synthetic(&self, node_id: accesskit::NodeId) -> Option<WidgetId> {
503 self.synthetic_parent_map.get(&node_id).copied()
504 }
505
506 pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
514 self.arena
515 .get(content_id)
516 .and_then(|node| node.widget.as_any())
517 .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
518 .and_then(|d| d.materialized_child())
519 .unwrap_or(content_id)
520 }
521
522 pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
533 let content_id = self.tooltip_content_node(content_id);
534 let node = self.arena.get(content_id)?;
535 let mut probe = AccessNodeBuilder::for_name_probe(content_id);
536 node.widget.accessibility(&mut probe);
537 if let Some(ov) = node.access_overrides.as_deref() {
538 ov.apply(&mut probe);
539 }
540 probe
541 .name()
542 .map(str::trim)
543 .filter(|name| !name.is_empty())
544 .map(str::to_owned)
545 }
546
547 pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
562 let builder = self.build_overridden_builder(id);
563 let role = builder.role();
564 let name = builder.name().map(|s| s.to_string());
565 let actions = builder.actions().to_vec();
566 let mut info = AccessibilityInfo::new(role, name, actions);
567 if let Some(toggled) = builder.toggled() {
568 info = info.with_toggled(toggled);
569 }
570 if let Some(expanded) = builder.expanded() {
571 info = info.with_expanded(expanded);
572 }
573 if let Some(selected) = builder.selected() {
574 info = info.with_selected(selected);
575 }
576 let disabled = self.access_disabled_override(
581 id,
582 self.live_accessibility_proxy(id).is_some(),
583 &self.composites_standing_behind(id),
584 );
585 let force_clear_disabled = disabled == Some(false);
586 let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
587 let disabled_override = disabled == Some(true);
592 if disabled_arena || disabled_override {
593 info = info.with_disabled(true);
594 }
595 if builder.is_hidden() {
596 info = info.with_hidden(true);
597 }
598 info = info.with_published(self.is_in_published_at_tree(id));
599 if let Some(value) = builder.value() {
600 info = info.with_value(value);
601 }
602 if let Some(v) = builder.numeric_value() {
603 info = info.with_numeric_value(v);
604 }
605 info = info.with_numeric_range(builder.min_numeric_value(), builder.max_numeric_value());
606 info.with_custom_actions(
607 builder
608 .custom_actions()
609 .iter()
610 .map(|a| a.description.clone())
611 .collect(),
612 )
613 }
614
615 pub fn try_accessibility_node(&self, id: WidgetId) -> Option<AccessibilityInfo> {
619 self.arena.get(id)?;
620 Some(self.accessibility_node(id))
621 }
622
623 fn is_in_published_at_tree(&self, id: WidgetId) -> bool {
627 use crate::widget_builder::AccessSubtreeMode;
628 if !self.arena.is_active(id) {
629 return false;
630 }
631 let mut cursor = self.arena.parent(id);
632 while let Some(ancestor) = cursor {
633 match self.arena.get(ancestor) {
634 Some(node) if node.access_subtree != AccessSubtreeMode::Inherit => return false,
635 Some(_) => {}
636 None => break,
637 }
638 cursor = self.arena.parent(ancestor);
639 }
640 true
641 }
642
643 pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
644 self.arena
645 .active_ids_iter()
646 .find(|&id| self.build_overridden_builder(id).role() == role)
647 }
648
649 pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
650 self.arena
651 .active_ids_iter()
652 .find(|&id| self.build_overridden_builder(id).name() == Some(label))
653 }
654
655 pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
656 self.arena.active_ids_iter().find(|&id| {
657 self.build_overridden_builder(id)
658 .actions()
659 .contains(&action)
660 })
661 }
662
663 pub fn text_content(&self, id: WidgetId) -> Option<String> {
667 self.arena.get(id)?;
668 self.build_overridden_builder(id)
669 .name()
670 .map(|s| s.to_string())
671 }
672
673 pub fn text_value(&self, id: WidgetId) -> Option<String> {
677 self.arena.get(id)?;
678 self.build_overridden_builder(id)
679 .value()
680 .map(|s| s.to_string())
681 }
682}
683
684pub(crate) fn emit_root_transform(root: &mut accesskit::Node, device_scale_factor: f32) {
713 root.set_transform(accesskit::Affine::scale(device_scale_factor as f64));
714}
715
716#[cfg(test)]
717pub(crate) mod test_helpers {
718 pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
724 accesskit_consumer::Tree::new(update.clone(), false);
725 }
726
727 pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
736 let emitted: std::collections::HashSet<accesskit::NodeId> =
737 update.nodes.iter().map(|(id, _)| *id).collect();
738 for (parent_id, node) in &update.nodes {
739 for &target in node.controls() {
740 assert!(
741 emitted.contains(&target),
742 "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
743 );
744 }
745 for &target in node.described_by() {
746 assert!(
747 emitted.contains(&target),
748 "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
749 );
750 }
751 for &target in node.labelled_by() {
752 assert!(
753 emitted.contains(&target),
754 "node {parent_id:?} has labelled_by() → {target:?} which is absent from the tree"
755 );
756 }
757 if let Some(target) = node.next_on_line() {
758 assert!(
759 emitted.contains(&target),
760 "node {parent_id:?} has next_on_line() → {target:?} which is absent from the tree"
761 );
762 }
763 if let Some(target) = node.previous_on_line() {
764 assert!(
765 emitted.contains(&target),
766 "node {parent_id:?} has previous_on_line() → {target:?} which is absent from the tree"
767 );
768 }
769 }
770 }
771
772 #[allow(dead_code)]
774 pub(crate) fn nodes_with_role(
775 update: &accesskit::TreeUpdate,
776 role: accesskit::Role,
777 ) -> Vec<accesskit::NodeId> {
778 update
779 .nodes
780 .iter()
781 .filter(|(_, node)| node.role() == role)
782 .map(|(id, _)| *id)
783 .collect()
784 }
785}
786
787#[cfg(test)]
788mod tests {
789 use super::test_helpers::*;
790 use super::*;
791 use crate::test_widgets::{FillWidget, StackWidget};
792
793 const ANNOUNCER_NODES: usize = 0;
798
799 struct Arrivals {
805 replay: accesskit_consumer::Tree,
806 }
807
808 impl Arrivals {
809 fn attach(update: accesskit::TreeUpdate) -> Self {
810 Self {
811 replay: accesskit_consumer::Tree::new(update, false),
812 }
813 }
814
815 fn feed(&mut self, update: accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
816 struct Handler(Vec<(String, accesskit::Live)>);
817 fn included(node: &accesskit_consumer::NodeRef) -> bool {
818 accesskit_consumer::common_filter(node) == accesskit_consumer::FilterResult::Include
819 }
820 impl Handler {
821 fn entered(&mut self, node: &accesskit_consumer::NodeRef) {
822 if node.live() != accesskit::Live::Off
823 && let Some(label) = node.label()
824 {
825 self.0.push((label, node.live()));
826 }
827 }
828 }
829 impl accesskit_consumer::TreeChangeHandler for Handler {
830 fn node_added(&mut self, node: &accesskit_consumer::NodeRef) {
831 if included(node) {
832 self.entered(node);
833 }
834 }
835 fn node_updated(
836 &mut self,
837 old: &accesskit_consumer::NodeRef,
838 new: &accesskit_consumer::NodeRef,
839 ) {
840 if !included(old) && included(new) {
841 self.entered(new);
842 }
843 }
844 fn focus_moved(
845 &mut self,
846 _old: Option<&accesskit_consumer::NodeRef>,
847 _new: Option<&accesskit_consumer::NodeRef>,
848 ) {
849 }
850 fn node_removed(&mut self, _node: &accesskit_consumer::NodeRef) {}
851 }
852 let mut handler = Handler(Vec::new());
853 self.replay.update_and_process_changes(update, &mut handler);
854 handler.0
855 }
856 }
857
858 fn announced_by_consumer(update: &accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
875 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
876 let state = consumer.state();
877 let mut found = Vec::new();
878 let mut stack = vec![state.root()];
879 while let Some(node) = stack.pop() {
880 if node.live() != accesskit::Live::Off
881 && let Some(label) = node.label()
882 {
883 found.push((label, node.live()));
884 }
885 for child in node.filtered_children(&accesskit_consumer::common_filter) {
886 stack.push(child);
887 }
888 }
889 found
890 }
891
892 fn live_nodes_in_filtered_tree(update: &accesskit::TreeUpdate) -> Vec<String> {
901 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
902 let state = consumer.state();
903 let mut found = Vec::new();
904 let mut stack = vec![state.root()];
905 while let Some(node) = stack.pop() {
906 if node.live() != accesskit::Live::Off {
907 found.push(format!("{:?}", node.id()));
908 }
909 for child in node.filtered_children(&accesskit_consumer::common_filter) {
910 stack.push(child);
911 }
912 }
913 found.sort();
914 found
915 }
916
917 fn tree_with_one_widget() -> WidgetTree {
918 let mut tree = WidgetTree::new();
919 tree.add(FillWidget::new().label("content"));
920 tree.layout(SizeProposal::exact(200.0, 100.0));
921 tree
922 }
923
924 #[test]
928 fn an_announcement_enters_the_filtered_tree_then_leaves_it() {
929 let mut tree = tree_with_one_widget();
930 tree.advance_time(std::time::Duration::ZERO);
931 let update = tree.sync_accessibility();
934 assert_eq!(announced_by_consumer(&update), Vec::new());
935 assert_eq!(
936 live_nodes_in_filtered_tree(&update),
937 Vec::<String>::new(),
938 "an idle announcer must put nothing in the filtered tree"
939 );
940 assert_a11y_tree_valid(&update);
941 let mut arrivals = Arrivals::attach(update);
942
943 tree.announce("Event added");
944
945 let exposed = tree.sync_accessibility();
946 assert_eq!(
947 announced_by_consumer(&exposed),
948 vec![("Event added".to_string(), accesskit::Live::Polite)],
949 "the message must be a label on a live node inside the filtered tree"
950 );
951 assert_eq!(
952 live_nodes_in_filtered_tree(&exposed).len(),
953 1,
954 "exactly one announcer node must have entered the filtered tree"
955 );
956 assert_a11y_tree_valid(&exposed);
957 assert_eq!(
958 arrivals.feed(exposed),
959 vec![("Event added".to_string(), accesskit::Live::Polite)]
960 );
961
962 tree.advance_time(std::time::Duration::from_millis(16));
965 let lingering = tree.sync_accessibility();
966 assert_eq!(live_nodes_in_filtered_tree(&lingering).len(), 1);
967 assert_eq!(
968 arrivals.feed(lingering),
969 Vec::new(),
970 "a message is said once"
971 );
972
973 tree.advance_time(crate::announcer::LINGER);
975 let gone = tree.sync_accessibility();
976 assert_eq!(
977 live_nodes_in_filtered_tree(&gone),
978 Vec::<String>::new(),
979 "the live node must leave the filtered tree entirely"
980 );
981 assert_a11y_tree_valid(&gone);
982 }
983
984 #[test]
989 fn the_same_message_announced_twice_is_exposed_twice() {
990 let mut tree = tree_with_one_widget();
991 tree.advance_time(std::time::Duration::ZERO);
992 let mut arrivals = Arrivals::attach(tree.sync_accessibility());
993 tree.announce("Saved");
994 tree.announce("Saved");
995
996 let mut heard = Vec::new();
997 let mut ids = std::collections::HashSet::new();
998 for _ in 0..6 {
999 let update = tree.sync_accessibility();
1000 ids.extend(
1001 update
1002 .nodes
1003 .iter()
1004 .filter(|(id, _)| crate::announcer::is_announcer_node(*id))
1005 .map(|(id, _)| *id),
1006 );
1007 heard.extend(arrivals.feed(update).into_iter().map(|(text, _)| text));
1008 tree.advance_time(std::time::Duration::from_millis(16));
1009 }
1010 assert_eq!(
1011 heard,
1012 ["Saved", "Saved"],
1013 "each announcement is its own arrival"
1014 );
1015 assert_eq!(
1016 ids.len(),
1017 2,
1018 "each announcement is spoken from its own node"
1019 );
1020 }
1021
1022 #[test]
1023 fn an_assertive_announcement_uses_the_assertive_live_setting() {
1024 let mut tree = tree_with_one_widget();
1025 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
1026 let update = tree.sync_accessibility();
1027 assert_eq!(
1028 announced_by_consumer(&update),
1029 vec![("Could not save".to_string(), accesskit::Live::Assertive)]
1030 );
1031 }
1032
1033 #[test]
1036 fn the_two_politeness_levels_announce_independently() {
1037 let mut tree = tree_with_one_widget();
1038 tree.announce("Event added");
1039 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
1040
1041 let mut spoken = announced_by_consumer(&tree.sync_accessibility());
1042 spoken.sort_by(|a, b| a.0.cmp(&b.0));
1043 assert_eq!(
1044 spoken,
1045 vec![
1046 ("Could not save".to_string(), accesskit::Live::Assertive),
1047 ("Event added".to_string(), accesskit::Live::Polite),
1048 ],
1049 "both levels must be exposed on the same update"
1050 );
1051 }
1052
1053 #[test]
1057 fn announcing_requests_the_syncs_it_needs() {
1058 let mut tree = tree_with_one_widget();
1059 let _ = tree.sync_accessibility();
1060 tree.a11y_update_requested.set(false);
1061 tree.frame_tick_requested.set(false);
1062
1063 tree.announce("Event added");
1064 assert!(
1065 tree.a11y_update_requested.get(),
1066 "an announcement must dirty the accessibility tree"
1067 );
1068 assert!(
1069 tree.frame_requested(),
1070 "an announcement must ask for the frame that carries it"
1071 );
1072
1073 tree.announce("3 events");
1076 tree.a11y_update_requested.set(false);
1077 tree.frame_tick_requested.set(false);
1078 let _ = tree.sync_accessibility();
1079 assert!(tree.a11y_update_requested.get());
1080 assert!(tree.frame_requested());
1081
1082 tree.a11y_update_requested.set(false);
1085 tree.frame_tick_requested.set(false);
1086 let before = std::time::Instant::now();
1087 let _ = tree.sync_accessibility();
1088 assert!(!tree.a11y_update_requested.get());
1089 assert!(!tree.frame_requested());
1090 let wake = tree
1091 .next_timer_deadline()
1092 .expect("a message in the tree asks to be woken when it leaves");
1093 assert!(wake > before + crate::announcer::LINGER / 2);
1094 }
1095
1096 #[test]
1106 fn a_throttled_subscriber_does_not_hold_back_an_announcement() {
1107 const NORMAL_FRAME: std::time::Duration = std::time::Duration::from_micros(17_500);
1109 const CLOCK: std::time::Duration = std::time::Duration::from_secs(60);
1110
1111 fn frame(tree: &mut WidgetTree) -> accesskit::TreeUpdate {
1112 tree.layout(SizeProposal::exact(200.0, 100.0));
1113 let update = tree.sync_accessibility();
1114 let _ = tree.render();
1115 update
1116 }
1117 fn wait_for_next_frame(tree: &WidgetTree) -> std::time::Duration {
1118 let deadline = tree
1119 .frame_tick_deadline()
1120 .expect("a visible subscriber keeps a frame scheduled");
1121 let last = tree.last_frame_time.expect("a frame has run");
1122 deadline.saturating_duration_since(last)
1123 }
1124
1125 let mut tree = WidgetTree::new();
1126 let row = tree.add(FillWidget::new().label("content"));
1127 let _clock = tree.subscribe_frame_tick_throttled(row, CLOCK);
1128 let _ = frame(&mut tree);
1129 let _ = frame(&mut tree);
1130 assert!(
1131 wait_for_next_frame(&tree) >= CLOCK - NORMAL_FRAME,
1132 "precondition: with nothing else going on, the clock alone paces the loop"
1133 );
1134
1135 let messages = ["Annulé : l'ajout d'un évènement", "3 évènements"];
1136 for message in messages {
1137 tree.announce(message);
1138 }
1139 assert!(
1143 wait_for_next_frame(&tree) <= NORMAL_FRAME,
1144 "an announcement must be carried by the next normal frame, not the clock's \
1145 (waited {:?})",
1146 wait_for_next_frame(&tree)
1147 );
1148
1149 let mut exposed = Vec::new();
1150 let mut frames = 0;
1151 let mut arrivals = Arrivals::attach(tree.accessibility_tree_snapshot());
1152 let settled = loop {
1153 let update = frame(&mut tree);
1154 frames += 1;
1155 exposed.extend(arrivals.feed(update).into_iter().map(|(text, _)| text));
1156 let wait = wait_for_next_frame(&tree);
1157 if wait > NORMAL_FRAME {
1158 break wait;
1159 }
1160 assert!(frames < 16, "the announcer never went idle");
1161 };
1162 assert_eq!(
1163 exposed, messages,
1164 "every queued message must be exposed before the loop goes back to sleeping on the \
1165 clock; one left behind is heard only at the next key press"
1166 );
1167 assert!(
1168 settled >= CLOCK - NORMAL_FRAME,
1169 "once the queue is spoken the clock must pace alone again, not free-run at 60 Hz \
1170 (waited {settled:?})"
1171 );
1172 }
1173
1174 #[test]
1177 fn an_announcement_reaches_the_automation_ring() {
1178 let mut tree = tree_with_one_widget();
1179 let before = tree.announcements_since(0).len() as u64;
1180 tree.announce("Event added");
1181 let _ = tree.sync_accessibility();
1182 let got = tree.announcements_since(before);
1183 assert_eq!(got.len(), 1);
1184 assert_eq!(got[0].text, "Event added");
1185 assert!(!got[0].assertive);
1186 }
1187
1188 #[test]
1192 fn a_tree_snapshot_does_not_consume_a_pending_announcement() {
1193 let mut tree = tree_with_one_widget();
1194 tree.announce("Event added");
1195
1196 let snapshot = tree.accessibility_tree_snapshot();
1197 assert_eq!(
1198 announced_by_consumer(&snapshot),
1199 Vec::new(),
1200 "a snapshot must not advance the announcer"
1201 );
1202
1203 let delivered = tree.sync_accessibility();
1204 assert_eq!(
1205 announced_by_consumer(&delivered),
1206 vec![("Event added".to_string(), accesskit::Live::Polite)],
1207 "the message must still be there to deliver"
1208 );
1209 }
1210
1211 #[test]
1214 fn an_empty_announcement_is_not_exposed() {
1215 let mut tree = tree_with_one_widget();
1216 let _ = tree.sync_accessibility();
1217 tree.announce(" ");
1218 let update = tree.sync_accessibility();
1219 assert_eq!(announced_by_consumer(&update), Vec::new());
1220 }
1221
1222 #[derive(Debug)]
1223 struct ActionWidget;
1224
1225 impl Widget for ActionWidget {
1226 fn layout_response(
1227 &self,
1228 proposal: SizeProposal,
1229 _ctx: &LayoutContext,
1230 ) -> crate::widget::LayoutResponse {
1231 proposal.resolve(0.0, 0.0).into()
1232 }
1233
1234 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1235 builder.set_role(accesskit::Role::Button);
1236 builder.set_name("Save");
1237 builder.add_action(accesskit::Action::Click);
1238 builder.add_action(accesskit::Action::Focus);
1239 }
1240 }
1241
1242 #[derive(Debug)]
1243 struct ClickableWidget;
1244
1245 impl Widget for ClickableWidget {
1246 fn layout_response(
1247 &self,
1248 proposal: SizeProposal,
1249 _ctx: &LayoutContext,
1250 ) -> crate::widget::LayoutResponse {
1251 proposal.resolve(0.0, 0.0).into()
1252 }
1253
1254 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1255 builder.set_role(accesskit::Role::Button);
1256 builder.set_name("Click Me");
1257 builder.add_action(accesskit::Action::Click);
1258 }
1259 }
1260
1261 #[test]
1266 fn a_focusable_node_advertises_focus_without_the_widget_saying_so() {
1267 let mut tree = WidgetTree::new();
1268 let id = tree.add(ClickableWidget.focusable(true));
1270 tree.layout(SizeProposal::exact(100.0, 100.0));
1271
1272 let info = tree.accessibility_node(id);
1273 assert!(
1274 info.actions().contains(&accesskit::Action::Focus),
1275 "the arena knows the node is focusable; the AT node must say so"
1276 );
1277
1278 let mut ops = crate::window::NoopWindowOps;
1280 assert!(tree.dispatch_access_action(
1281 crate::accessibility::widget_id_to_node_id(id),
1282 accesskit::Action::Focus,
1283 None,
1284 &mut ops,
1285 ));
1286 assert_eq!(tree.focused(), Some(id));
1287 }
1288
1289 #[derive(Debug)]
1295 struct CompositeWidget {
1296 child_ids: Vec<WidgetId>,
1297 }
1298
1299 impl Widget for CompositeWidget {
1300 fn layout_response(
1301 &self,
1302 proposal: SizeProposal,
1303 _ctx: &LayoutContext,
1304 ) -> crate::widget::LayoutResponse {
1305 proposal.resolve(0.0, 0.0).into()
1306 }
1307
1308 fn place_children(
1309 &self,
1310 bounds: Rect,
1311 _proposal: SizeProposal,
1312 children: &mut [crate::widget::WidgetPlacement],
1313 _ctx: &LayoutContext,
1314 ) {
1315 for child in children.iter_mut() {
1316 child.origin = bounds.origin();
1317 child.size = bounds.size();
1318 }
1319 }
1320
1321 fn children(&self) -> Vec<WidgetId> {
1322 self.child_ids.clone()
1323 }
1324
1325 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1326 builder.set_role(accesskit::Role::SpinButton);
1327 builder.add_action(accesskit::Action::Focus);
1328 }
1329 }
1330
1331 #[test]
1342 fn an_at_focus_on_a_non_focusable_composite_lands_on_its_focusable_leaf() {
1343 let mut tree = WidgetTree::new();
1344 let leaf = tree.add(ClickableWidget.focusable(true));
1345 let root = tree.add(CompositeWidget {
1346 child_ids: vec![leaf],
1347 });
1348 tree.layout(SizeProposal::exact(100.0, 100.0));
1349
1350 let mut ops = crate::window::NoopWindowOps;
1351 assert!(tree.dispatch_access_action(
1352 crate::accessibility::widget_id_to_node_id(root),
1353 accesskit::Action::Focus,
1354 None,
1355 &mut ops,
1356 ));
1357 assert_eq!(
1358 tree.focused(),
1359 Some(leaf),
1360 "focus must reach the focusable leaf, not the composite root"
1361 );
1362 }
1363
1364 #[test]
1373 fn an_at_focus_on_a_plain_container_does_not_walk_into_it() {
1374 let mut tree = WidgetTree::new();
1375 let leaf = tree.add(ClickableWidget.focusable(true));
1376 let root = tree.add(StackWidget::new().child(leaf));
1377 tree.layout(SizeProposal::exact(100.0, 100.0));
1378
1379 let mut ops = crate::window::NoopWindowOps;
1380 assert!(
1381 !tree.dispatch_access_action(
1382 crate::accessibility::widget_id_to_node_id(root),
1383 accesskit::Action::Focus,
1384 None,
1385 &mut ops,
1386 ),
1387 "a node that never advertised `Focus` must report the action unhandled"
1388 );
1389 assert_eq!(
1390 tree.focused(),
1391 None,
1392 "focus must stay where it was, not fall into the container's first control"
1393 );
1394 }
1395
1396 #[test]
1399 fn an_at_focus_on_a_focusable_node_still_lands_on_that_node() {
1400 let mut tree = WidgetTree::new();
1401 let outer = tree.add(ClickableWidget.focusable(true));
1402 tree.layout(SizeProposal::exact(100.0, 100.0));
1403
1404 let mut ops = crate::window::NoopWindowOps;
1405 assert!(tree.dispatch_access_action(
1406 crate::accessibility::widget_id_to_node_id(outer),
1407 accesskit::Action::Focus,
1408 None,
1409 &mut ops,
1410 ));
1411 assert_eq!(tree.focused(), Some(outer));
1412 }
1413
1414 #[test]
1417 fn a_derived_focus_action_is_still_removable_by_an_override() {
1418 let mut tree = WidgetTree::new();
1419 let id = tree.add(
1420 ClickableWidget
1421 .focusable(true)
1422 .access_remove_action(accesskit::Action::Focus),
1423 );
1424 tree.layout(SizeProposal::exact(100.0, 100.0));
1425 assert!(
1426 !tree
1427 .accessibility_node(id)
1428 .actions()
1429 .contains(&accesskit::Action::Focus)
1430 );
1431 }
1432
1433 #[test]
1436 fn a_non_focusable_node_does_not_advertise_focus() {
1437 let mut tree = WidgetTree::new();
1438 let id = tree.add(ClickableWidget);
1439 tree.layout(SizeProposal::exact(100.0, 100.0));
1440 assert!(
1441 !tree
1442 .accessibility_node(id)
1443 .actions()
1444 .contains(&accesskit::Action::Focus)
1445 );
1446 }
1447
1448 #[test]
1449 fn labeled_widget_has_accessibility() {
1450 let mut tree = WidgetTree::new();
1451 let widget = tree.add(FillWidget::new().label("Hello"));
1452 tree.layout(SizeProposal::exact(100.0, 20.0));
1453 let info = tree.accessibility_node(widget);
1454 assert_eq!(info.role(), accesskit::Role::Label);
1455 assert_eq!(info.name(), Some("Hello"));
1456 }
1457
1458 #[test]
1459 fn find_by_label_works() {
1460 let mut tree = WidgetTree::new();
1461 let widget = tree.add(FillWidget::new().label("Save"));
1462 tree.layout(SizeProposal::exact(100.0, 20.0));
1463 assert_eq!(tree.find_by_label("Save"), Some(widget));
1464 }
1465
1466 #[test]
1467 fn label_role_emits_text_as_value_not_label() {
1468 let mut tree = WidgetTree::new();
1475 let widget = tree.add(FillWidget::new().label("Section")); tree.layout(SizeProposal::exact(100.0, 20.0));
1477
1478 assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1480
1481 let update = tree.sync_accessibility();
1483 let node = &update
1484 .nodes
1485 .iter()
1486 .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1487 .expect("label node must be present in the tree update")
1488 .1;
1489 assert_eq!(node.role(), accesskit::Role::Label);
1490 assert_eq!(
1491 node.value(),
1492 Some("Section"),
1493 "Role::Label text must be exposed via `value`"
1494 );
1495 assert_eq!(
1496 node.label(),
1497 None,
1498 "Role::Label must not leave text in the `label` property"
1499 );
1500 }
1501
1502 #[test]
1503 fn find_by_role_works() {
1504 let mut tree = WidgetTree::new();
1505 tree.add(FillWidget::new().label("Text"));
1506 tree.layout(SizeProposal::exact(100.0, 20.0));
1507 assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1508 }
1509
1510 #[test]
1511 fn access_hidden_bound_to_signal_toggles_reactively() {
1512 use crate::signal::Signal;
1513 use crate::widget_builder::WidgetBuilder;
1514 let hidden = Signal::new(false);
1515 let mut tree = WidgetTree::new();
1516 let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1517 tree.layout(SizeProposal::exact(100.0, 30.0));
1518 assert!(
1519 !tree.accessibility_node(w).is_hidden(),
1520 "node should be visible to AT while the signal is false"
1521 );
1522 hidden.set(true);
1523 assert!(
1525 tree.accessibility_node(w).is_hidden(),
1526 "node should hide from AT when the bound signal flips to true"
1527 );
1528 hidden.set(false);
1529 assert!(!tree.accessibility_node(w).is_hidden());
1530 }
1531
1532 #[test]
1536 fn a_widget_with_a_context_menu_advertises_the_action() {
1537 let mut tree = WidgetTree::new();
1538 let plain = tree.add(FillWidget::new().label("no menu"));
1539 let with_menu = tree.add(
1540 FillWidget::new()
1541 .label("has menu")
1542 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1543 );
1544 tree.layout(SizeProposal::exact(100.0, 20.0));
1545
1546 assert!(
1547 tree.accessibility_node(with_menu)
1548 .actions()
1549 .contains(&Action::ShowContextMenu),
1550 "a node owning a context-menu factory must announce it"
1551 );
1552 assert!(
1553 !tree
1554 .accessibility_node(plain)
1555 .actions()
1556 .contains(&Action::ShowContextMenu),
1557 "and a node without one must not"
1558 );
1559 }
1560
1561 #[test]
1564 fn a_removed_context_menu_action_stays_removed() {
1565 let mut tree = WidgetTree::new();
1566 let id = tree.add(
1567 FillWidget::new()
1568 .label("suppressed")
1569 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1570 .access_remove_action(Action::ShowContextMenu),
1571 );
1572 tree.layout(SizeProposal::exact(100.0, 20.0));
1573 assert!(
1574 !tree
1575 .accessibility_node(id)
1576 .actions()
1577 .contains(&Action::ShowContextMenu)
1578 );
1579 }
1580
1581 #[test]
1586 fn a_child_does_not_advertise_its_parents_context_menu() {
1587 let mut tree = WidgetTree::new();
1588 let child = tree.add(FillWidget::new().label("inner"));
1589 let parent = tree.add(
1590 StackWidget::new()
1591 .child(child)
1592 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1593 );
1594 tree.layout(SizeProposal::exact(100.0, 20.0));
1595
1596 assert!(
1597 tree.accessibility_node(parent)
1598 .actions()
1599 .contains(&Action::ShowContextMenu)
1600 );
1601 assert!(
1602 !tree
1603 .accessibility_node(child)
1604 .actions()
1605 .contains(&Action::ShowContextMenu),
1606 "the menu belongs to the widget that declared it"
1607 );
1608 }
1609
1610 #[test]
1611 fn accessibility_node_collects_actions() {
1612 let mut tree = WidgetTree::new();
1613 let widget = tree.add(ActionWidget);
1614 tree.layout(SizeProposal::exact(100.0, 40.0));
1615
1616 let info = tree.accessibility_node(widget);
1617 assert_eq!(info.role(), accesskit::Role::Button);
1618 assert_eq!(info.name(), Some("Save"));
1619 assert_eq!(info.actions().len(), 2);
1620 assert!(info.actions().contains(&accesskit::Action::Click));
1621 assert!(info.actions().contains(&accesskit::Action::Focus));
1622 }
1623
1624 #[test]
1625 fn sync_accessibility_produces_tree_update() {
1626 let mut tree = WidgetTree::new();
1627 tree.add(FillWidget::new().label("First"));
1628 tree.add(FillWidget::new().label("Second"));
1629 tree.layout(SizeProposal::exact(200.0, 100.0));
1630
1631 let update = tree.sync_accessibility();
1632 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1635 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1636 assert!(update.tree.is_some());
1637 assert_a11y_tree_valid(&update);
1638 }
1639
1640 #[test]
1641 fn root_node_carries_locale_as_language() {
1642 let mut tree = WidgetTree::new();
1643 tree.add(FillWidget::new().label("Bonjour"));
1644
1645 tree.layout(SizeProposal::exact(200.0, 100.0));
1647 let update = tree.sync_accessibility();
1648 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1649 assert_eq!(
1650 update.nodes[0].1.language(),
1651 None,
1652 "no language before a locale is set"
1653 );
1654
1655 tree.set_locale("fr-FR".to_string());
1658 let update = tree.sync_accessibility();
1659 assert_eq!(
1660 update.nodes[0].1.language(),
1661 Some("fr-FR"),
1662 "root node must advertise the active locale as its language"
1663 );
1664 }
1665
1666 #[test]
1667 fn sync_accessibility_excludes_dormant_widgets() {
1668 let mut tree = WidgetTree::new();
1669 tree.add(FillWidget::new().label("Active"));
1670 let dormant = tree.add(FillWidget::new().label("Dormant"));
1671 tree.layout(SizeProposal::exact(200.0, 100.0));
1672
1673 tree.set_dormant(dormant);
1674
1675 let update = tree.sync_accessibility();
1676 assert_eq!(update.nodes.len(), 2 + ANNOUNCER_NODES);
1677 assert_a11y_tree_valid(&update);
1678 }
1679
1680 #[test]
1681 fn try_accessibility_node_is_none_for_a_destroyed_id() {
1682 let mut tree = WidgetTree::new();
1683 let id = tree.add(FillWidget::new().label("Gone soon"));
1684 tree.layout(SizeProposal::exact(200.0, 100.0));
1685 assert!(tree.try_accessibility_node(id).is_some());
1686
1687 tree.destroy_subtree_for_testing(id);
1688 assert!(tree.try_accessibility_node(id).is_none());
1689 }
1690
1691 #[test]
1692 fn a_dormant_node_answers_but_is_not_published() {
1693 let mut tree = WidgetTree::new();
1694 let shown = tree.add(FillWidget::new().label("Shown"));
1695 let parked = tree.add(FillWidget::new().label("Parked"));
1696 tree.layout(SizeProposal::exact(200.0, 100.0));
1697 tree.set_dormant(parked);
1698
1699 let shown = tree.try_accessibility_node(shown).expect("in the arena");
1700 assert!(shown.published());
1701
1702 let parked = tree.try_accessibility_node(parked).expect("in the arena");
1703 assert_eq!(
1704 parked.name(),
1705 Some("Parked"),
1706 "a dormant node still answers"
1707 );
1708 assert!(!parked.is_hidden(), "dormancy is not the hidden flag");
1709 assert!(!parked.published());
1710 }
1711
1712 #[test]
1713 fn a_node_under_an_excluded_ancestor_is_not_published() {
1714 use crate::widget_builder::WidgetBuilder;
1715 let mut tree = WidgetTree::new();
1716 let inner = tree.add(FillWidget::new().label("Inner"));
1717 let outer = tree.add(StackWidget::new().child(inner).access_exclude_subtree());
1718 tree.layout(SizeProposal::exact(200.0, 100.0));
1719
1720 assert!(tree.accessibility_node(outer).published());
1721 assert!(!tree.accessibility_node(inner).published());
1722 }
1723
1724 #[test]
1731 fn relayout_without_activation_change_does_not_dirty_a11y() {
1732 let mut tree = WidgetTree::new();
1733 let id = tree.add(FillWidget::new().label("Static"));
1734 tree.layout(SizeProposal::exact(200.0, 100.0));
1735
1736 let _ = tree.sync_accessibility();
1738 assert!(
1739 !tree.a11y_dirty,
1740 "sync_accessibility must clear the dirty flag"
1741 );
1742
1743 tree.arena.mark_needs_layout(id);
1747 tree.layout(SizeProposal::exact(200.0, 100.0));
1748
1749 assert!(
1750 !tree.a11y_dirty,
1751 "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1752 );
1753 }
1754
1755 #[derive(Debug)]
1758 struct Mover {
1759 offset: crate::signal::Signal<f32>,
1760 child: WidgetId,
1761 }
1762
1763 impl crate::widget::Widget for Mover {
1764 fn children(&self) -> Vec<WidgetId> {
1765 vec![self.child]
1766 }
1767 fn layout_response(
1768 &self,
1769 proposal: SizeProposal,
1770 _ctx: &crate::widget::LayoutContext,
1771 ) -> crate::widget::LayoutResponse {
1772 proposal.resolve(200.0, 100.0).into()
1773 }
1774 fn place_children(
1775 &self,
1776 bounds: teksilo_canvas::Rect,
1777 _proposal: SizeProposal,
1778 children: &mut [crate::widget::WidgetPlacement],
1779 _ctx: &crate::widget::LayoutContext,
1780 ) {
1781 if let Some(placement) = children.first_mut() {
1782 placement.origin =
1783 teksilo_canvas::Point::new(bounds.x + self.offset.get(), bounds.y);
1784 placement.size = teksilo_canvas::Size::new(50.0, 20.0);
1785 }
1786 }
1787 }
1788
1789 #[test]
1796 fn translating_a_widget_patches_the_cache_without_a_walk() {
1797 let mut tree = WidgetTree::new();
1798 let inner = tree.add(FillWidget::new().label("Row"));
1799 let offset = crate::signal::Signal::new(0.0f32);
1800 let root = tree.add(Mover {
1801 offset: offset.clone(),
1802 child: inner,
1803 });
1804 tree.layout(SizeProposal::exact(200.0, 100.0));
1805 let update = tree.sync_accessibility();
1806 let walks = tree.a11y_walk_generation();
1807 let before = find_node(&update, inner)
1808 .unwrap()
1809 .bounds()
1810 .expect("the node carries bounds");
1811
1812 offset.set(20.0);
1814 tree.arena.mark_needs_layout(root);
1815 tree.layout(SizeProposal::exact(200.0, 100.0));
1816
1817 let update = tree.sync_accessibility();
1818 assert_eq!(
1819 tree.a11y_walk_generation(),
1820 walks,
1821 "a pure translation must not cost a walk"
1822 );
1823 let after = find_node(&update, inner)
1824 .unwrap()
1825 .bounds()
1826 .expect("the node still carries bounds");
1827 assert!(
1828 (after.x0 - before.x0 - 20.0).abs() < 0.01,
1829 "the cached node must follow the move: {before:?} -> {after:?}"
1830 );
1831 }
1832
1833 #[test]
1834 fn at_version_does_not_bump_for_a_move() {
1835 let mut tree = WidgetTree::new();
1839 let inner = tree.add(FillWidget::new().label("Row"));
1840 let offset = crate::signal::Signal::new(0.0f32);
1841 let root = tree.add(Mover {
1842 offset: offset.clone(),
1843 child: inner,
1844 });
1845 tree.layout(SizeProposal::exact(200.0, 100.0));
1846 let _ = tree.sync_accessibility();
1847 let version = tree.at_version().get();
1848
1849 offset.set(20.0);
1850 tree.arena.mark_needs_layout(root);
1851 tree.layout(SizeProposal::exact(200.0, 100.0));
1852 let _ = tree.sync_accessibility();
1853
1854 assert_eq!(tree.at_version().get(), version);
1855 }
1856
1857 #[test]
1862 fn a_delivery_is_owed_only_for_what_the_adapter_does_not_hold() {
1863 let mut tree = WidgetTree::new();
1864 let inner = tree.add(FillWidget::new().label("Row"));
1865 let offset = crate::signal::Signal::new(0.0f32);
1866 let root = tree.add(Mover {
1867 offset: offset.clone(),
1868 child: inner,
1869 });
1870 tree.layout(SizeProposal::exact(200.0, 100.0));
1871 let _ = tree.sync_accessibility();
1872 assert!(
1873 tree.accessibility_delivery_owed(true),
1874 "nothing delivered yet"
1875 );
1876 let _ = tree.deliver_accessibility(true);
1877 assert!(!tree.accessibility_delivery_owed(true));
1878 assert!(
1879 tree.accessibility_delivery_owed(false),
1880 "the window's focus changes what the adapter keeps"
1881 );
1882
1883 let _ = tree.sync_accessibility();
1884 assert!(
1885 !tree.accessibility_delivery_owed(true),
1886 "an idle frame owes nothing"
1887 );
1888
1889 offset.set(20.0);
1890 tree.arena.mark_needs_layout(root);
1891 tree.layout(SizeProposal::exact(200.0, 100.0));
1892 let _ = tree.sync_accessibility();
1893 assert!(tree.accessibility_delivery_owed(true), "a move is owed");
1894 let _ = tree.deliver_accessibility(true);
1895 assert!(!tree.accessibility_delivery_owed(true));
1896
1897 let walks = tree.a11y_walk_generation();
1899 tree.set_theme(crate::presets::intui::dark());
1900 let _ = tree.sync_accessibility();
1901 assert!(
1902 tree.a11y_walk_generation() > walks,
1903 "precondition: it walked"
1904 );
1905 assert!(!tree.accessibility_delivery_owed(true));
1906
1907 tree.add(FillWidget::new().label("New"));
1908 tree.layout(SizeProposal::exact(200.0, 100.0));
1909 let _ = tree.sync_accessibility();
1910 assert!(
1911 tree.accessibility_delivery_owed(true),
1912 "new content is owed"
1913 );
1914 let _ = tree.deliver_accessibility(true);
1915 assert!(!tree.accessibility_delivery_owed(true));
1916 }
1917
1918 #[test]
1923 fn an_idle_frame_hands_the_adapter_no_node() {
1924 struct Count(usize);
1925 impl accesskit_consumer::TreeChangeHandler for Count {
1926 fn node_added(&mut self, _: &accesskit_consumer::NodeRef) {
1927 self.0 += 1;
1928 }
1929 fn node_updated(
1930 &mut self,
1931 _: &accesskit_consumer::NodeRef,
1932 _: &accesskit_consumer::NodeRef,
1933 ) {
1934 self.0 += 1;
1935 }
1936 fn focus_moved(
1937 &mut self,
1938 _: Option<&accesskit_consumer::NodeRef>,
1939 _: Option<&accesskit_consumer::NodeRef>,
1940 ) {
1941 self.0 += 1;
1942 }
1943 fn node_removed(&mut self, _: &accesskit_consumer::NodeRef) {
1944 self.0 += 1;
1945 }
1946 }
1947
1948 let mut tree = WidgetTree::new();
1949 let inner = tree.add(FillWidget::new().label("Row").focusable());
1950 let offset = crate::signal::Signal::new(0.0f32);
1951 let root = tree.add(Mover {
1952 offset: offset.clone(),
1953 child: inner,
1954 });
1955 tree.focus(inner);
1956 tree.layout(SizeProposal::exact(200.0, 100.0));
1957 let _ = tree.sync_accessibility();
1958 let delivered = tree.deliver_accessibility(true);
1959 let mut adapter = accesskit_consumer::Tree::new(delivered.clone(), true);
1960
1961 tree.layout(SizeProposal::exact(200.0, 100.0));
1962 let _ = tree.sync_accessibility();
1963 let idle = tree.accessibility_unchanged(true);
1964 assert!(
1965 idle.nodes.is_empty(),
1966 "an idle frame handed the adapter {} nodes to compare",
1967 idle.nodes.len()
1968 );
1969 assert_eq!(idle.tree, None);
1970 assert_eq!(idle.tree_id, delivered.tree_id);
1971 assert_eq!(idle.focus, delivered.focus, "the focus it holds");
1972 let mut changes = Count(0);
1973 adapter.update_and_process_changes(idle, &mut changes);
1974 assert_eq!(changes.0, 0, "the adapter heard a change");
1975
1976 offset.set(20.0);
1978 tree.arena.mark_needs_layout(root);
1979 tree.layout(SizeProposal::exact(200.0, 100.0));
1980 let _ = tree.sync_accessibility();
1981 let moved = tree.accessibility_unchanged(true);
1982 assert!(!moved.nodes.is_empty(), "a move is delivered");
1983 assert!(!tree.accessibility_delivery_owed(true));
1984 }
1985
1986 #[test]
1987 fn resizing_a_widget_dirties_the_accessibility_tree() {
1988 let mut tree = WidgetTree::new();
1992 let id = tree.add(FillWidget::new().label("Static"));
1993 tree.layout(SizeProposal::exact(200.0, 100.0));
1994 let _ = tree.sync_accessibility();
1995 let walks = tree.a11y_walk_generation();
1996
1997 tree.layout(SizeProposal::exact(300.0, 100.0));
1998 let _ = tree.sync_accessibility();
1999
2000 assert!(
2001 tree.a11y_walk_generation() > walks,
2002 "a resize must be walked, not patched"
2003 );
2004 let _ = id;
2005 }
2006
2007 #[test]
2008 fn changing_the_theme_dirties_the_accessibility_tree() {
2009 let mut tree = WidgetTree::new();
2014 tree.add(FillWidget::new().label("Static"));
2015 tree.layout(SizeProposal::exact(200.0, 100.0));
2016 let _ = tree.sync_accessibility();
2017 let walks = tree.a11y_walk_generation();
2018
2019 tree.set_theme(crate::presets::intui::dark());
2020 let _ = tree.sync_accessibility();
2021
2022 assert!(tree.a11y_walk_generation() > walks);
2023 }
2024
2025 #[test]
2026 fn changing_the_text_scale_dirties_the_accessibility_tree() {
2027 let mut tree = WidgetTree::new();
2028 tree.add(FillWidget::new().label("Static"));
2029 tree.layout(SizeProposal::exact(200.0, 100.0));
2030 let _ = tree.sync_accessibility();
2031 let walks = tree.a11y_walk_generation();
2032
2033 tree.set_user_text_scale(1.5);
2034 let _ = tree.sync_accessibility();
2035
2036 assert!(tree.a11y_walk_generation() > walks);
2037 }
2038
2039 #[test]
2040 fn a_moved_node_absent_from_the_cache_is_skipped() {
2041 use crate::widget_builder::WidgetBuilder;
2046 let mut tree = WidgetTree::new();
2047 let hidden = tree.add(FillWidget::new().label("Inner"));
2048 let offset = crate::signal::Signal::new(0.0f32);
2049 let root = tree.add(
2050 Mover {
2051 offset: offset.clone(),
2052 child: hidden,
2053 }
2054 .access_exclude_subtree(),
2055 );
2056 tree.layout(SizeProposal::exact(200.0, 100.0));
2057 let update = tree.sync_accessibility();
2058 assert!(
2059 find_node(&update, hidden).is_none(),
2060 "the excluded descendant must be absent for this test to mean anything"
2061 );
2062
2063 offset.set(20.0);
2064 tree.arena.mark_needs_layout(root);
2065 tree.layout(SizeProposal::exact(200.0, 100.0));
2066 let update = tree.sync_accessibility();
2067
2068 assert!(find_node(&update, hidden).is_none());
2069 assert_no_dangling_relationships(&update);
2070 }
2071
2072 #[test]
2073 fn focus_on_a_node_the_walk_did_not_emit_resolves_to_an_emitted_ancestor() {
2074 use crate::widget_builder::WidgetBuilder;
2085 let mut tree = WidgetTree::new();
2086 let inner = tree.add(ClickableWidget);
2087 let outer = tree.add(
2088 Mover {
2089 offset: crate::signal::Signal::new(0.0f32),
2090 child: inner,
2091 }
2092 .access_exclude_subtree(),
2093 );
2094 tree.layout(SizeProposal::exact(200.0, 100.0));
2095 tree.focus(inner);
2096
2097 let update = tree.sync_accessibility();
2098 assert!(
2099 find_node(&update, inner).is_none(),
2100 "precondition: the excluded descendant emits nothing"
2101 );
2102 let emitted: std::collections::HashSet<_> =
2103 update.nodes.iter().map(|(id, _)| *id).collect();
2104 assert!(
2105 emitted.contains(&update.focus),
2106 "the published focus must be a node in the published tree"
2107 );
2108 assert_eq!(
2109 update.focus,
2110 crate::accessibility::widget_id_to_node_id(outer),
2111 "…and specifically the nearest ancestor that did emit"
2112 );
2113 accesskit_consumer::Tree::new(update, false);
2115 }
2116
2117 #[test]
2121 fn activation_transition_does_dirty_a11y() {
2122 let mut tree = WidgetTree::new();
2123 let id = tree.add(FillWidget::new().label("Toggle"));
2124 tree.layout(SizeProposal::exact(200.0, 100.0));
2125 let _ = tree.sync_accessibility();
2126 assert!(!tree.a11y_dirty);
2127
2128 tree.set_dormant(id);
2129 tree.layout(SizeProposal::exact(200.0, 100.0));
2130 assert!(
2131 tree.a11y_dirty,
2132 "active→dormant transition must dirty the AT cache so the dormant node is removed"
2133 );
2134 }
2135
2136 #[test]
2137 fn sync_accessibility_includes_focus() {
2138 let mut tree = WidgetTree::new();
2139 let widget = tree.add(FillWidget::new().focusable().label("Focused"));
2140 tree.layout(SizeProposal::exact(100.0, 50.0));
2141 tree.focus(widget);
2142
2143 let update = tree.sync_accessibility();
2144 let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
2145 assert_eq!(update.focus, expected_focus);
2146 assert_a11y_tree_valid(&update);
2147 }
2148
2149 #[test]
2150 fn sync_accessibility_parent_child_relationship() {
2151 let mut tree = WidgetTree::new();
2152 let child = tree.add(FillWidget::new().label("Child"));
2153 let parent = tree.add(
2156 StackWidget::new()
2157 .child(child)
2158 .access_label_literal("Parent"),
2159 );
2160 tree.layout(SizeProposal::exact(100.0, 50.0));
2161
2162 let update = tree.sync_accessibility();
2163 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
2164
2165 let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
2166 let parent_node = update
2167 .nodes
2168 .iter()
2169 .find(|(id, _)| *id == parent_node_id)
2170 .map(|(_, node)| node)
2171 .unwrap();
2172
2173 let child_node_id = crate::accessibility::widget_id_to_node_id(child);
2174 assert!(parent_node.children().contains(&child_node_id));
2175 assert_a11y_tree_valid(&update);
2176 }
2177
2178 #[test]
2179 fn presentational_containers_collapse_and_promote_children() {
2180 let mut tree = WidgetTree::new();
2186 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(
2190 StackWidget::new()
2191 .child(outer)
2192 .access_label_literal("Group"),
2193 );
2194 tree.layout(SizeProposal::exact(100.0, 50.0));
2195 let update = tree.sync_accessibility();
2196
2197 assert!(find_node(&update, inner).is_none(), "bare inner pruned");
2198 assert!(find_node(&update, outer).is_none(), "bare outer pruned");
2199
2200 let labeled_node = find_node(&update, labeled).expect("labeled group survives");
2201 let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
2202 assert!(
2203 labeled_node.children().contains(&leaf_nid),
2204 "leaf promoted past both bare containers to the labeled ancestor"
2205 );
2206 assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
2207 assert_a11y_tree_valid(&update);
2208 }
2209
2210 #[test]
2211 fn find_by_action_finds_clickable() {
2212 let mut tree = WidgetTree::new();
2213 let widget = tree.add(ClickableWidget);
2214 tree.layout(SizeProposal::exact(100.0, 40.0));
2215
2216 assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
2217 assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
2218 }
2219
2220 #[test]
2221 fn text_content_returns_accessibility_name() {
2222 let mut tree = WidgetTree::new();
2223 let widget = tree.add(FillWidget::new().label("Hello World"));
2224 tree.layout(SizeProposal::exact(100.0, 50.0));
2225
2226 assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
2227 }
2228
2229 #[test]
2230 fn text_content_returns_none_without_label() {
2231 let mut tree = WidgetTree::new();
2232 let widget = tree.add(FillWidget::new());
2233 tree.layout(SizeProposal::exact(100.0, 50.0));
2234
2235 assert_eq!(tree.text_content(widget), None);
2236 }
2237
2238 #[test]
2239 fn descendant_of_disabled_ancestor_reports_disabled() {
2240 use crate::signal::Signal;
2241
2242 let mut tree = WidgetTree::new();
2243 let child = tree.add(FillWidget::new().label("Child"));
2244 let parent = tree.add(StackWidget::new().child(child));
2245 tree.enabled_when(parent, Signal::new(false));
2246 tree.layout(SizeProposal::exact(100.0, 50.0));
2247
2248 assert!(
2249 tree.accessibility_node(child).is_disabled(),
2250 "descendant should report disabled when ancestor is disabled"
2251 );
2252 }
2253
2254 #[test]
2255 fn text_value_returns_accessibility_value() {
2256 #[derive(Debug)]
2257 struct ValueWidget;
2258
2259 impl Widget for ValueWidget {
2260 fn layout_response(
2261 &self,
2262 proposal: SizeProposal,
2263 _ctx: &LayoutContext,
2264 ) -> crate::widget::LayoutResponse {
2265 proposal.resolve(0.0, 0.0).into()
2266 }
2267
2268 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
2269 builder.set_role(accesskit::Role::Slider);
2270 builder.set_name("Volume");
2271 builder.set_value("75%");
2272 }
2273 }
2274
2275 let mut tree = WidgetTree::new();
2276 let widget = tree.add(ValueWidget);
2277 tree.layout(SizeProposal::exact(100.0, 40.0));
2278
2279 assert_eq!(tree.text_value(widget), Some("75%".to_string()));
2280 assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
2281 }
2282
2283 #[test]
2284 fn sync_accessibility_has_no_duplicate_children() {
2285 let mut tree = WidgetTree::new();
2289 let grandchild = tree.add(FillWidget::new().label("Grandchild"));
2290 let child_a = tree.add(StackWidget::new().child(grandchild));
2291 let child_b = tree.add(FillWidget::new().label("Sibling"));
2292 let _root = tree.add(StackWidget::new().child(child_a).child(child_b));
2293 tree.layout(SizeProposal::exact(200.0, 100.0));
2294
2295 let update = tree.sync_accessibility();
2296
2297 let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
2298 std::collections::HashMap::new();
2299 for (parent_id, node) in &update.nodes {
2300 for &child_id in node.children() {
2301 let prev = all_children.insert(child_id, *parent_id);
2302 assert!(
2303 prev.is_none(),
2304 "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
2305 );
2306 }
2307 }
2308 assert_a11y_tree_valid(&update);
2309 }
2310
2311 #[derive(Debug)]
2315 struct AtParent {
2316 kids: Vec<WidgetId>,
2317 at: std::rc::Rc<std::cell::RefCell<Option<Vec<WidgetId>>>>,
2318 claims: std::rc::Rc<std::cell::RefCell<Vec<WidgetId>>>,
2319 }
2320
2321 impl AtParent {
2322 fn new(kids: Vec<WidgetId>) -> Self {
2323 Self {
2324 kids,
2325 at: Default::default(),
2326 claims: Default::default(),
2327 }
2328 }
2329 }
2330
2331 impl crate::widget::Widget for AtParent {
2332 fn layout_response(
2333 &self,
2334 proposal: SizeProposal,
2335 _ctx: &crate::widget::LayoutContext,
2336 ) -> crate::widget::LayoutResponse {
2337 proposal.resolve(10.0, 10.0).into()
2338 }
2339 fn children(&self) -> Vec<WidgetId> {
2340 self.kids.clone()
2341 }
2342 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2343 self.at.borrow().clone()
2344 }
2345 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
2346 builder.set_role(accesskit::Role::Group);
2347 let claims = self.claims.borrow();
2348 if claims.is_empty() {
2349 return;
2350 }
2351 let group =
2352 builder.push_scene_child(1, crate::accessibility::SyntheticKind::SceneGroup, |b| {
2353 b.set_role(accesskit::Role::Group)
2354 });
2355 for &c in claims.iter() {
2356 builder
2357 .attach_scene_child_under(group, crate::accessibility::widget_id_to_node_id(c));
2358 }
2359 }
2360 fn wants_descendant_redirects(&self) -> bool {
2361 !self.claims.borrow().is_empty()
2362 }
2363 fn a11y_redirect_descendant(
2364 &self,
2365 _self_id: WidgetId,
2366 descendant: WidgetId,
2367 ) -> Option<accesskit::NodeId> {
2368 self.claims.borrow().contains(&descendant).then(|| {
2369 crate::accessibility::synthetic_node_id(
2370 _self_id,
2371 1,
2372 crate::accessibility::SyntheticKind::SceneGroup,
2373 )
2374 })
2375 }
2376 }
2377
2378 fn node_count(update: &accesskit::TreeUpdate, id: WidgetId) -> usize {
2379 let nid = crate::accessibility::widget_id_to_node_id(id);
2380 update.nodes.iter().filter(|(n, _)| *n == nid).count()
2381 }
2382
2383 fn parents_of(update: &accesskit::TreeUpdate, id: WidgetId) -> Vec<accesskit::NodeId> {
2384 let nid = crate::accessibility::widget_id_to_node_id(id);
2385 update
2386 .nodes
2387 .iter()
2388 .filter(|(_, n)| n.children().contains(&nid))
2389 .map(|(p, _)| *p)
2390 .collect()
2391 }
2392
2393 #[test]
2396 fn an_accessibility_child_may_be_a_deeper_descendant() {
2397 let mut tree = WidgetTree::new();
2398 let c = tree.add(FillWidget::new().label("C"));
2399 let a_widget = AtParent::new(vec![c]);
2400 *a_widget.at.borrow_mut() = Some(Vec::new());
2402 let a = tree.add(a_widget);
2403 let b = tree.add(FillWidget::new().label("B"));
2404 let r_widget = AtParent::new(vec![a, b]);
2405 let r_at = r_widget.at.clone();
2406 let r = tree.add(r_widget);
2407 *r_at.borrow_mut() = Some(vec![a, b, c]);
2408 tree.layout(SizeProposal::exact(200.0, 100.0));
2409 let update = tree.sync_accessibility();
2410 assert_eq!(node_count(&update, c), 1);
2411 assert_eq!(
2412 parents_of(&update, c),
2413 vec![crate::accessibility::widget_id_to_node_id(r)]
2414 );
2415 assert_a11y_tree_valid(&update);
2416 }
2417
2418 #[test]
2421 fn a_child_claimed_twice_is_built_once() {
2422 let mut tree = WidgetTree::new();
2424 let a = tree.add(FillWidget::new().label("A"));
2425 let r_widget = AtParent::new(vec![a]);
2426 *r_widget.at.borrow_mut() = Some(vec![a, a]);
2427 tree.add(r_widget);
2428 tree.layout(SizeProposal::exact(200.0, 100.0));
2429 let update = tree.sync_accessibility();
2430 assert_eq!(node_count(&update, a), 1);
2431 assert_a11y_tree_valid(&update);
2432
2433 let mut tree = WidgetTree::new();
2436 let c = tree.add(FillWidget::new().label("C"));
2437 let a = tree.add(AtParent::new(vec![c]));
2438 let r_widget = AtParent::new(vec![a]);
2439 let r_at = r_widget.at.clone();
2440 r_widget.claims.borrow_mut().push(c);
2441 tree.add(r_widget);
2442 *r_at.borrow_mut() = Some(vec![c, a]);
2443 tree.layout(SizeProposal::exact(200.0, 100.0));
2444 let update = tree.sync_accessibility();
2445 assert_eq!(node_count(&update, c), 1, "C is built once");
2446 assert_eq!(parents_of(&update, c).len(), 1, "and has one parent");
2447 assert_a11y_tree_valid(&update);
2448 }
2449
2450 #[test]
2451 fn no_dangling_relationships_in_basic_tree() {
2452 let mut tree = WidgetTree::new();
2453 let child = tree.add(FillWidget::new().label("Child"));
2454 let _parent = tree.add(StackWidget::new().child(child));
2455 tree.layout(SizeProposal::exact(100.0, 50.0));
2456
2457 let update = tree.sync_accessibility();
2458 assert_no_dangling_relationships(&update);
2459 assert_a11y_tree_valid(&update);
2460 }
2461
2462 use crate::widget_builder::WidgetBuilder;
2467 use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
2468
2469 fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
2471 let nid = crate::accessibility::widget_id_to_node_id(id);
2472 update
2473 .nodes
2474 .iter()
2475 .find(|(node_id, _)| *node_id == nid)
2476 .map(|(_, n)| n)
2477 }
2478
2479 #[derive(Debug)]
2482 struct AlwaysHiddenWidget;
2483 impl Widget for AlwaysHiddenWidget {
2484 fn layout_response(
2485 &self,
2486 proposal: SizeProposal,
2487 _ctx: &LayoutContext,
2488 ) -> crate::widget::LayoutResponse {
2489 proposal.resolve(0.0, 0.0).into()
2490 }
2491 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2492 builder.set_role(Role::GenericContainer);
2493 builder.set_hidden();
2494 }
2495 }
2496
2497 #[test]
2499 fn access_label_replaces_widget_label() {
2500 let mut tree = WidgetTree::new();
2501 let id = tree.add(ClickableWidget.access_label_literal("Publish"));
2502 tree.layout(SizeProposal::exact(100.0, 40.0));
2503 assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
2504 }
2505
2506 #[test]
2508 fn access_description_appears_on_bare_widget() {
2509 let mut tree = WidgetTree::new();
2510 let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
2511 tree.layout(SizeProposal::exact(50.0, 50.0));
2512 let update = tree.sync_accessibility();
2513 let node = find_node(&update, id).expect("node present");
2514 assert_eq!(node.description(), Some("Decorative"));
2515 }
2516
2517 #[test]
2519 fn access_value_replaces_widget_value() {
2520 #[derive(Debug)]
2521 struct SliderWidget;
2522 impl Widget for SliderWidget {
2523 fn layout_response(
2524 &self,
2525 proposal: SizeProposal,
2526 _ctx: &LayoutContext,
2527 ) -> crate::widget::LayoutResponse {
2528 proposal.resolve(0.0, 0.0).into()
2529 }
2530 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2531 builder.set_role(Role::Slider);
2532 builder.set_value("50");
2533 }
2534 }
2535 let mut tree = WidgetTree::new();
2536 let id = tree.add(SliderWidget.access_value_literal("Custom"));
2537 tree.layout(SizeProposal::exact(100.0, 40.0));
2538 assert_eq!(tree.text_value(id), Some("Custom".to_string()));
2539 }
2540
2541 #[test]
2543 fn access_role_overrides_widget_role() {
2544 let mut tree = WidgetTree::new();
2545 let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
2546 tree.layout(SizeProposal::exact(100.0, 40.0));
2547 assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
2548 }
2549
2550 #[test]
2551 fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
2552 let mut tree = WidgetTree::new();
2559 let anchor = tree.add(FillWidget::new().label("Save"));
2560 let tip = tree.add(FillWidget::new().label("Save the file"));
2561 tree.layout(SizeProposal::exact(200.0, 100.0));
2562 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
2563
2564 assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
2565 let update = tree.sync_accessibility();
2566 let node = find_node(&update, anchor).expect("anchor node present");
2567 assert_eq!(
2568 node.description(),
2569 Some("Save the file"),
2570 "the anchor must describe itself with its tooltip text while unshown"
2571 );
2572 }
2573
2574 #[test]
2575 fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
2576 let mut tree = WidgetTree::new();
2582 let anchor = tree.add(FillWidget::new().label("Save"));
2583 let tip = tree.add(FillWidget::new().label("Save the file"));
2584 tree.layout(SizeProposal::exact(200.0, 100.0));
2585 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2586
2587 tree.pointer_move(tree.bounds(anchor).center());
2588 tree.advance_time(std::time::Duration::from_millis(150));
2589 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
2590
2591 let update = tree.sync_accessibility();
2592 let node = find_node(&update, anchor).expect("anchor node present");
2593 assert!(
2594 !node.described_by().is_empty(),
2595 "a shown tooltip is wired as a described_by relation"
2596 );
2597 assert_eq!(
2598 node.description(),
2599 Some("Save the file"),
2600 "and the anchor still describes itself with the tooltip's text"
2601 );
2602 }
2603
2604 #[test]
2606 fn access_hint_alias_writes_description_field() {
2607 let mut tree = WidgetTree::new();
2608 let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
2609 tree.layout(SizeProposal::exact(50.0, 50.0));
2610 let update = tree.sync_accessibility();
2611 let node = find_node(&update, id).unwrap();
2612 assert_eq!(node.description(), Some("Tip"));
2613 }
2614
2615 #[test]
2617 fn access_identifier_writes_author_id() {
2618 let mut tree = WidgetTree::new();
2619 let id = tree.add(FillWidget::new().access_identifier("save-button"));
2620 tree.layout(SizeProposal::exact(50.0, 50.0));
2621 let update = tree.sync_accessibility();
2622 let node = find_node(&update, id).unwrap();
2623 assert_eq!(node.author_id(), Some("save-button"));
2624 }
2625
2626 #[test]
2628 fn access_shortcut_literal_set() {
2629 let mut tree = WidgetTree::new();
2630 let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
2631 tree.layout(SizeProposal::exact(50.0, 50.0));
2632 let update = tree.sync_accessibility();
2633 let node = find_node(&update, id).unwrap();
2634 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
2635 }
2636
2637 #[test]
2640 fn access_shortcut_id_resolves_and_tracks_rebinds() {
2641 use crate::event::Key;
2642 use crate::shortcut::{KeyStroke, Shortcut};
2643
2644 let mut tree = WidgetTree::new();
2645 tree.shortcut_registry_mut().register(
2646 Shortcut::new("app.save")
2647 .name("Save")
2648 .primary(KeyStroke::command(Key::S))
2649 .build(),
2650 );
2651 let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
2652 tree.layout(SizeProposal::exact(50.0, 50.0));
2653
2654 let accel = if cfg!(target_os = "macos") {
2658 "Cmd"
2659 } else {
2660 "Ctrl"
2661 };
2662
2663 let update = tree.sync_accessibility();
2665 let node = find_node(&update, id).unwrap();
2666 assert_eq!(
2667 node.keyboard_shortcut(),
2668 Some(format!("{accel}+S").as_str())
2669 );
2670
2671 tree.shortcut_registry_mut()
2673 .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
2674 let update = tree.sync_accessibility();
2675 let node = find_node(&update, id).unwrap();
2676 assert_eq!(
2677 node.keyboard_shortcut(),
2678 Some(format!("{accel}+Q").as_str())
2679 );
2680 }
2681
2682 #[test]
2685 fn access_shortcut_id_unknown_id_omits_announcement() {
2686 let mut tree = WidgetTree::new();
2687 let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
2688 tree.layout(SizeProposal::exact(50.0, 50.0));
2689 let update = tree.sync_accessibility();
2690 let node = find_node(&update, id).unwrap();
2691 assert_eq!(node.keyboard_shortcut(), None);
2692 }
2693
2694 #[test]
2696 fn access_hidden_true_hides_widget() {
2697 let mut tree = WidgetTree::new();
2698 let id = tree.add(ClickableWidget.access_hidden(true));
2699 tree.layout(SizeProposal::exact(50.0, 50.0));
2700 assert!(tree.accessibility_node(id).is_hidden());
2701 }
2702
2703 #[test]
2705 fn access_hidden_false_clears_widget_set_hidden() {
2706 let mut tree = WidgetTree::new();
2707 let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
2708 tree.layout(SizeProposal::exact(50.0, 50.0));
2709 assert!(
2710 !tree.accessibility_node(id).is_hidden(),
2711 "access_hidden(false) should clear widget-emitted hidden"
2712 );
2713 }
2714
2715 #[test]
2717 fn access_disabled_true_marks_disabled() {
2718 let mut tree = WidgetTree::new();
2719 let id = tree.add(FillWidget::new().access_disabled(true));
2720 tree.layout(SizeProposal::exact(50.0, 50.0));
2721 assert!(tree.accessibility_node(id).is_disabled());
2722 }
2723
2724 #[test]
2726 fn access_disabled_false_clears_arena_driven_disabled() {
2727 use crate::signal::Signal;
2728 let mut tree = WidgetTree::new();
2729 let id = tree.add(FillWidget::new().label("X").access_disabled(false));
2730 tree.enabled_when(id, Signal::new(false));
2731 tree.layout(SizeProposal::exact(50.0, 50.0));
2732 assert!(
2733 !tree.accessibility_node(id).is_disabled(),
2734 "access_disabled(false) should clear even arena-driven disabled"
2735 );
2736 }
2737
2738 #[test]
2740 fn access_controls_appends() {
2741 let mut tree = WidgetTree::new();
2742 let target = tree.add(FillWidget::new().label("Target"));
2743 let controller = tree.add(
2744 FillWidget::new()
2745 .label("Controller")
2746 .access_controls(target),
2747 );
2748 tree.layout(SizeProposal::exact(100.0, 50.0));
2749 let update = tree.sync_accessibility();
2750 let node = find_node(&update, controller).unwrap();
2751 let target_nid = crate::accessibility::widget_id_to_node_id(target);
2752 assert!(
2753 node.controls().contains(&target_nid),
2754 "controls list should contain the target NodeId"
2755 );
2756 }
2757
2758 #[test]
2760 fn access_described_by_appends() {
2761 let mut tree = WidgetTree::new();
2762 let other = tree.add(FillWidget::new().label("Desc"));
2763 let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
2764 tree.layout(SizeProposal::exact(100.0, 50.0));
2765 let update = tree.sync_accessibility();
2766 let node = find_node(&update, id).unwrap();
2767 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2768 assert!(node.described_by().contains(&other_nid));
2769 }
2770
2771 #[test]
2773 fn access_labelled_by_appends() {
2774 let mut tree = WidgetTree::new();
2775 let other = tree.add(FillWidget::new().label("Lbl"));
2776 let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
2777 tree.layout(SizeProposal::exact(100.0, 50.0));
2778 let update = tree.sync_accessibility();
2779 let node = find_node(&update, id).unwrap();
2780 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2781 assert!(node.labelled_by().contains(&other_nid));
2782 }
2783
2784 #[test]
2788 fn a_labelled_by_relation_replaces_a_default_name_only() {
2789 use crate::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
2790
2791 #[derive(Debug)]
2792 struct DefaultNamed;
2793 impl crate::widget::Widget for DefaultNamed {
2794 fn layout_response(
2795 &self,
2796 proposal: SizeProposal,
2797 _ctx: &crate::widget::LayoutContext,
2798 ) -> crate::widget::LayoutResponse {
2799 proposal.resolve(40.0, 20.0).into()
2800 }
2801 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2802 builder.set_role(accesskit::Role::TextInput);
2803 builder.set_default_name("Date");
2804 }
2805 }
2806 #[derive(Debug)]
2807 struct Named;
2808 impl crate::widget::Widget for Named {
2809 fn layout_response(
2810 &self,
2811 proposal: SizeProposal,
2812 _ctx: &crate::widget::LayoutContext,
2813 ) -> crate::widget::LayoutResponse {
2814 proposal.resolve(40.0, 20.0).into()
2815 }
2816 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2817 builder.set_role(accesskit::Role::TextInput);
2818 builder.set_name("Main");
2819 }
2820 }
2821
2822 let mut tree = WidgetTree::new();
2823 let row = tree.add(FillWidget::new().label("Birthday"));
2824 let nameless = tree.add(FillWidget::new());
2825 let linked = tree.add(DefaultNamed.access_labelled_by(row));
2826 let given = tree.add(
2827 DefaultNamed
2828 .access_labelled_by(row)
2829 .access_label_literal("Given"),
2830 );
2831 let unlinked = tree.add(DefaultNamed);
2832 let linked_to_nothing = tree.add(DefaultNamed.access_labelled_by(nameless));
2833 let named = tree.add(Named.access_labelled_by(row));
2834 tree.layout(SizeProposal::exact(400.0, 300.0));
2835 let update = tree.sync_accessibility();
2836
2837 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
2838 let state = consumer.state();
2839 let name = |id| {
2840 state
2841 .node_by_tree_local_id(widget_id_to_node_id(id), accesskit::TreeId::ROOT)
2842 .and_then(|n| n.label())
2843 };
2844 assert_eq!(name(linked).as_deref(), Some("Birthday"));
2845 assert_eq!(name(given).as_deref(), Some("Given"));
2846 assert_eq!(name(unlinked).as_deref(), Some("Date"));
2847 assert_eq!(name(linked_to_nothing).as_deref(), Some("Date"));
2848 assert_eq!(name(named).as_deref(), Some("Main"));
2850 }
2851
2852 #[test]
2864 fn a_child_attached_to_a_node_that_never_reached_the_tree_is_stripped() {
2865 use crate::accessibility::{AccessNodeBuilder, SyntheticKind, widget_id_to_node_id};
2866
2867 #[derive(Debug)]
2870 struct Grafter {
2871 child: std::cell::Cell<Option<WidgetId>>,
2872 }
2873
2874 impl Widget for Grafter {
2875 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2876 let child = ctx.add(FillWidget::new().label("Card"));
2877 self.child.set(Some(child));
2878 vec![child]
2879 }
2880 fn layout_response(
2881 &self,
2882 _p: SizeProposal,
2883 _c: &crate::widget::LayoutContext,
2884 ) -> crate::widget::LayoutResponse {
2885 teksilo_canvas::Size::new(100.0, 100.0).into()
2886 }
2887 fn children(&self) -> Vec<WidgetId> {
2888 self.child.get().into_iter().collect()
2889 }
2890 fn wants_descendant_redirects(&self) -> bool {
2891 true
2892 }
2893 fn a11y_redirect_descendant(
2894 &self,
2895 _self_id: WidgetId,
2896 descendant: WidgetId,
2897 ) -> Option<accesskit::NodeId> {
2898 (self.child.get() == Some(descendant)).then(|| widget_id_to_node_id(descendant))
2901 }
2902 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2903 builder.set_role(accesskit::Role::Pane);
2904 let group =
2905 builder.push_scene_child_under(None, 7, SyntheticKind::SceneGroup, |c| {
2906 c.set_role(accesskit::Role::GenericContainer);
2907 c.set_name("Group".to_string());
2908 });
2909 if let Some(child) = self.child.get() {
2910 builder.attach_scene_child_under(group, widget_id_to_node_id(child));
2911 }
2912 }
2913 }
2914
2915 let mut tree = WidgetTree::new();
2916 let grafter = tree.add(Grafter {
2917 child: std::cell::Cell::new(None),
2918 });
2919 tree.layout(SizeProposal::exact(200.0, 200.0));
2920 let child = tree.children(grafter)[0];
2921
2922 let update = tree.sync_accessibility();
2924 let child_nid = widget_id_to_node_id(child);
2925 assert!(
2926 update.nodes.iter().any(|(id, _)| *id == child_nid),
2927 "precondition: the grafted child is in the tree"
2928 );
2929 assert!(
2930 update
2931 .nodes
2932 .iter()
2933 .any(|(_, n)| n.children().contains(&child_nid)),
2934 "precondition: something names it as a child"
2935 );
2936 assert_a11y_tree_valid(&update);
2937
2938 tree.set_dormant(child);
2942 let update = tree.sync_accessibility();
2943 assert!(
2944 !update.nodes.iter().any(|(id, _)| *id == child_nid),
2945 "a dormant widget emits no node"
2946 );
2947 let emitted: std::collections::HashSet<accesskit::NodeId> =
2948 update.nodes.iter().map(|(id, _)| *id).collect();
2949 for (parent, node) in &update.nodes {
2950 for c in node.children() {
2951 assert!(
2952 emitted.contains(c),
2953 "node {parent:?} names child {c:?}, absent from the tree"
2954 );
2955 }
2956 }
2957 assert_a11y_tree_valid(&update);
2960 }
2961
2962 #[test]
2963 fn a_labelled_by_target_that_went_dormant_is_stripped() {
2964 let mut tree = WidgetTree::new();
2970 let title = tree.add(FillWidget::new().label("Preferences"));
2971 let dialog = tree.add(FillWidget::new().access_labelled_by(title));
2972 tree.layout(SizeProposal::exact(200.0, 100.0));
2973
2974 let update = tree.sync_accessibility();
2975 assert!(
2976 !find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2977 "the relation is live while the target is in the tree"
2978 );
2979
2980 tree.set_dormant(title);
2981 let update = tree.sync_accessibility();
2982 assert!(
2983 find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2984 "a target absent from the tree must not survive in the relation"
2985 );
2986 assert_no_dangling_relationships(&update);
2987 }
2988
2989 #[test]
2990 fn re_registering_a_labelled_by_relation_does_not_duplicate_it() {
2991 let mut tree = WidgetTree::new();
2995 let title = tree.add(FillWidget::new().label("Name"));
2996 let field = tree.add(FillWidget::new());
2997 tree.layout(SizeProposal::exact(200.0, 100.0));
2998
2999 for _ in 0..3 {
3000 tree.push_access_labelled_by(field, title);
3001 }
3002 let update = tree.sync_accessibility();
3003 assert_eq!(
3004 find_node(&update, field).unwrap().labelled_by().len(),
3005 1,
3006 "the same target registered repeatedly is one relation"
3007 );
3008 }
3009
3010 #[test]
3011 fn re_registering_a_described_by_relation_does_not_duplicate_it() {
3012 let mut tree = WidgetTree::new();
3013 let hint = tree.add(FillWidget::new().label("Must be unique"));
3014 let field = tree.add(FillWidget::new());
3015 tree.layout(SizeProposal::exact(200.0, 100.0));
3016
3017 for _ in 0..3 {
3018 tree.push_access_described_by(field, hint);
3019 }
3020 let update = tree.sync_accessibility();
3021 assert_eq!(
3022 find_node(&update, field).unwrap().described_by().len(),
3023 1,
3024 "the same target registered repeatedly is one relation"
3025 );
3026 }
3027
3028 #[test]
3030 fn access_live_assertive_set() {
3031 let mut tree = WidgetTree::new();
3032 let id = tree.add(FillWidget::new().access_live(Live::Assertive));
3033 tree.layout(SizeProposal::exact(50.0, 50.0));
3034 let update = tree.sync_accessibility();
3035 let node = find_node(&update, id).unwrap();
3036 assert_eq!(node.live(), Some(Live::Assertive));
3037 }
3038
3039 #[test]
3041 fn access_aria_current_set() {
3042 let mut tree = WidgetTree::new();
3043 let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
3044 tree.layout(SizeProposal::exact(50.0, 50.0));
3045 let update = tree.sync_accessibility();
3046 let node = find_node(&update, id).unwrap();
3047 assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
3048 }
3049
3050 #[test]
3052 fn access_has_popup_set() {
3053 let mut tree = WidgetTree::new();
3054 let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
3055 tree.layout(SizeProposal::exact(50.0, 50.0));
3056 let update = tree.sync_accessibility();
3057 let node = find_node(&update, id).unwrap();
3058 assert_eq!(node.has_popup(), Some(HasPopup::Menu));
3059 }
3060
3061 #[test]
3063 fn access_orientation_set() {
3064 let mut tree = WidgetTree::new();
3065 let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
3066 tree.layout(SizeProposal::exact(50.0, 50.0));
3067 let update = tree.sync_accessibility();
3068 let node = find_node(&update, id).unwrap();
3069 assert_eq!(node.orientation(), Some(Orientation::Vertical));
3070 }
3071
3072 #[test]
3074 fn access_numeric_value_and_range() {
3075 let mut tree = WidgetTree::new();
3076 let id = tree.add(
3077 FillWidget::new()
3078 .access_role(Role::Slider)
3079 .access_numeric_value(50.0)
3080 .access_numeric_range(0.0, 100.0)
3081 .access_numeric_step(5.0),
3082 );
3083 tree.layout(SizeProposal::exact(50.0, 50.0));
3084 let update = tree.sync_accessibility();
3085 let node = find_node(&update, id).unwrap();
3086 assert_eq!(node.numeric_value(), Some(50.0));
3087 assert_eq!(node.min_numeric_value(), Some(0.0));
3088 assert_eq!(node.max_numeric_value(), Some(100.0));
3089 assert_eq!(node.numeric_value_step(), Some(5.0));
3090 }
3091
3092 #[test]
3094 fn access_action_advertises_and_routes() {
3095 use crate::signal::Signal;
3096 let flag = Signal::new(false);
3097 let flag_for_cb = flag.clone();
3098 let mut tree = WidgetTree::new();
3099 let id = tree.add(
3100 FillWidget::new()
3101 .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
3102 );
3103 tree.layout(SizeProposal::exact(50.0, 50.0));
3104 let info = tree.accessibility_node(id);
3105 assert!(info.actions().contains(&Action::ShowContextMenu));
3106 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
3107 action: Action::ShowContextMenu,
3108 target: Some(id),
3109 target_node: crate::accessibility::widget_id_to_node_id(id),
3110 data: None,
3111 });
3112 assert!(flag.get(), "callback should have been invoked");
3113 }
3114
3115 #[test]
3117 fn access_two_actions_both_route() {
3118 use crate::signal::Signal;
3119 let click = Signal::new(false);
3120 let increment = Signal::new(false);
3121 let click_cb = click.clone();
3122 let inc_cb = increment.clone();
3123 let mut tree = WidgetTree::new();
3124 let id = tree.add(
3125 FillWidget::new()
3126 .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
3127 .access_action(Action::Increment, move |_| inc_cb.set(true)),
3128 );
3129 tree.layout(SizeProposal::exact(50.0, 50.0));
3130 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
3131 action: Action::ShowContextMenu,
3132 target: Some(id),
3133 target_node: crate::accessibility::widget_id_to_node_id(id),
3134 data: None,
3135 });
3136 assert!(click.get() && !increment.get(), "only first action fired");
3137 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
3138 action: Action::Increment,
3139 target: Some(id),
3140 target_node: crate::accessibility::widget_id_to_node_id(id),
3141 data: None,
3142 });
3143 assert!(
3144 click.get() && increment.get(),
3145 "both actions fired exactly once each"
3146 );
3147 }
3148
3149 #[test]
3151 fn access_remove_action_suppresses_widget_action() {
3152 let mut tree = WidgetTree::new();
3153 let id = tree.add(ActionWidget.access_remove_action(Action::Click));
3154 tree.layout(SizeProposal::exact(50.0, 50.0));
3155 let info = tree.accessibility_node(id);
3156 assert!(!info.actions().contains(&Action::Click));
3157 assert!(info.actions().contains(&Action::Focus));
3158 }
3159
3160 #[test]
3162 fn access_custom_action_uses_localized_label() {
3163 let mut tree = WidgetTree::new();
3164 let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
3165 tree.layout(SizeProposal::exact(50.0, 50.0));
3166 let update = tree.sync_accessibility();
3167 let node = find_node(&update, id).unwrap();
3168 let actions = node.custom_actions();
3169 assert_eq!(actions.len(), 1);
3170 assert_eq!(actions[0].id, 0);
3171 assert_eq!(actions[0].description.as_str(), "Reply");
3172 }
3173
3174 #[test]
3176 fn access_custom_action_routes_by_index() {
3177 use crate::signal::Signal;
3178 let first = Signal::new(false);
3179 let second = Signal::new(false);
3180 let f = first.clone();
3181 let s = second.clone();
3182 let mut tree = WidgetTree::new();
3183 let id = tree.add(
3184 FillWidget::new()
3185 .access_custom_action_literal("First", move |_| f.set(true))
3186 .access_custom_action_literal("Second", move |_| s.set(true)),
3187 );
3188 tree.layout(SizeProposal::exact(50.0, 50.0));
3189 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
3190 action: Action::CustomAction,
3191 target: Some(id),
3192 target_node: crate::accessibility::widget_id_to_node_id(id),
3193 data: Some(accesskit::ActionData::CustomAction(1)),
3194 });
3195 assert!(!first.get(), "first should not fire");
3196 assert!(second.get(), "second should fire (index 1)");
3197 }
3198
3199 #[derive(Debug)]
3202 struct OwnCustomActionWidget;
3203
3204 const OWN_CUSTOM_ACTION_ID: i32 = 1000;
3205
3206 impl Widget for OwnCustomActionWidget {
3207 fn layout_response(
3208 &self,
3209 proposal: SizeProposal,
3210 _ctx: &LayoutContext,
3211 ) -> crate::widget::LayoutResponse {
3212 proposal.resolve(0.0, 0.0).into()
3213 }
3214
3215 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3216 builder.set_role(accesskit::Role::Slider);
3217 builder.set_custom_actions(vec![accesskit::CustomAction {
3218 id: OWN_CUSTOM_ACTION_ID,
3219 description: "Reset to default".to_owned(),
3220 }]);
3221 }
3222 }
3223
3224 #[test]
3226 fn access_custom_action_appends_to_the_widgets_own() {
3227 use crate::signal::Signal;
3228 let own = Signal::new(0_u32);
3229 let added = Signal::new(0_u32);
3230 let own_sink = own.clone();
3231 let added_sink = added.clone();
3232 let mut tree = WidgetTree::new();
3233 let id = tree.add(
3234 OwnCustomActionWidget
3235 .access_custom_action_literal("Pin", move |_| added_sink.set(added_sink.get() + 1))
3236 .on_access_action_request(move |action, _node, data, _ctx| {
3237 if action == Action::CustomAction
3238 && data == Some(accesskit::ActionData::CustomAction(OWN_CUSTOM_ACTION_ID))
3239 {
3240 own_sink.set(own_sink.get() + 1);
3241 return crate::event::EventResponse::Handled;
3242 }
3243 crate::event::EventResponse::Ignored
3244 }),
3245 );
3246 tree.layout(SizeProposal::exact(50.0, 50.0));
3247 let update = tree.sync_accessibility();
3248 let node = find_node(&update, id).unwrap();
3249 let offered: Vec<(i32, &str)> = node
3250 .custom_actions()
3251 .iter()
3252 .map(|a| (a.id, a.description.as_str()))
3253 .collect();
3254 assert_eq!(
3255 offered,
3256 vec![(OWN_CUSTOM_ACTION_ID, "Reset to default"), (0, "Pin")],
3257 "the application's action joins the widget's rather than replacing it",
3258 );
3259 assert!(node.supports_action(Action::CustomAction));
3260
3261 let invoke = |tree: &mut WidgetTree, custom: i32| {
3262 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
3263 action: Action::CustomAction,
3264 target: Some(id),
3265 target_node: crate::accessibility::widget_id_to_node_id(id),
3266 data: Some(accesskit::ActionData::CustomAction(custom)),
3267 });
3268 };
3269 invoke(&mut tree, 0);
3270 assert_eq!(
3271 (own.get(), added.get()),
3272 (0, 1),
3273 "index 0 is the override's"
3274 );
3275 invoke(&mut tree, OWN_CUSTOM_ACTION_ID);
3276 assert_eq!(
3277 (own.get(), added.get()),
3278 (1, 1),
3279 "and the widget's id is still the widget's"
3280 );
3281 }
3282
3283 #[test]
3285 fn access_action_layered_with_on_access_action() {
3286 use crate::signal::Signal;
3287 let from_override = Signal::new(false);
3288 let from_user = Signal::new(false);
3289 let ov_cb = from_override.clone();
3290 let user_cb = from_user.clone();
3291 let mut tree = WidgetTree::new();
3292 let id = tree.add(
3293 FillWidget::new()
3294 .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
3295 .on_access_action(move |_action, _ctx| {
3296 user_cb.set(true);
3297 crate::event::EventResponse::Handled
3298 }),
3299 );
3300 tree.layout(SizeProposal::exact(50.0, 50.0));
3301 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
3302 action: Action::ShowContextMenu,
3303 target: Some(id),
3304 target_node: crate::accessibility::widget_id_to_node_id(id),
3305 data: None,
3306 });
3307 assert!(from_override.get(), "override callback fired");
3308 assert!(from_user.get(), "user catch-all fired");
3309 }
3310
3311 #[test]
3317 fn access_label_accepts_resolved_string_via_into() {
3318 struct ResolvedAtCall(String);
3322 impl From<ResolvedAtCall> for crate::signal::Prop<String> {
3323 fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
3324 crate::signal::Prop::Static(v.0)
3325 }
3326 }
3327 let mut tree = WidgetTree::new();
3328 let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
3329 tree.layout(SizeProposal::exact(50.0, 50.0));
3330 assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
3331 }
3332
3333 #[test]
3335 fn access_exclude_subtree_prunes_children_from_at_tree() {
3336 let mut tree = WidgetTree::new();
3337 let inner1 = tree.add(FillWidget::new().label("A"));
3338 let inner2 = tree.add(FillWidget::new().label("B"));
3339 let outer = tree.add(
3340 StackWidget::new()
3341 .child(inner1)
3342 .child(inner2)
3343 .access_label_literal("Section")
3347 .access_exclude_subtree(),
3348 );
3349 tree.layout(SizeProposal::exact(100.0, 50.0));
3350 let update = tree.sync_accessibility();
3351 assert!(find_node(&update, outer).is_some());
3353 assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
3354 assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
3355 let outer_node = find_node(&update, outer).unwrap();
3356 assert!(
3357 outer_node.children().is_empty(),
3358 "outer should have no AT children when excluded"
3359 );
3360 }
3361
3362 #[test]
3364 fn access_merge_subtree_concatenates_descendant_labels() {
3365 let mut tree = WidgetTree::new();
3366 let title = tree.add(FillWidget::new().label("Title"));
3367 let subtitle = tree.add(FillWidget::new().label("Subtitle"));
3368 let card = tree.add(
3369 StackWidget::new()
3370 .child(title)
3371 .child(subtitle)
3372 .access_merge_subtree(),
3373 );
3374 tree.layout(SizeProposal::exact(100.0, 50.0));
3375 assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
3377 let update = tree.sync_accessibility();
3378 assert!(find_node(&update, title).is_none());
3379 assert!(find_node(&update, subtitle).is_none());
3380 }
3381
3382 #[test]
3383 fn access_merge_subtree_takes_nothing_from_under_a_hidden_descendant() {
3384 let mut tree = WidgetTree::new();
3389 let title = tree.add(FillWidget::new().label("Title"));
3390 let secret = tree.add(FillWidget::new().label("Secret"));
3391 let hidden = tree.add(StackWidget::new().child(secret).access_hidden(true));
3392 let card = tree.add(
3393 StackWidget::new()
3394 .child(title)
3395 .child(hidden)
3396 .access_merge_subtree(),
3397 );
3398 tree.layout(SizeProposal::exact(100.0, 50.0));
3399 assert_eq!(tree.text_content(card), Some("Title".to_string()));
3400 }
3401
3402 #[test]
3404 fn access_merge_subtree_unions_actions() {
3405 let mut tree = WidgetTree::new();
3406 let click_a = tree.add(ClickableWidget);
3407 let click_b = tree.add(ClickableWidget);
3408 let card = tree.add(
3409 StackWidget::new()
3410 .child(click_a)
3411 .child(click_b)
3412 .access_merge_subtree(),
3413 );
3414 tree.layout(SizeProposal::exact(100.0, 50.0));
3415 let actions = tree.accessibility_node(card).actions().to_vec();
3416 let click_count = actions.iter().filter(|a| **a == Action::Click).count();
3417 assert_eq!(
3418 click_count, 1,
3419 "Click should be present exactly once after merge (deduplicated)"
3420 );
3421 }
3422
3423 #[test]
3433 fn access_merge_subtree_first_nonempty_value_wins() {
3434 #[derive(Debug)]
3435 struct ValueWidget(&'static str);
3436 impl Widget for ValueWidget {
3437 fn layout_response(
3438 &self,
3439 proposal: SizeProposal,
3440 _ctx: &LayoutContext,
3441 ) -> crate::widget::LayoutResponse {
3442 proposal.resolve(0.0, 0.0).into()
3443 }
3444 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3445 builder.set_role(Role::Slider);
3446 builder.set_value(self.0);
3447 }
3448 }
3449 let mut tree = WidgetTree::new();
3450 let v1 = tree.add(ValueWidget("first"));
3451 let v2 = tree.add(ValueWidget("second"));
3452 let card = tree.add(
3453 StackWidget::new()
3454 .child(v1)
3455 .child(v2)
3456 .access_merge_subtree(),
3457 );
3458 tree.layout(SizeProposal::exact(100.0, 50.0));
3459 assert_eq!(tree.text_value(card), Some("first".to_string()));
3460 }
3461
3462 #[test]
3464 fn access_exclude_inside_merge() {
3465 let mut tree = WidgetTree::new();
3466 let visible = tree.add(FillWidget::new().label("VISIBLE"));
3467 let pruned = tree.add(FillWidget::new().label("PRUNED"));
3468 let inner_excluded = tree.add(StackWidget::new().child(pruned).access_exclude_subtree());
3469 let card = tree.add(
3470 StackWidget::new()
3471 .child(visible)
3472 .child(inner_excluded)
3473 .access_merge_subtree(),
3474 );
3475 tree.layout(SizeProposal::exact(100.0, 50.0));
3476 let merged = tree.text_content(card).unwrap_or_default();
3477 assert!(merged.contains("VISIBLE"));
3480 assert!(
3481 !merged.contains("PRUNED"),
3482 "excluded subtree should not contribute to merge"
3483 );
3484 }
3485
3486 #[test]
3488 fn access_merge_inside_merge() {
3489 let mut tree = WidgetTree::new();
3490 let inner_a = tree.add(FillWidget::new().label("a"));
3491 let inner_b = tree.add(FillWidget::new().label("b"));
3492 let inner_card = tree.add(
3493 StackWidget::new()
3494 .child(inner_a)
3495 .child(inner_b)
3496 .access_merge_subtree(),
3497 );
3498 let outer_extra = tree.add(FillWidget::new().label("X"));
3499 let outer = tree.add(
3500 StackWidget::new()
3501 .child(inner_card)
3502 .child(outer_extra)
3503 .access_merge_subtree(),
3504 );
3505 tree.layout(SizeProposal::exact(200.0, 100.0));
3506 let merged = tree.text_content(outer).unwrap_or_default();
3511 assert!(merged.contains("a"));
3512 assert!(merged.contains("b"));
3513 assert!(merged.contains("X"));
3514 let update = tree.sync_accessibility();
3516 assert!(find_node(&update, inner_a).is_none());
3517 assert!(find_node(&update, inner_b).is_none());
3518 assert!(find_node(&update, inner_card).is_none());
3519 assert!(find_node(&update, outer_extra).is_none());
3520 }
3521
3522 #[test]
3524 fn access_customize_runs_last() {
3525 let mut tree = WidgetTree::new();
3526 let id = tree.add(
3527 FillWidget::new()
3528 .access_label_literal("A")
3529 .access_customize(|b| b.set_name("B")),
3530 );
3531 tree.layout(SizeProposal::exact(50.0, 50.0));
3532 assert_eq!(tree.accessibility_node(id).name(), Some("B"));
3533 }
3534
3535 #[test]
3537 fn access_customize_can_reach_inner_mut() {
3538 let mut tree = WidgetTree::new();
3539 let id = tree.add(FillWidget::new().access_customize(|b| {
3540 b.inner_mut().set_author_id("from-customize");
3541 }));
3542 tree.layout(SizeProposal::exact(50.0, 50.0));
3543 let update = tree.sync_accessibility();
3544 let node = find_node(&update, id).unwrap();
3545 assert_eq!(node.author_id(), Some("from-customize"));
3546 }
3547
3548 #[test]
3550 fn access_overrides_zero_cost_when_unused() {
3551 use std::mem::size_of;
3558 assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
3559 assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
3560 }
3561
3562 #[test]
3563 fn accessibility_children_overrides_at_reading_order() {
3564 #[derive(Debug)]
3569 struct NamedLeaf(&'static str);
3570 impl Widget for NamedLeaf {
3571 fn layout_response(
3572 &self,
3573 proposal: SizeProposal,
3574 _ctx: &LayoutContext,
3575 ) -> crate::widget::LayoutResponse {
3576 proposal.resolve(10.0, 10.0).into()
3577 }
3578 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3579 builder.set_role(accesskit::Role::Button);
3583 builder.set_name(self.0);
3584 }
3585 }
3586
3587 #[derive(Debug, Default)]
3588 struct ReorderContainer {
3589 a: Option<WidgetId>,
3590 b: Option<WidgetId>,
3591 }
3592 impl Widget for ReorderContainer {
3593 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
3594 let a = ctx.add(NamedLeaf("A"));
3595 let b = ctx.add(NamedLeaf("B"));
3596 self.a = Some(a);
3597 self.b = Some(b);
3598 vec![a, b]
3599 }
3600 fn layout_response(
3601 &self,
3602 proposal: SizeProposal,
3603 _ctx: &LayoutContext,
3604 ) -> crate::widget::LayoutResponse {
3605 proposal.resolve(100.0, 100.0).into()
3606 }
3607 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3608 builder.set_role(accesskit::Role::Group);
3611 builder.set_name("Container");
3612 }
3613 fn children(&self) -> Vec<WidgetId> {
3614 [self.a, self.b].into_iter().flatten().collect()
3615 }
3616 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
3617 Some([self.b, self.a].into_iter().flatten().collect())
3619 }
3620 }
3621
3622 let mut tree = WidgetTree::new();
3623 let container = tree.add(ReorderContainer::default());
3624 tree.layout(SizeProposal::exact(100.0, 100.0));
3625 let update = tree.sync_accessibility();
3626
3627 let a_nid = update
3628 .nodes
3629 .iter()
3630 .find(|(_, n)| n.label() == Some("A"))
3631 .map(|(id, _)| *id)
3632 .expect("A node present");
3633 let b_nid = update
3634 .nodes
3635 .iter()
3636 .find(|(_, n)| n.label() == Some("B"))
3637 .map(|(id, _)| *id)
3638 .expect("B node present");
3639 let cnid = crate::accessibility::widget_id_to_node_id(container);
3640 let cnode = update
3641 .nodes
3642 .iter()
3643 .find(|(id, _)| *id == cnid)
3644 .map(|(_, n)| n)
3645 .expect("container node present");
3646 let kids = cnode.children();
3647 let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
3648 let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
3649 assert!(
3650 pb < pa,
3651 "accessibility_children() must set AT order B-before-A; got {kids:?}"
3652 );
3653 }
3654
3655 fn consumer_bounding_box(update: &accesskit::TreeUpdate, label: &str) -> accesskit::Rect {
3669 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
3670 let state = consumer.state();
3671 let mut stack = vec![state.root()];
3672 while let Some(node) = stack.pop() {
3673 let text = node.value().or_else(|| node.label());
3676 if text.as_deref() == Some(label) {
3677 return node.bounding_box().expect("node has bounds");
3678 }
3679 for child in node.children() {
3680 stack.push(child);
3681 }
3682 }
3683 panic!("no node labelled {label:?} in the consumer tree");
3684 }
3685
3686 fn tree_at_scale(scale: f32) -> (WidgetTree, WidgetId) {
3687 let mut tree = WidgetTree::new();
3688 let id = tree.add(FillWidget::new().label("content"));
3689 tree.set_device_scale_factor(scale);
3690 tree.layout(SizeProposal::exact(200.0, 100.0));
3691 (tree, id)
3692 }
3693
3694 #[test]
3695 fn the_root_carries_the_device_scale_as_its_transform() {
3696 let (mut tree, _) = tree_at_scale(2.0);
3697 let update = tree.sync_accessibility();
3698 let root = update
3699 .nodes
3700 .iter()
3701 .find(|(id, _)| *id == crate::accessibility::root_node_id())
3702 .map(|(_, n)| n)
3703 .expect("root node present");
3704 assert_eq!(
3705 root.transform().copied(),
3706 Some(accesskit::Affine::scale(2.0)),
3707 "AccessKit wants physical coordinates; the tree emits logical ones"
3708 );
3709 assert_a11y_tree_valid(&update);
3710 }
3711
3712 #[test]
3713 fn emitted_bounds_stay_logical_at_scale_two() {
3714 let (mut tree, id) = tree_at_scale(2.0);
3723 let update = tree.sync_accessibility();
3724 let layout = tree.bounds(id);
3725 let nid = crate::accessibility::widget_id_to_node_id(id);
3726 let raw = update
3727 .nodes
3728 .iter()
3729 .find(|(n, _)| *n == nid)
3730 .and_then(|(_, n)| n.bounds())
3731 .expect("widget node has bounds");
3732 assert_eq!(
3733 (raw.x0, raw.y0, raw.x1, raw.y1),
3734 (
3735 layout.x as f64,
3736 layout.y as f64,
3737 (layout.x + layout.width) as f64,
3738 (layout.y + layout.height) as f64,
3739 ),
3740 "an emitted rectangle must be the logical layout rectangle, unscaled"
3741 );
3742 }
3743
3744 #[test]
3763 fn an_interactive_node_emits_its_exact_layout_rectangle() {
3764 let mut tree = WidgetTree::new();
3765 let id = tree.add(ActionWidget);
3766 tree.layout(SizeProposal::exact(200.0, 100.0));
3767 let update = tree.sync_accessibility();
3768 let nid = crate::accessibility::widget_id_to_node_id(id);
3769 let node = update
3770 .nodes
3771 .iter()
3772 .find(|(n, _)| *n == nid)
3773 .map(|(_, n)| n)
3774 .expect("the interactive node is in the update");
3775 assert!(
3776 node.supports_action(accesskit::Action::Click),
3777 "the fixture has to be the clickable shape the rule is about, \
3778 or this test guards nothing"
3779 );
3780 let layout = tree.bounds(id);
3781 assert!(
3782 layout.width > 0.0 && layout.height > 0.0,
3783 "a real, non-degenerate rectangle to compare against: {layout:?}"
3784 );
3785 let raw = node.bounds().expect("the interactive node has bounds");
3786 assert_eq!(
3787 (raw.x0, raw.y0, raw.x1, raw.y1),
3788 (
3789 layout.x as f64,
3790 layout.y as f64,
3791 (layout.x + layout.width) as f64,
3792 (layout.y + layout.height) as f64,
3793 ),
3794 "a clickable node's emitted rectangle is its layout rectangle, \
3795 not an outset one"
3796 );
3797 }
3798
3799 #[test]
3800 fn what_an_assistive_technology_reads_is_the_physical_rectangle() {
3801 let (mut one, _) = tree_at_scale(1.0);
3805 let at_one = consumer_bounding_box(&one.sync_accessibility(), "content");
3806 let (mut two, _) = tree_at_scale(2.0);
3807 let at_two = consumer_bounding_box(&two.sync_accessibility(), "content");
3808 assert_eq!(at_two.x0, at_one.x0 * 2.0);
3809 assert_eq!(at_two.y0, at_one.y0 * 2.0);
3810 assert_eq!(at_two.x1, at_one.x1 * 2.0);
3811 assert_eq!(at_two.y1, at_one.y1 * 2.0);
3812 assert!(at_two.x1 > at_two.x0, "a real, non-degenerate rectangle");
3813 }
3814
3815 #[test]
3816 fn a_scale_change_invalidates_the_accessibility_cache() {
3817 let (mut tree, _) = tree_at_scale(1.0);
3823 let _ = tree.sync_accessibility();
3824 tree.set_device_scale_factor(2.0);
3825 let update = tree.sync_accessibility();
3826 let root = update
3827 .nodes
3828 .iter()
3829 .find(|(id, _)| *id == crate::accessibility::root_node_id())
3830 .map(|(_, n)| n)
3831 .expect("root node present");
3832 assert_eq!(
3833 root.transform().copied(),
3834 Some(accesskit::Affine::scale(2.0))
3835 );
3836 }
3837
3838 #[test]
3839 fn setting_the_same_scale_does_not_dirty_the_tree() {
3840 let (mut tree, _) = tree_at_scale(2.0);
3844 let first = tree.sync_accessibility();
3845 tree.set_device_scale_factor(2.0);
3846 let second = tree.sync_accessibility();
3847 assert_eq!(first, second);
3848 }
3849
3850 #[test]
3853 fn explore_by_touch_is_off_by_default() {
3854 let tree = WidgetTree::new();
3855 assert_eq!(tree.explore_by_touch(), crate::ExploreByTouch::Off);
3856 assert_eq!(
3857 tree.screen_reader_state(),
3858 crate::ScreenReaderState::Unknown
3859 );
3860 assert!(!tree.explore_by_touch_active());
3861 }
3862
3863 #[test]
3864 fn auto_follows_the_operating_systems_screen_reader_flag() {
3865 let mut tree = WidgetTree::new();
3866 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3867 assert!(!tree.explore_by_touch_active(), "Unknown is not a yes");
3868 tree.set_screen_reader_state(crate::ScreenReaderState::Inactive);
3869 assert!(!tree.explore_by_touch_active());
3870 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3871 assert!(tree.explore_by_touch_active());
3872 }
3873
3874 #[test]
3875 fn on_ignores_the_screen_reader_flag_and_off_ignores_everything() {
3876 let mut tree = WidgetTree::new();
3877 tree.set_explore_by_touch(crate::ExploreByTouch::On);
3878 assert!(tree.explore_by_touch_active(), "On is the app's own choice");
3879 tree.set_explore_by_touch(crate::ExploreByTouch::Off);
3880 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3881 assert!(!tree.explore_by_touch_active());
3882 }
3883
3884 #[test]
3885 fn an_attached_accesskit_client_does_not_switch_exploring_on() {
3886 let mut tree = WidgetTree::new();
3892 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3893 tree.set_at_client_attached(true);
3894 assert!(tree.at_client_attached());
3895 assert_eq!(
3896 tree.screen_reader_state(),
3897 crate::ScreenReaderState::Unknown,
3898 "attaching says nothing about screen readers"
3899 );
3900 assert!(!tree.explore_by_touch_active());
3901 }
3902
3903 #[test]
3904 fn touch_still_reaches_a_handler_with_an_accesskit_client_attached() {
3905 use crate::Modifiers;
3911 use crate::pointer::{
3912 BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase,
3913 PointerSample,
3914 };
3915 use crate::widget_builder::WidgetBuilder;
3916 use std::cell::Cell;
3917 use std::rc::Rc;
3918
3919 let taps = Rc::new(Cell::new(0u32));
3920 let seen = Rc::clone(&taps);
3921 let mut tree = WidgetTree::new();
3922 let id = tree.add(
3923 FillWidget::new()
3924 .label("target")
3925 .on_tap(move |_e, _ctx| seen.set(seen.get() + 1)),
3926 );
3927 tree.layout(SizeProposal::exact(200.0, 100.0));
3928 tree.set_at_client_attached(true);
3929 assert!(!tree.explore_by_touch_active());
3930
3931 let at = Point::new(50.0, 50.0);
3932 let pid = PointerIdAllocator::global().begin(BackendDeviceKey::new(0x0A11), 1);
3933 let contact = |phase| PointerSample {
3934 pointer: PointerInfo::touch(pid, EventTime::ZERO),
3935 phase,
3936 position: at,
3937 button: None,
3938 modifiers: Modifiers::NONE,
3939 coalesced: Vec::new(),
3940 };
3941 tree.dispatch_pointer(contact(PointerPhase::Down));
3942 tree.dispatch_pointer(contact(PointerPhase::Up));
3943 assert!(tree.bounds(id).width > 0.0);
3944 assert_eq!(taps.get(), 1, "a touch tap must still activate its handler");
3945 }
3946
3947 #[test]
3948 fn a_detaching_client_is_evidence_that_no_screen_reader_is_reading() {
3949 let mut tree = WidgetTree::new();
3953 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3954 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3955 tree.set_at_client_attached(true);
3956 assert!(tree.explore_by_touch_active());
3957 tree.set_at_client_attached(false);
3958 assert_eq!(
3959 tree.screen_reader_state(),
3960 crate::ScreenReaderState::Inactive
3961 );
3962 assert!(!tree.explore_by_touch_active());
3963 }
3964
3965 #[test]
3966 fn a_repeated_detach_notice_does_not_clobber_a_fresh_os_reading() {
3967 let mut tree = WidgetTree::new();
3971 tree.set_at_client_attached(false);
3972 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3973 tree.set_at_client_attached(false);
3974 assert_eq!(tree.screen_reader_state(), crate::ScreenReaderState::Active);
3975 }
3976
3977 #[derive(Debug)]
3982 struct ScreenReaderProbe {
3983 calls: std::rc::Rc<std::cell::Cell<u32>>,
3984 seen: std::rc::Rc<std::cell::RefCell<Vec<crate::ScreenReaderState>>>,
3985 handle: std::rc::Rc<
3986 std::cell::RefCell<Option<crate::signal::Signal<crate::ScreenReaderState>>>,
3987 >,
3988 at_build: std::rc::Rc<std::cell::Cell<Option<crate::ScreenReaderState>>>,
3989 }
3990
3991 impl Widget for ScreenReaderProbe {
3992 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
3993 let signal = ctx.screen_reader_signal();
3994 self.at_build.set(Some(ctx.screen_reader_state()));
3995 let calls = self.calls.clone();
3996 let seen = self.seen.clone();
3997 ctx.effect(&signal, move |state| {
3998 calls.set(calls.get() + 1);
3999 seen.borrow_mut().push(*state);
4000 });
4001 *self.handle.borrow_mut() = Some(signal);
4002 vec![]
4003 }
4004
4005 fn layout_response(
4006 &self,
4007 proposal: SizeProposal,
4008 _ctx: &LayoutContext,
4009 ) -> crate::widget::LayoutResponse {
4010 proposal.resolve(0.0, 0.0).into()
4011 }
4012 }
4013
4014 struct ProbeTree {
4015 tree: WidgetTree,
4016 calls: std::rc::Rc<std::cell::Cell<u32>>,
4017 seen: std::rc::Rc<std::cell::RefCell<Vec<crate::ScreenReaderState>>>,
4018 handle: std::rc::Rc<
4019 std::cell::RefCell<Option<crate::signal::Signal<crate::ScreenReaderState>>>,
4020 >,
4021 at_build: std::rc::Rc<std::cell::Cell<Option<crate::ScreenReaderState>>>,
4022 }
4023
4024 fn screen_reader_probe_tree() -> ProbeTree {
4025 screen_reader_probe_tree_in(crate::ScreenReaderState::Unknown)
4026 }
4027
4028 fn screen_reader_probe_tree_in(state: crate::ScreenReaderState) -> ProbeTree {
4030 let calls = std::rc::Rc::new(std::cell::Cell::new(0));
4031 let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
4032 let handle = std::rc::Rc::new(std::cell::RefCell::new(None));
4033 let at_build = std::rc::Rc::new(std::cell::Cell::new(None));
4034 let mut tree = WidgetTree::new();
4035 tree.set_screen_reader_state(state);
4036 tree.add(ScreenReaderProbe {
4037 calls: calls.clone(),
4038 seen: seen.clone(),
4039 handle: handle.clone(),
4040 at_build: at_build.clone(),
4041 });
4042 tree.layout(SizeProposal::exact(100.0, 100.0));
4043 ProbeTree {
4044 tree,
4045 calls,
4046 seen,
4047 handle,
4048 at_build,
4049 }
4050 }
4051
4052 #[test]
4053 fn the_screen_reader_signal_starts_unknown_and_is_one_signal_for_tree_and_context() {
4054 let p = screen_reader_probe_tree();
4055 assert_eq!(
4056 p.tree.screen_reader_signal().get(),
4057 crate::ScreenReaderState::Unknown
4058 );
4059 assert_eq!(p.at_build.get(), Some(crate::ScreenReaderState::Unknown));
4060 let from_context = p.handle.borrow().clone().expect("built");
4061 assert!(
4062 crate::signal::Signal::same(&p.tree.screen_reader_signal(), &from_context),
4063 "the build context hands out the tree's own signal"
4064 );
4065 }
4066
4067 #[test]
4068 fn the_screen_reader_signal_notifies_on_a_real_change_only() {
4069 let mut p = screen_reader_probe_tree();
4070 p.tree
4071 .set_screen_reader_state(crate::ScreenReaderState::Active);
4072 assert_eq!(p.calls.get(), 1);
4073 p.tree
4074 .set_screen_reader_state(crate::ScreenReaderState::Active);
4075 assert_eq!(p.calls.get(), 1, "the same value again notifies nobody");
4076 assert_eq!(*p.seen.borrow(), vec![crate::ScreenReaderState::Active]);
4077 }
4078
4079 #[test]
4080 fn a_client_detaching_notifies_once_and_a_repeat_notifies_nobody() {
4081 let mut p = screen_reader_probe_tree();
4082 p.tree
4083 .set_screen_reader_state(crate::ScreenReaderState::Active);
4084 p.tree.set_at_client_attached(true);
4085 let before = p.calls.get();
4086 p.tree.set_at_client_attached(false);
4087 assert_eq!(p.calls.get(), before + 1);
4088 assert_eq!(
4089 p.tree.screen_reader_signal().get(),
4090 crate::ScreenReaderState::Inactive
4091 );
4092 p.tree.set_at_client_attached(false);
4093 assert_eq!(p.calls.get(), before + 1, "a repeated detach adds nothing");
4094 p.tree
4095 .set_screen_reader_state(crate::ScreenReaderState::Active);
4096 assert_eq!(p.calls.get(), before + 2);
4097 assert_eq!(
4098 p.tree.screen_reader_state(),
4099 crate::ScreenReaderState::Active,
4100 "a later OS reading sticks"
4101 );
4102 }
4103
4104 #[test]
4105 fn a_detach_that_finds_the_state_already_inactive_notifies_nobody() {
4106 let mut p = screen_reader_probe_tree();
4107 p.tree
4108 .set_screen_reader_state(crate::ScreenReaderState::Inactive);
4109 p.tree.set_at_client_attached(true);
4110 let before = p.calls.get();
4111 p.tree.set_at_client_attached(false);
4112 assert_eq!(p.calls.get(), before);
4113 }
4114
4115 #[test]
4116 fn a_state_the_os_reported_before_the_build_is_read_at_build() {
4117 let p = screen_reader_probe_tree_in(crate::ScreenReaderState::Active);
4118 assert_eq!(p.at_build.get(), Some(crate::ScreenReaderState::Active));
4119 assert_eq!(
4120 p.tree.screen_reader_signal().get(),
4121 crate::ScreenReaderState::Active
4122 );
4123 }
4124
4125 #[test]
4126 fn a_screen_reader_change_dirties_neither_layout_nor_the_accessibility_tree() {
4127 let mut p = screen_reader_probe_tree();
4128 p.tree.render();
4130 let _ = p.tree.sync_accessibility();
4131 assert!(!p.tree.needs_render(), "the baseline is clean");
4132 assert!(!p.tree.a11y_dirty, "the baseline is clean");
4133
4134 p.tree
4135 .set_screen_reader_state(crate::ScreenReaderState::Active);
4136 assert!(
4137 !p.tree.needs_render(),
4138 "the setter schedules no relayout and no repaint"
4139 );
4140 assert!(
4141 !p.tree.a11y_dirty,
4142 "the accessibility tree is not re-walked"
4143 );
4144 }
4145
4146 #[test]
4147 fn an_event_handler_reads_the_screen_reader_state_of_its_dispatch() {
4148 let mut tree = WidgetTree::new();
4149 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
4150 let mut read = None;
4151 tree.run_with_event_context(&mut crate::window::NoopWindowOps, |ctx| {
4152 read = Some(ctx.screen_reader_state());
4153 });
4154 assert_eq!(read, Some(crate::ScreenReaderState::Active));
4155 }
4156
4157 #[test]
4160 fn a_density_switch_announces_once_in_the_applications_own_words() {
4161 use teksilo_tokens::TargetDensity;
4162 let mut tree = tree_with_one_widget();
4163 tree.set_density_announcement(Some(std::rc::Rc::new(|d: TargetDensity| {
4164 format!("layout {d:?}")
4165 })));
4166 let _ = tree.sync_accessibility();
4167
4168 let mut arrivals = Arrivals::attach(tree.accessibility_tree_snapshot());
4169
4170 tree.set_input_density(TargetDensity::Comfortable);
4171 let update = tree.sync_accessibility();
4172 assert_eq!(
4173 arrivals.feed(update),
4174 vec![("layout Comfortable".to_string(), accesskit::Live::Polite)]
4175 );
4176
4177 let _ = arrivals.feed(tree.sync_accessibility());
4179 tree.set_input_density(TargetDensity::Comfortable);
4180 let update = tree.sync_accessibility();
4181 assert_eq!(arrivals.feed(update), Vec::new());
4182 }
4183
4184 #[test]
4185 fn a_density_switch_is_silent_without_a_registered_wording() {
4186 use teksilo_tokens::TargetDensity;
4191 let mut tree = tree_with_one_widget();
4192 let _ = tree.sync_accessibility();
4193 tree.set_input_density(TargetDensity::Comfortable);
4194 let update = tree.sync_accessibility();
4195 assert_eq!(announced_by_consumer(&update), Vec::new());
4196 }
4197
4198 #[test]
4199 fn an_announcement_stays_quiet_when_the_widget_already_speaks() {
4200 let mut tree = WidgetTree::new();
4201 use crate::widget_builder::WidgetBuilder;
4202 let widget = tree.add(
4203 FillWidget::new()
4204 .label("Saved")
4205 .access_live(accesskit::Live::Polite),
4206 );
4207 tree.layout(SizeProposal::exact(200.0, 100.0));
4208 let _ = tree.sync_accessibility();
4210
4211 assert!(
4212 !tree.announce_unless_widget_speaks(widget, "Saved"),
4213 "a widget with its own live region must not be doubled"
4214 );
4215 let update = tree.sync_accessibility();
4216 let live = live_nodes_in_filtered_tree(&update);
4220 assert_eq!(live.len(), 1, "exactly one voice, not two: {live:?}");
4221 }
4222
4223 #[test]
4224 fn an_announcement_goes_ahead_when_the_widget_is_silent() {
4225 let mut tree = WidgetTree::new();
4226 let widget = tree.add(FillWidget::new().label("Row"));
4227 tree.layout(SizeProposal::exact(200.0, 100.0));
4228 let _ = tree.sync_accessibility();
4229
4230 assert!(tree.announce_unless_widget_speaks(widget, "Moved to 3 of 12"));
4231 let update = tree.sync_accessibility();
4232 assert_eq!(
4233 announced_by_consumer(&update),
4234 vec![("Moved to 3 of 12".to_string(), accesskit::Live::Polite)]
4235 );
4236 }
4237
4238 #[test]
4239 fn a_tree_that_records_no_announcements_still_hears_the_widget_speak() {
4240 let mut tree = WidgetTree::new();
4245 tree.set_records_announcements(false);
4246 use crate::widget_builder::WidgetBuilder;
4247 let widget = tree.add(
4248 FillWidget::new()
4249 .label("Saved")
4250 .access_live(accesskit::Live::Polite),
4251 );
4252 tree.layout(SizeProposal::exact(200.0, 100.0));
4253 let _ = tree.sync_accessibility();
4254 assert!(!tree.announce_unless_widget_speaks(widget, "Saved"));
4255 }
4256
4257 #[test]
4258 fn a_live_region_no_platform_hears_does_not_count_as_speaking() {
4259 let mut tree = WidgetTree::new();
4264 use crate::widget_builder::WidgetBuilder;
4265 let widget = tree.add(
4266 FillWidget::new()
4267 .access_role(accesskit::Role::Status)
4268 .access_value("Saved")
4269 .access_live(accesskit::Live::Polite),
4270 );
4271 tree.layout(SizeProposal::exact(200.0, 100.0));
4272 let _ = tree.sync_accessibility();
4273 assert!(tree.announce_unless_widget_speaks(widget, "Saved"));
4274 }
4275
4276 #[test]
4277 fn a_hidden_live_region_does_not_count_as_speaking() {
4278 let mut tree = WidgetTree::new();
4280 use crate::widget_builder::WidgetBuilder;
4281 let widget = tree.add(
4282 FillWidget::new()
4283 .label("Saved")
4284 .access_live(accesskit::Live::Polite)
4285 .access_hidden(true),
4286 );
4287 tree.layout(SizeProposal::exact(200.0, 100.0));
4288 let _ = tree.sync_accessibility();
4289 assert!(tree.announce_unless_widget_speaks(widget, "Saved"));
4290 }
4291
4292 #[test]
4293 fn a_live_region_around_the_widget_does_not_count_as_the_widget_speaking() {
4294 let mut tree = WidgetTree::new();
4300 use crate::widget_builder::WidgetBuilder;
4301 let region = tree.add(
4302 StackWidget::new()
4303 .access_role(accesskit::Role::Group)
4304 .access_label("Notification")
4305 .access_live(accesskit::Live::Polite),
4306 );
4307 let button = tree.add_child(
4308 region,
4309 FillWidget::new()
4310 .label("Annuler")
4311 .access_role(accesskit::Role::Button),
4312 );
4313 tree.layout(SizeProposal::exact(200.0, 100.0));
4314 let _ = tree.sync_accessibility();
4315 assert!(tree.announce_unless_widget_speaks(button, "Menu des actions ouvert"));
4316 }
4317
4318 #[test]
4319 fn a_live_region_with_no_text_does_not_count_as_speaking() {
4320 let mut tree = WidgetTree::new();
4323 use crate::widget_builder::WidgetBuilder;
4324 let widget = tree.add(FillWidget::new().access_live(accesskit::Live::Polite));
4325 tree.layout(SizeProposal::exact(200.0, 100.0));
4326 let _ = tree.sync_accessibility();
4327 assert!(tree.announce_unless_widget_speaks(widget, "Something happened"));
4328 }
4329
4330 #[derive(Debug)]
4338 struct SpeakingSynthetic;
4339
4340 impl crate::widget::Widget for SpeakingSynthetic {
4341 fn layout_response(
4342 &self,
4343 proposal: SizeProposal,
4344 _ctx: &crate::widget::LayoutContext,
4345 ) -> crate::widget::LayoutResponse {
4346 proposal.resolve(0.0, 0.0).into()
4347 }
4348
4349 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
4350 builder.set_role(accesskit::Role::Group);
4351 let child = builder.push_scene_child(
4352 1,
4353 crate::accessibility::SyntheticKind::SceneItem,
4354 |item| {
4355 item.set_role(accesskit::Role::Status);
4356 item.set_name("2 items selected");
4357 },
4358 );
4359 builder.with_collected_node(child, |node| node.set_live(accesskit::Live::Polite));
4360 }
4361 }
4362
4363 #[test]
4364 fn a_live_synthetic_child_counts_as_the_widget_speaking() {
4365 let mut tree = WidgetTree::new();
4370 let widget = tree.add(SpeakingSynthetic);
4371 tree.layout(SizeProposal::exact(200.0, 100.0));
4372 let update = tree.sync_accessibility();
4373 assert!(
4374 update
4375 .nodes
4376 .iter()
4377 .any(|(id, _)| crate::accessibility::is_synthetic(*id)),
4378 "the fixture must actually emit a synthetic child"
4379 );
4380 assert!(!tree.announce_unless_widget_speaks(widget, "2 items selected"));
4381 }
4382
4383 #[test]
4384 fn a_descendants_live_region_silences_its_ancestor_too() {
4385 let mut tree = WidgetTree::new();
4388 let container = tree.add(StackWidget::new());
4389 use crate::widget_builder::WidgetBuilder;
4390 let inner = FillWidget::new()
4391 .label("3 results")
4392 .access_live(accesskit::Live::Polite);
4393 tree.add_child(container, inner);
4394 tree.layout(SizeProposal::exact(200.0, 100.0));
4395 let _ = tree.sync_accessibility();
4396 assert!(!tree.announce_unless_widget_speaks(container, "3 results"));
4397 }
4398}