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 if self.a11y_update_requested.replace(false) {
53 self.a11y_dirty = true;
54 }
55 let current_shortcut_version = self.shortcut_registry.version().get();
63 if current_shortcut_version != self.last_synced_shortcut_version {
64 self.a11y_dirty = true;
65 self.last_synced_shortcut_version = current_shortcut_version;
66 }
67
68 let current_locale = self.locale_signal.get();
77 if current_locale != self.last_synced_locale {
78 self.a11y_dirty = true;
79 self.last_synced_locale = current_locale;
80 }
81
82 let current_title = self.window_title_for_accessibility();
85 if current_title != self.last_synced_window_title {
86 self.a11y_dirty = true;
87 self.last_synced_window_title = current_title;
88 }
89
90 self.step_announcers();
101
102 if !self.a11y_dirty
103 && let Some(cached) = &self.cached_a11y
104 {
105 let _ = cached;
118 let moved = self.patch_accessibility_bounds();
119 if moved
129 && self.announcement_ring.is_recording()
130 && let Some(patched) = &self.cached_a11y
131 && patched.nodes.iter().any(|(id, node)| {
132 !crate::announcer::is_announcer_node(*id)
133 && node.live().is_some_and(|live| live != accesskit::Live::Off)
134 })
135 {
136 self.announcement_ring.observe(patched);
137 }
138 return self
139 .cached_a11y
140 .as_ref()
141 .expect("the cache was present a line ago")
142 .clone();
143 }
144
145 let (update, parents, local_bounds, descriptions, admitted) =
146 self.build_accessibility_tree(true);
147 let announcers_busy = self.settle_announcers(admitted);
148 self.announcement_ring.observe(&update);
153 let content_changed = self.cached_a11y.as_ref() != Some(&update);
160 self.cached_a11y = Some(update.clone());
161 if content_changed {
162 self.adapter_ids.source_changed();
163 }
164 self.synthetic_parent_map = parents;
165 self.synthetic_local_bounds = local_bounds;
166 let descriptions_catching_up = descriptions.catching_up;
173 self.description_memory = descriptions;
174 self.a11y_dirty = false;
175 self.arena.take_a11y_moved();
178 self.arena.take_a11y_resized();
179 self.a11y_walk_generation = self.a11y_walk_generation.saturating_add(1);
180 if content_changed {
181 self.at_version.set(self.at_version.get().saturating_add(1));
184 }
185 if announcers_busy || descriptions_catching_up {
186 self.request_accessibility_update();
187 self.request_frame();
188 }
189 update
190 }
191
192 pub fn deliver_accessibility(&mut self, window_focused: bool) -> accesskit::TreeUpdate {
214 if self.cached_a11y.is_none() {
215 let _ = self.sync_accessibility();
216 }
217 let arena = &self.arena;
218 let source = self
219 .cached_a11y
220 .as_ref()
221 .expect("sync_accessibility leaves an update behind");
222 self.adapter_ids.deliver(source, window_focused, |id| {
223 crate::accessibility::node_id_to_widget_id_maybe(id)
224 .is_none_or(|widget| arena.get(widget).is_some())
225 })
226 }
227
228 #[doc(hidden)]
237 pub fn accessibility_delivery_owed(&self, window_focused: bool) -> bool {
238 self.cached_a11y.is_none() || self.adapter_ids.owes(window_focused)
239 }
240
241 pub fn resolve_adapter_action(
246 &self,
247 request: accesskit::ActionRequest,
248 ) -> Option<accesskit::ActionRequest> {
249 self.adapter_ids.own_request(request)
250 }
251
252 pub fn a11y_walk_generation(&self) -> u64 {
258 self.a11y_walk_generation
259 }
260
261 fn patch_accessibility_bounds(&mut self) -> bool {
275 let moved = self.arena.take_a11y_moved();
276 if moved.is_empty() {
277 return false;
278 }
279 let Some(cached) = self.cached_a11y.as_mut() else {
280 return false;
281 };
282 let index: std::collections::HashMap<accesskit::NodeId, usize> = cached
283 .nodes
284 .iter()
285 .enumerate()
286 .map(|(i, (id, _))| (*id, i))
287 .collect();
288
289 for &id in moved.keys() {
290 let bounds = self.arena.bounds(id);
291 if let Some(&slot) = index.get(&crate::accessibility::widget_id_to_node_id(id)) {
292 cached.nodes[slot].1.set_bounds(to_accesskit_rect(bounds));
293 }
294 }
295
296 for (&syn_id, &owner) in &self.synthetic_parent_map {
301 let Some(delta) = moved.get(&owner) else {
302 continue;
303 };
304 let Some(&slot) = index.get(&syn_id) else {
305 continue;
306 };
307 let node = &mut cached.nodes[slot].1;
308 if node.transform().is_some() {
316 continue;
317 }
318 if let Some(local) = self.synthetic_local_bounds.get(&syn_id) {
319 let origin = self.arena.bounds(owner).origin();
320 node.set_bounds(to_accesskit_rect(teksilo_canvas::Rect::new(
321 origin.x + local.x,
322 origin.y + local.y,
323 local.width,
324 local.height,
325 )));
326 } else if let Some(rect) = node.bounds() {
327 node.set_bounds(accesskit::Rect {
328 x0: rect.x0 + delta.x as f64,
329 y0: rect.y0 + delta.y as f64,
330 x1: rect.x1 + delta.x as f64,
331 y1: rect.y1 + delta.y as f64,
332 });
333 }
334 }
335 self.adapter_ids.source_changed();
338 true
339 }
340
341 pub fn accessibility_tree_snapshot(&self) -> accesskit::TreeUpdate {
355 self.build_accessibility_tree(false).0
356 }
357
358 fn step_announcers(&mut self) {
361 let now = self.animation_clock();
365 let polite = self.announcer_polite.step(now, &mut self.announcer_ids);
366 let assertive = self.announcer_assertive.step(now, &mut self.announcer_ids);
367 if polite || assertive {
368 self.a11y_dirty = true;
369 }
370 self.wake_for_retirement();
371 }
372
373 fn settle_announcers(&mut self, admitted: bool) -> bool {
377 if admitted {
378 let now = self.animation_clock();
379 self.announcer_polite.admit(now);
380 self.announcer_assertive.admit(now);
381 self.wake_for_retirement();
382 }
383 self.announcer_polite.busy() || self.announcer_assertive.busy()
384 }
385
386 fn wake_for_retirement(&self) {
391 if !self.sim_time_frozen
392 && let Some(at) = [
393 self.announcer_polite.next_retirement(),
394 self.announcer_assertive.next_retirement(),
395 ]
396 .into_iter()
397 .flatten()
398 .min()
399 {
400 self.request_wake_at(at);
401 }
402 }
403
404 pub fn dispatch_access_action(
427 &mut self,
428 node_id: accesskit::NodeId,
429 action: accesskit::Action,
430 data: Option<accesskit::ActionData>,
431 ops: &mut dyn crate::window::WindowOps,
432 ) -> bool {
433 let target = crate::accessibility::node_id_to_widget_id_maybe(node_id)
434 .or_else(|| self.widget_for_synthetic(node_id));
435 let event = crate::event::WidgetEvent::AccessAction {
436 action,
437 target,
438 target_node: node_id,
439 data,
440 };
441 self.access_action_handled = false;
442 self.dispatch_event_with_ops(event, ops);
443 self.access_action_handled
444 }
445
446 pub fn widget_for_synthetic(&self, node_id: accesskit::NodeId) -> Option<WidgetId> {
455 self.synthetic_parent_map.get(&node_id).copied()
456 }
457
458 pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
466 self.arena
467 .get(content_id)
468 .and_then(|node| node.widget.as_any())
469 .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
470 .and_then(|d| d.materialized_child())
471 .unwrap_or(content_id)
472 }
473
474 pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
485 let content_id = self.tooltip_content_node(content_id);
486 let node = self.arena.get(content_id)?;
487 let mut probe = AccessNodeBuilder::for_name_probe(content_id);
488 node.widget.accessibility(&mut probe);
489 if let Some(ov) = node.access_overrides.as_deref() {
490 ov.apply(&mut probe);
491 }
492 probe
493 .name()
494 .map(str::trim)
495 .filter(|name| !name.is_empty())
496 .map(str::to_owned)
497 }
498
499 pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
514 let builder = self.build_overridden_builder(id);
515 let role = builder.role();
516 let name = builder.name().map(|s| s.to_string());
517 let actions = builder.actions().to_vec();
518 let mut info = AccessibilityInfo::new(role, name, actions);
519 if let Some(toggled) = builder.toggled() {
520 info = info.with_toggled(toggled);
521 }
522 if let Some(expanded) = builder.expanded() {
523 info = info.with_expanded(expanded);
524 }
525 if let Some(selected) = builder.selected() {
526 info = info.with_selected(selected);
527 }
528 let disabled = self.access_disabled_override(
533 id,
534 self.live_accessibility_proxy(id).is_some(),
535 &self.composites_standing_behind(id),
536 );
537 let force_clear_disabled = disabled == Some(false);
538 let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
539 let disabled_override = disabled == Some(true);
544 if disabled_arena || disabled_override {
545 info = info.with_disabled(true);
546 }
547 if builder.is_hidden() {
548 info = info.with_hidden(true);
549 }
550 info = info.with_published(self.is_in_published_at_tree(id));
551 if let Some(value) = builder.value() {
552 info = info.with_value(value);
553 }
554 if let Some(v) = builder.numeric_value() {
555 info = info.with_numeric_value(v);
556 }
557 info = info.with_numeric_range(builder.min_numeric_value(), builder.max_numeric_value());
558 info.with_custom_actions(
559 builder
560 .custom_actions()
561 .iter()
562 .map(|a| a.description.clone())
563 .collect(),
564 )
565 }
566
567 pub fn try_accessibility_node(&self, id: WidgetId) -> Option<AccessibilityInfo> {
571 self.arena.get(id)?;
572 Some(self.accessibility_node(id))
573 }
574
575 fn is_in_published_at_tree(&self, id: WidgetId) -> bool {
579 use crate::widget_builder::AccessSubtreeMode;
580 if !self.arena.is_active(id) {
581 return false;
582 }
583 let mut cursor = self.arena.parent(id);
584 while let Some(ancestor) = cursor {
585 match self.arena.get(ancestor) {
586 Some(node) if node.access_subtree != AccessSubtreeMode::Inherit => return false,
587 Some(_) => {}
588 None => break,
589 }
590 cursor = self.arena.parent(ancestor);
591 }
592 true
593 }
594
595 pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
596 self.arena
597 .active_ids_iter()
598 .find(|&id| self.build_overridden_builder(id).role() == role)
599 }
600
601 pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
602 self.arena
603 .active_ids_iter()
604 .find(|&id| self.build_overridden_builder(id).name() == Some(label))
605 }
606
607 pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
608 self.arena.active_ids_iter().find(|&id| {
609 self.build_overridden_builder(id)
610 .actions()
611 .contains(&action)
612 })
613 }
614
615 pub fn text_content(&self, id: WidgetId) -> Option<String> {
619 self.arena.get(id)?;
620 self.build_overridden_builder(id)
621 .name()
622 .map(|s| s.to_string())
623 }
624
625 pub fn text_value(&self, id: WidgetId) -> Option<String> {
629 self.arena.get(id)?;
630 self.build_overridden_builder(id)
631 .value()
632 .map(|s| s.to_string())
633 }
634}
635
636pub(crate) fn emit_root_transform(root: &mut accesskit::Node, device_scale_factor: f32) {
665 root.set_transform(accesskit::Affine::scale(device_scale_factor as f64));
666}
667
668#[cfg(test)]
669pub(crate) mod test_helpers {
670 pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
676 accesskit_consumer::Tree::new(update.clone(), false);
677 }
678
679 pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
688 let emitted: std::collections::HashSet<accesskit::NodeId> =
689 update.nodes.iter().map(|(id, _)| *id).collect();
690 for (parent_id, node) in &update.nodes {
691 for &target in node.controls() {
692 assert!(
693 emitted.contains(&target),
694 "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
695 );
696 }
697 for &target in node.described_by() {
698 assert!(
699 emitted.contains(&target),
700 "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
701 );
702 }
703 for &target in node.labelled_by() {
704 assert!(
705 emitted.contains(&target),
706 "node {parent_id:?} has labelled_by() → {target:?} which is absent from the tree"
707 );
708 }
709 if let Some(target) = node.next_on_line() {
710 assert!(
711 emitted.contains(&target),
712 "node {parent_id:?} has next_on_line() → {target:?} which is absent from the tree"
713 );
714 }
715 if let Some(target) = node.previous_on_line() {
716 assert!(
717 emitted.contains(&target),
718 "node {parent_id:?} has previous_on_line() → {target:?} which is absent from the tree"
719 );
720 }
721 }
722 }
723
724 #[allow(dead_code)]
726 pub(crate) fn nodes_with_role(
727 update: &accesskit::TreeUpdate,
728 role: accesskit::Role,
729 ) -> Vec<accesskit::NodeId> {
730 update
731 .nodes
732 .iter()
733 .filter(|(_, node)| node.role() == role)
734 .map(|(id, _)| *id)
735 .collect()
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::test_helpers::*;
742 use super::*;
743 use crate::test_widgets::{FillWidget, StackWidget};
744
745 const ANNOUNCER_NODES: usize = 0;
750
751 struct Arrivals {
757 replay: accesskit_consumer::Tree,
758 }
759
760 impl Arrivals {
761 fn attach(update: accesskit::TreeUpdate) -> Self {
762 Self {
763 replay: accesskit_consumer::Tree::new(update, false),
764 }
765 }
766
767 fn feed(&mut self, update: accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
768 struct Handler(Vec<(String, accesskit::Live)>);
769 fn included(node: &accesskit_consumer::NodeRef) -> bool {
770 accesskit_consumer::common_filter(node) == accesskit_consumer::FilterResult::Include
771 }
772 impl Handler {
773 fn entered(&mut self, node: &accesskit_consumer::NodeRef) {
774 if node.live() != accesskit::Live::Off
775 && let Some(label) = node.label()
776 {
777 self.0.push((label, node.live()));
778 }
779 }
780 }
781 impl accesskit_consumer::TreeChangeHandler for Handler {
782 fn node_added(&mut self, node: &accesskit_consumer::NodeRef) {
783 if included(node) {
784 self.entered(node);
785 }
786 }
787 fn node_updated(
788 &mut self,
789 old: &accesskit_consumer::NodeRef,
790 new: &accesskit_consumer::NodeRef,
791 ) {
792 if !included(old) && included(new) {
793 self.entered(new);
794 }
795 }
796 fn focus_moved(
797 &mut self,
798 _old: Option<&accesskit_consumer::NodeRef>,
799 _new: Option<&accesskit_consumer::NodeRef>,
800 ) {
801 }
802 fn node_removed(&mut self, _node: &accesskit_consumer::NodeRef) {}
803 }
804 let mut handler = Handler(Vec::new());
805 self.replay.update_and_process_changes(update, &mut handler);
806 handler.0
807 }
808 }
809
810 fn announced_by_consumer(update: &accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
827 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
828 let state = consumer.state();
829 let mut found = Vec::new();
830 let mut stack = vec![state.root()];
831 while let Some(node) = stack.pop() {
832 if node.live() != accesskit::Live::Off
833 && let Some(label) = node.label()
834 {
835 found.push((label, node.live()));
836 }
837 for child in node.filtered_children(&accesskit_consumer::common_filter) {
838 stack.push(child);
839 }
840 }
841 found
842 }
843
844 fn live_nodes_in_filtered_tree(update: &accesskit::TreeUpdate) -> Vec<String> {
853 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
854 let state = consumer.state();
855 let mut found = Vec::new();
856 let mut stack = vec![state.root()];
857 while let Some(node) = stack.pop() {
858 if node.live() != accesskit::Live::Off {
859 found.push(format!("{:?}", node.id()));
860 }
861 for child in node.filtered_children(&accesskit_consumer::common_filter) {
862 stack.push(child);
863 }
864 }
865 found.sort();
866 found
867 }
868
869 fn tree_with_one_widget() -> WidgetTree {
870 let mut tree = WidgetTree::new();
871 tree.add(FillWidget::new().label("content"));
872 tree.layout(SizeProposal::exact(200.0, 100.0));
873 tree
874 }
875
876 #[test]
880 fn an_announcement_enters_the_filtered_tree_then_leaves_it() {
881 let mut tree = tree_with_one_widget();
882 tree.advance_time(std::time::Duration::ZERO);
883 let update = tree.sync_accessibility();
886 assert_eq!(announced_by_consumer(&update), Vec::new());
887 assert_eq!(
888 live_nodes_in_filtered_tree(&update),
889 Vec::<String>::new(),
890 "an idle announcer must put nothing in the filtered tree"
891 );
892 assert_a11y_tree_valid(&update);
893 let mut arrivals = Arrivals::attach(update);
894
895 tree.announce("Event added");
896
897 let exposed = tree.sync_accessibility();
898 assert_eq!(
899 announced_by_consumer(&exposed),
900 vec![("Event added".to_string(), accesskit::Live::Polite)],
901 "the message must be a label on a live node inside the filtered tree"
902 );
903 assert_eq!(
904 live_nodes_in_filtered_tree(&exposed).len(),
905 1,
906 "exactly one announcer node must have entered the filtered tree"
907 );
908 assert_a11y_tree_valid(&exposed);
909 assert_eq!(
910 arrivals.feed(exposed),
911 vec![("Event added".to_string(), accesskit::Live::Polite)]
912 );
913
914 tree.advance_time(std::time::Duration::from_millis(16));
917 let lingering = tree.sync_accessibility();
918 assert_eq!(live_nodes_in_filtered_tree(&lingering).len(), 1);
919 assert_eq!(
920 arrivals.feed(lingering),
921 Vec::new(),
922 "a message is said once"
923 );
924
925 tree.advance_time(crate::announcer::LINGER);
927 let gone = tree.sync_accessibility();
928 assert_eq!(
929 live_nodes_in_filtered_tree(&gone),
930 Vec::<String>::new(),
931 "the live node must leave the filtered tree entirely"
932 );
933 assert_a11y_tree_valid(&gone);
934 }
935
936 #[test]
941 fn the_same_message_announced_twice_is_exposed_twice() {
942 let mut tree = tree_with_one_widget();
943 tree.advance_time(std::time::Duration::ZERO);
944 let mut arrivals = Arrivals::attach(tree.sync_accessibility());
945 tree.announce("Saved");
946 tree.announce("Saved");
947
948 let mut heard = Vec::new();
949 let mut ids = std::collections::HashSet::new();
950 for _ in 0..6 {
951 let update = tree.sync_accessibility();
952 ids.extend(
953 update
954 .nodes
955 .iter()
956 .filter(|(id, _)| crate::announcer::is_announcer_node(*id))
957 .map(|(id, _)| *id),
958 );
959 heard.extend(arrivals.feed(update).into_iter().map(|(text, _)| text));
960 tree.advance_time(std::time::Duration::from_millis(16));
961 }
962 assert_eq!(
963 heard,
964 ["Saved", "Saved"],
965 "each announcement is its own arrival"
966 );
967 assert_eq!(
968 ids.len(),
969 2,
970 "each announcement is spoken from its own node"
971 );
972 }
973
974 #[test]
975 fn an_assertive_announcement_uses_the_assertive_live_setting() {
976 let mut tree = tree_with_one_widget();
977 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
978 let update = tree.sync_accessibility();
979 assert_eq!(
980 announced_by_consumer(&update),
981 vec![("Could not save".to_string(), accesskit::Live::Assertive)]
982 );
983 }
984
985 #[test]
988 fn the_two_politeness_levels_announce_independently() {
989 let mut tree = tree_with_one_widget();
990 tree.announce("Event added");
991 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
992
993 let mut spoken = announced_by_consumer(&tree.sync_accessibility());
994 spoken.sort_by(|a, b| a.0.cmp(&b.0));
995 assert_eq!(
996 spoken,
997 vec![
998 ("Could not save".to_string(), accesskit::Live::Assertive),
999 ("Event added".to_string(), accesskit::Live::Polite),
1000 ],
1001 "both levels must be exposed on the same update"
1002 );
1003 }
1004
1005 #[test]
1009 fn announcing_requests_the_syncs_it_needs() {
1010 let mut tree = tree_with_one_widget();
1011 let _ = tree.sync_accessibility();
1012 tree.a11y_update_requested.set(false);
1013 tree.frame_tick_requested.set(false);
1014
1015 tree.announce("Event added");
1016 assert!(
1017 tree.a11y_update_requested.get(),
1018 "an announcement must dirty the accessibility tree"
1019 );
1020 assert!(
1021 tree.frame_requested(),
1022 "an announcement must ask for the frame that carries it"
1023 );
1024
1025 tree.announce("3 events");
1028 tree.a11y_update_requested.set(false);
1029 tree.frame_tick_requested.set(false);
1030 let _ = tree.sync_accessibility();
1031 assert!(tree.a11y_update_requested.get());
1032 assert!(tree.frame_requested());
1033
1034 tree.a11y_update_requested.set(false);
1037 tree.frame_tick_requested.set(false);
1038 let before = std::time::Instant::now();
1039 let _ = tree.sync_accessibility();
1040 assert!(!tree.a11y_update_requested.get());
1041 assert!(!tree.frame_requested());
1042 let wake = tree
1043 .next_timer_deadline()
1044 .expect("a message in the tree asks to be woken when it leaves");
1045 assert!(wake > before + crate::announcer::LINGER / 2);
1046 }
1047
1048 #[test]
1058 fn a_throttled_subscriber_does_not_hold_back_an_announcement() {
1059 const NORMAL_FRAME: std::time::Duration = std::time::Duration::from_micros(17_500);
1061 const CLOCK: std::time::Duration = std::time::Duration::from_secs(60);
1062
1063 fn frame(tree: &mut WidgetTree) -> accesskit::TreeUpdate {
1064 tree.layout(SizeProposal::exact(200.0, 100.0));
1065 let update = tree.sync_accessibility();
1066 let _ = tree.render();
1067 update
1068 }
1069 fn wait_for_next_frame(tree: &WidgetTree) -> std::time::Duration {
1070 let deadline = tree
1071 .frame_tick_deadline()
1072 .expect("a visible subscriber keeps a frame scheduled");
1073 let last = tree.last_frame_time.expect("a frame has run");
1074 deadline.saturating_duration_since(last)
1075 }
1076
1077 let mut tree = WidgetTree::new();
1078 let row = tree.add(FillWidget::new().label("content"));
1079 let _clock = tree.subscribe_frame_tick_throttled(row, CLOCK);
1080 let _ = frame(&mut tree);
1081 let _ = frame(&mut tree);
1082 assert!(
1083 wait_for_next_frame(&tree) >= CLOCK - NORMAL_FRAME,
1084 "precondition: with nothing else going on, the clock alone paces the loop"
1085 );
1086
1087 let messages = ["Annulé : l'ajout d'un évènement", "3 évènements"];
1088 for message in messages {
1089 tree.announce(message);
1090 }
1091 assert!(
1095 wait_for_next_frame(&tree) <= NORMAL_FRAME,
1096 "an announcement must be carried by the next normal frame, not the clock's \
1097 (waited {:?})",
1098 wait_for_next_frame(&tree)
1099 );
1100
1101 let mut exposed = Vec::new();
1102 let mut frames = 0;
1103 let mut arrivals = Arrivals::attach(tree.accessibility_tree_snapshot());
1104 let settled = loop {
1105 let update = frame(&mut tree);
1106 frames += 1;
1107 exposed.extend(arrivals.feed(update).into_iter().map(|(text, _)| text));
1108 let wait = wait_for_next_frame(&tree);
1109 if wait > NORMAL_FRAME {
1110 break wait;
1111 }
1112 assert!(frames < 16, "the announcer never went idle");
1113 };
1114 assert_eq!(
1115 exposed, messages,
1116 "every queued message must be exposed before the loop goes back to sleeping on the \
1117 clock; one left behind is heard only at the next key press"
1118 );
1119 assert!(
1120 settled >= CLOCK - NORMAL_FRAME,
1121 "once the queue is spoken the clock must pace alone again, not free-run at 60 Hz \
1122 (waited {settled:?})"
1123 );
1124 }
1125
1126 #[test]
1129 fn an_announcement_reaches_the_automation_ring() {
1130 let mut tree = tree_with_one_widget();
1131 let before = tree.announcements_since(0).len() as u64;
1132 tree.announce("Event added");
1133 let _ = tree.sync_accessibility();
1134 let got = tree.announcements_since(before);
1135 assert_eq!(got.len(), 1);
1136 assert_eq!(got[0].text, "Event added");
1137 assert!(!got[0].assertive);
1138 }
1139
1140 #[test]
1144 fn a_tree_snapshot_does_not_consume_a_pending_announcement() {
1145 let mut tree = tree_with_one_widget();
1146 tree.announce("Event added");
1147
1148 let snapshot = tree.accessibility_tree_snapshot();
1149 assert_eq!(
1150 announced_by_consumer(&snapshot),
1151 Vec::new(),
1152 "a snapshot must not advance the announcer"
1153 );
1154
1155 let delivered = tree.sync_accessibility();
1156 assert_eq!(
1157 announced_by_consumer(&delivered),
1158 vec![("Event added".to_string(), accesskit::Live::Polite)],
1159 "the message must still be there to deliver"
1160 );
1161 }
1162
1163 #[test]
1166 fn an_empty_announcement_is_not_exposed() {
1167 let mut tree = tree_with_one_widget();
1168 let _ = tree.sync_accessibility();
1169 tree.announce(" ");
1170 let update = tree.sync_accessibility();
1171 assert_eq!(announced_by_consumer(&update), Vec::new());
1172 }
1173
1174 #[derive(Debug)]
1175 struct ActionWidget;
1176
1177 impl Widget for ActionWidget {
1178 fn layout_response(
1179 &self,
1180 proposal: SizeProposal,
1181 _ctx: &LayoutContext,
1182 ) -> crate::widget::LayoutResponse {
1183 proposal.resolve(0.0, 0.0).into()
1184 }
1185
1186 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1187 builder.set_role(accesskit::Role::Button);
1188 builder.set_name("Save");
1189 builder.add_action(accesskit::Action::Click);
1190 builder.add_action(accesskit::Action::Focus);
1191 }
1192 }
1193
1194 #[derive(Debug)]
1195 struct ClickableWidget;
1196
1197 impl Widget for ClickableWidget {
1198 fn layout_response(
1199 &self,
1200 proposal: SizeProposal,
1201 _ctx: &LayoutContext,
1202 ) -> crate::widget::LayoutResponse {
1203 proposal.resolve(0.0, 0.0).into()
1204 }
1205
1206 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1207 builder.set_role(accesskit::Role::Button);
1208 builder.set_name("Click Me");
1209 builder.add_action(accesskit::Action::Click);
1210 }
1211 }
1212
1213 #[test]
1218 fn a_focusable_node_advertises_focus_without_the_widget_saying_so() {
1219 let mut tree = WidgetTree::new();
1220 let id = tree.add(ClickableWidget.focusable(true));
1222 tree.layout(SizeProposal::exact(100.0, 100.0));
1223
1224 let info = tree.accessibility_node(id);
1225 assert!(
1226 info.actions().contains(&accesskit::Action::Focus),
1227 "the arena knows the node is focusable; the AT node must say so"
1228 );
1229
1230 let mut ops = crate::window::NoopWindowOps;
1232 assert!(tree.dispatch_access_action(
1233 crate::accessibility::widget_id_to_node_id(id),
1234 accesskit::Action::Focus,
1235 None,
1236 &mut ops,
1237 ));
1238 assert_eq!(tree.focused(), Some(id));
1239 }
1240
1241 #[derive(Debug)]
1247 struct CompositeWidget {
1248 child_ids: Vec<WidgetId>,
1249 }
1250
1251 impl Widget for CompositeWidget {
1252 fn layout_response(
1253 &self,
1254 proposal: SizeProposal,
1255 _ctx: &LayoutContext,
1256 ) -> crate::widget::LayoutResponse {
1257 proposal.resolve(0.0, 0.0).into()
1258 }
1259
1260 fn place_children(
1261 &self,
1262 bounds: Rect,
1263 _proposal: SizeProposal,
1264 children: &mut [crate::widget::WidgetPlacement],
1265 _ctx: &LayoutContext,
1266 ) {
1267 for child in children.iter_mut() {
1268 child.origin = bounds.origin();
1269 child.size = bounds.size();
1270 }
1271 }
1272
1273 fn children(&self) -> Vec<WidgetId> {
1274 self.child_ids.clone()
1275 }
1276
1277 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1278 builder.set_role(accesskit::Role::SpinButton);
1279 builder.add_action(accesskit::Action::Focus);
1280 }
1281 }
1282
1283 #[test]
1294 fn an_at_focus_on_a_non_focusable_composite_lands_on_its_focusable_leaf() {
1295 let mut tree = WidgetTree::new();
1296 let leaf = tree.add(ClickableWidget.focusable(true));
1297 let root = tree.add(CompositeWidget {
1298 child_ids: vec![leaf],
1299 });
1300 tree.layout(SizeProposal::exact(100.0, 100.0));
1301
1302 let mut ops = crate::window::NoopWindowOps;
1303 assert!(tree.dispatch_access_action(
1304 crate::accessibility::widget_id_to_node_id(root),
1305 accesskit::Action::Focus,
1306 None,
1307 &mut ops,
1308 ));
1309 assert_eq!(
1310 tree.focused(),
1311 Some(leaf),
1312 "focus must reach the focusable leaf, not the composite root"
1313 );
1314 }
1315
1316 #[test]
1325 fn an_at_focus_on_a_plain_container_does_not_walk_into_it() {
1326 let mut tree = WidgetTree::new();
1327 let leaf = tree.add(ClickableWidget.focusable(true));
1328 let root = tree.add(StackWidget::new().child(leaf));
1329 tree.layout(SizeProposal::exact(100.0, 100.0));
1330
1331 let mut ops = crate::window::NoopWindowOps;
1332 assert!(
1333 !tree.dispatch_access_action(
1334 crate::accessibility::widget_id_to_node_id(root),
1335 accesskit::Action::Focus,
1336 None,
1337 &mut ops,
1338 ),
1339 "a node that never advertised `Focus` must report the action unhandled"
1340 );
1341 assert_eq!(
1342 tree.focused(),
1343 None,
1344 "focus must stay where it was, not fall into the container's first control"
1345 );
1346 }
1347
1348 #[test]
1351 fn an_at_focus_on_a_focusable_node_still_lands_on_that_node() {
1352 let mut tree = WidgetTree::new();
1353 let outer = tree.add(ClickableWidget.focusable(true));
1354 tree.layout(SizeProposal::exact(100.0, 100.0));
1355
1356 let mut ops = crate::window::NoopWindowOps;
1357 assert!(tree.dispatch_access_action(
1358 crate::accessibility::widget_id_to_node_id(outer),
1359 accesskit::Action::Focus,
1360 None,
1361 &mut ops,
1362 ));
1363 assert_eq!(tree.focused(), Some(outer));
1364 }
1365
1366 #[test]
1369 fn a_derived_focus_action_is_still_removable_by_an_override() {
1370 let mut tree = WidgetTree::new();
1371 let id = tree.add(
1372 ClickableWidget
1373 .focusable(true)
1374 .access_remove_action(accesskit::Action::Focus),
1375 );
1376 tree.layout(SizeProposal::exact(100.0, 100.0));
1377 assert!(
1378 !tree
1379 .accessibility_node(id)
1380 .actions()
1381 .contains(&accesskit::Action::Focus)
1382 );
1383 }
1384
1385 #[test]
1388 fn a_non_focusable_node_does_not_advertise_focus() {
1389 let mut tree = WidgetTree::new();
1390 let id = tree.add(ClickableWidget);
1391 tree.layout(SizeProposal::exact(100.0, 100.0));
1392 assert!(
1393 !tree
1394 .accessibility_node(id)
1395 .actions()
1396 .contains(&accesskit::Action::Focus)
1397 );
1398 }
1399
1400 #[test]
1401 fn labeled_widget_has_accessibility() {
1402 let mut tree = WidgetTree::new();
1403 let widget = tree.add(FillWidget::new().label("Hello"));
1404 tree.layout(SizeProposal::exact(100.0, 20.0));
1405 let info = tree.accessibility_node(widget);
1406 assert_eq!(info.role(), accesskit::Role::Label);
1407 assert_eq!(info.name(), Some("Hello"));
1408 }
1409
1410 #[test]
1411 fn find_by_label_works() {
1412 let mut tree = WidgetTree::new();
1413 let widget = tree.add(FillWidget::new().label("Save"));
1414 tree.layout(SizeProposal::exact(100.0, 20.0));
1415 assert_eq!(tree.find_by_label("Save"), Some(widget));
1416 }
1417
1418 #[test]
1419 fn label_role_emits_text_as_value_not_label() {
1420 let mut tree = WidgetTree::new();
1427 let widget = tree.add(FillWidget::new().label("Section")); tree.layout(SizeProposal::exact(100.0, 20.0));
1429
1430 assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1432
1433 let update = tree.sync_accessibility();
1435 let node = &update
1436 .nodes
1437 .iter()
1438 .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1439 .expect("label node must be present in the tree update")
1440 .1;
1441 assert_eq!(node.role(), accesskit::Role::Label);
1442 assert_eq!(
1443 node.value(),
1444 Some("Section"),
1445 "Role::Label text must be exposed via `value`"
1446 );
1447 assert_eq!(
1448 node.label(),
1449 None,
1450 "Role::Label must not leave text in the `label` property"
1451 );
1452 }
1453
1454 #[test]
1455 fn find_by_role_works() {
1456 let mut tree = WidgetTree::new();
1457 tree.add(FillWidget::new().label("Text"));
1458 tree.layout(SizeProposal::exact(100.0, 20.0));
1459 assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1460 }
1461
1462 #[test]
1463 fn access_hidden_bound_to_signal_toggles_reactively() {
1464 use crate::signal::Signal;
1465 use crate::widget_builder::WidgetBuilder;
1466 let hidden = Signal::new(false);
1467 let mut tree = WidgetTree::new();
1468 let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1469 tree.layout(SizeProposal::exact(100.0, 30.0));
1470 assert!(
1471 !tree.accessibility_node(w).is_hidden(),
1472 "node should be visible to AT while the signal is false"
1473 );
1474 hidden.set(true);
1475 assert!(
1477 tree.accessibility_node(w).is_hidden(),
1478 "node should hide from AT when the bound signal flips to true"
1479 );
1480 hidden.set(false);
1481 assert!(!tree.accessibility_node(w).is_hidden());
1482 }
1483
1484 #[test]
1488 fn a_widget_with_a_context_menu_advertises_the_action() {
1489 let mut tree = WidgetTree::new();
1490 let plain = tree.add(FillWidget::new().label("no menu"));
1491 let with_menu = tree.add(
1492 FillWidget::new()
1493 .label("has menu")
1494 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1495 );
1496 tree.layout(SizeProposal::exact(100.0, 20.0));
1497
1498 assert!(
1499 tree.accessibility_node(with_menu)
1500 .actions()
1501 .contains(&Action::ShowContextMenu),
1502 "a node owning a context-menu factory must announce it"
1503 );
1504 assert!(
1505 !tree
1506 .accessibility_node(plain)
1507 .actions()
1508 .contains(&Action::ShowContextMenu),
1509 "and a node without one must not"
1510 );
1511 }
1512
1513 #[test]
1516 fn a_removed_context_menu_action_stays_removed() {
1517 let mut tree = WidgetTree::new();
1518 let id = tree.add(
1519 FillWidget::new()
1520 .label("suppressed")
1521 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1522 .access_remove_action(Action::ShowContextMenu),
1523 );
1524 tree.layout(SizeProposal::exact(100.0, 20.0));
1525 assert!(
1526 !tree
1527 .accessibility_node(id)
1528 .actions()
1529 .contains(&Action::ShowContextMenu)
1530 );
1531 }
1532
1533 #[test]
1538 fn a_child_does_not_advertise_its_parents_context_menu() {
1539 let mut tree = WidgetTree::new();
1540 let child = tree.add(FillWidget::new().label("inner"));
1541 let parent = tree.add(
1542 StackWidget::new()
1543 .child(child)
1544 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1545 );
1546 tree.layout(SizeProposal::exact(100.0, 20.0));
1547
1548 assert!(
1549 tree.accessibility_node(parent)
1550 .actions()
1551 .contains(&Action::ShowContextMenu)
1552 );
1553 assert!(
1554 !tree
1555 .accessibility_node(child)
1556 .actions()
1557 .contains(&Action::ShowContextMenu),
1558 "the menu belongs to the widget that declared it"
1559 );
1560 }
1561
1562 #[test]
1563 fn accessibility_node_collects_actions() {
1564 let mut tree = WidgetTree::new();
1565 let widget = tree.add(ActionWidget);
1566 tree.layout(SizeProposal::exact(100.0, 40.0));
1567
1568 let info = tree.accessibility_node(widget);
1569 assert_eq!(info.role(), accesskit::Role::Button);
1570 assert_eq!(info.name(), Some("Save"));
1571 assert_eq!(info.actions().len(), 2);
1572 assert!(info.actions().contains(&accesskit::Action::Click));
1573 assert!(info.actions().contains(&accesskit::Action::Focus));
1574 }
1575
1576 #[test]
1577 fn sync_accessibility_produces_tree_update() {
1578 let mut tree = WidgetTree::new();
1579 tree.add(FillWidget::new().label("First"));
1580 tree.add(FillWidget::new().label("Second"));
1581 tree.layout(SizeProposal::exact(200.0, 100.0));
1582
1583 let update = tree.sync_accessibility();
1584 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1587 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1588 assert!(update.tree.is_some());
1589 assert_a11y_tree_valid(&update);
1590 }
1591
1592 #[test]
1593 fn root_node_carries_locale_as_language() {
1594 let mut tree = WidgetTree::new();
1595 tree.add(FillWidget::new().label("Bonjour"));
1596
1597 tree.layout(SizeProposal::exact(200.0, 100.0));
1599 let update = tree.sync_accessibility();
1600 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1601 assert_eq!(
1602 update.nodes[0].1.language(),
1603 None,
1604 "no language before a locale is set"
1605 );
1606
1607 tree.set_locale("fr-FR".to_string());
1610 let update = tree.sync_accessibility();
1611 assert_eq!(
1612 update.nodes[0].1.language(),
1613 Some("fr-FR"),
1614 "root node must advertise the active locale as its language"
1615 );
1616 }
1617
1618 #[test]
1619 fn sync_accessibility_excludes_dormant_widgets() {
1620 let mut tree = WidgetTree::new();
1621 tree.add(FillWidget::new().label("Active"));
1622 let dormant = tree.add(FillWidget::new().label("Dormant"));
1623 tree.layout(SizeProposal::exact(200.0, 100.0));
1624
1625 tree.set_dormant(dormant);
1626
1627 let update = tree.sync_accessibility();
1628 assert_eq!(update.nodes.len(), 2 + ANNOUNCER_NODES);
1629 assert_a11y_tree_valid(&update);
1630 }
1631
1632 #[test]
1633 fn try_accessibility_node_is_none_for_a_destroyed_id() {
1634 let mut tree = WidgetTree::new();
1635 let id = tree.add(FillWidget::new().label("Gone soon"));
1636 tree.layout(SizeProposal::exact(200.0, 100.0));
1637 assert!(tree.try_accessibility_node(id).is_some());
1638
1639 tree.destroy_subtree_for_testing(id);
1640 assert!(tree.try_accessibility_node(id).is_none());
1641 }
1642
1643 #[test]
1644 fn a_dormant_node_answers_but_is_not_published() {
1645 let mut tree = WidgetTree::new();
1646 let shown = tree.add(FillWidget::new().label("Shown"));
1647 let parked = tree.add(FillWidget::new().label("Parked"));
1648 tree.layout(SizeProposal::exact(200.0, 100.0));
1649 tree.set_dormant(parked);
1650
1651 let shown = tree.try_accessibility_node(shown).expect("in the arena");
1652 assert!(shown.published());
1653
1654 let parked = tree.try_accessibility_node(parked).expect("in the arena");
1655 assert_eq!(
1656 parked.name(),
1657 Some("Parked"),
1658 "a dormant node still answers"
1659 );
1660 assert!(!parked.is_hidden(), "dormancy is not the hidden flag");
1661 assert!(!parked.published());
1662 }
1663
1664 #[test]
1665 fn a_node_under_an_excluded_ancestor_is_not_published() {
1666 use crate::widget_builder::WidgetBuilder;
1667 let mut tree = WidgetTree::new();
1668 let inner = tree.add(FillWidget::new().label("Inner"));
1669 let outer = tree.add(StackWidget::new().child(inner).access_exclude_subtree());
1670 tree.layout(SizeProposal::exact(200.0, 100.0));
1671
1672 assert!(tree.accessibility_node(outer).published());
1673 assert!(!tree.accessibility_node(inner).published());
1674 }
1675
1676 #[test]
1683 fn relayout_without_activation_change_does_not_dirty_a11y() {
1684 let mut tree = WidgetTree::new();
1685 let id = tree.add(FillWidget::new().label("Static"));
1686 tree.layout(SizeProposal::exact(200.0, 100.0));
1687
1688 let _ = tree.sync_accessibility();
1690 assert!(
1691 !tree.a11y_dirty,
1692 "sync_accessibility must clear the dirty flag"
1693 );
1694
1695 tree.arena.mark_needs_layout(id);
1699 tree.layout(SizeProposal::exact(200.0, 100.0));
1700
1701 assert!(
1702 !tree.a11y_dirty,
1703 "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1704 );
1705 }
1706
1707 #[derive(Debug)]
1710 struct Mover {
1711 offset: crate::signal::Signal<f32>,
1712 child: WidgetId,
1713 }
1714
1715 impl crate::widget::Widget for Mover {
1716 fn children(&self) -> Vec<WidgetId> {
1717 vec![self.child]
1718 }
1719 fn layout_response(
1720 &self,
1721 proposal: SizeProposal,
1722 _ctx: &crate::widget::LayoutContext,
1723 ) -> crate::widget::LayoutResponse {
1724 proposal.resolve(200.0, 100.0).into()
1725 }
1726 fn place_children(
1727 &self,
1728 bounds: teksilo_canvas::Rect,
1729 _proposal: SizeProposal,
1730 children: &mut [crate::widget::WidgetPlacement],
1731 _ctx: &crate::widget::LayoutContext,
1732 ) {
1733 if let Some(placement) = children.first_mut() {
1734 placement.origin =
1735 teksilo_canvas::Point::new(bounds.x + self.offset.get(), bounds.y);
1736 placement.size = teksilo_canvas::Size::new(50.0, 20.0);
1737 }
1738 }
1739 }
1740
1741 #[test]
1748 fn translating_a_widget_patches_the_cache_without_a_walk() {
1749 let mut tree = WidgetTree::new();
1750 let inner = tree.add(FillWidget::new().label("Row"));
1751 let offset = crate::signal::Signal::new(0.0f32);
1752 let root = tree.add(Mover {
1753 offset: offset.clone(),
1754 child: inner,
1755 });
1756 tree.layout(SizeProposal::exact(200.0, 100.0));
1757 let update = tree.sync_accessibility();
1758 let walks = tree.a11y_walk_generation();
1759 let before = find_node(&update, inner)
1760 .unwrap()
1761 .bounds()
1762 .expect("the node carries bounds");
1763
1764 offset.set(20.0);
1766 tree.arena.mark_needs_layout(root);
1767 tree.layout(SizeProposal::exact(200.0, 100.0));
1768
1769 let update = tree.sync_accessibility();
1770 assert_eq!(
1771 tree.a11y_walk_generation(),
1772 walks,
1773 "a pure translation must not cost a walk"
1774 );
1775 let after = find_node(&update, inner)
1776 .unwrap()
1777 .bounds()
1778 .expect("the node still carries bounds");
1779 assert!(
1780 (after.x0 - before.x0 - 20.0).abs() < 0.01,
1781 "the cached node must follow the move: {before:?} -> {after:?}"
1782 );
1783 }
1784
1785 #[test]
1786 fn at_version_does_not_bump_for_a_move() {
1787 let mut tree = WidgetTree::new();
1791 let inner = tree.add(FillWidget::new().label("Row"));
1792 let offset = crate::signal::Signal::new(0.0f32);
1793 let root = tree.add(Mover {
1794 offset: offset.clone(),
1795 child: inner,
1796 });
1797 tree.layout(SizeProposal::exact(200.0, 100.0));
1798 let _ = tree.sync_accessibility();
1799 let version = tree.at_version().get();
1800
1801 offset.set(20.0);
1802 tree.arena.mark_needs_layout(root);
1803 tree.layout(SizeProposal::exact(200.0, 100.0));
1804 let _ = tree.sync_accessibility();
1805
1806 assert_eq!(tree.at_version().get(), version);
1807 }
1808
1809 #[test]
1814 fn a_delivery_is_owed_only_for_what_the_adapter_does_not_hold() {
1815 let mut tree = WidgetTree::new();
1816 let inner = tree.add(FillWidget::new().label("Row"));
1817 let offset = crate::signal::Signal::new(0.0f32);
1818 let root = tree.add(Mover {
1819 offset: offset.clone(),
1820 child: inner,
1821 });
1822 tree.layout(SizeProposal::exact(200.0, 100.0));
1823 let _ = tree.sync_accessibility();
1824 assert!(
1825 tree.accessibility_delivery_owed(true),
1826 "nothing delivered yet"
1827 );
1828 let _ = tree.deliver_accessibility(true);
1829 assert!(!tree.accessibility_delivery_owed(true));
1830 assert!(
1831 tree.accessibility_delivery_owed(false),
1832 "the window's focus changes what the adapter keeps"
1833 );
1834
1835 let _ = tree.sync_accessibility();
1836 assert!(
1837 !tree.accessibility_delivery_owed(true),
1838 "an idle frame owes nothing"
1839 );
1840
1841 offset.set(20.0);
1842 tree.arena.mark_needs_layout(root);
1843 tree.layout(SizeProposal::exact(200.0, 100.0));
1844 let _ = tree.sync_accessibility();
1845 assert!(tree.accessibility_delivery_owed(true), "a move is owed");
1846 let _ = tree.deliver_accessibility(true);
1847 assert!(!tree.accessibility_delivery_owed(true));
1848
1849 let walks = tree.a11y_walk_generation();
1851 tree.set_theme(crate::presets::intui::dark());
1852 let _ = tree.sync_accessibility();
1853 assert!(
1854 tree.a11y_walk_generation() > walks,
1855 "precondition: it walked"
1856 );
1857 assert!(!tree.accessibility_delivery_owed(true));
1858
1859 tree.add(FillWidget::new().label("New"));
1860 tree.layout(SizeProposal::exact(200.0, 100.0));
1861 let _ = tree.sync_accessibility();
1862 assert!(
1863 tree.accessibility_delivery_owed(true),
1864 "new content is owed"
1865 );
1866 let _ = tree.deliver_accessibility(true);
1867 assert!(!tree.accessibility_delivery_owed(true));
1868 }
1869
1870 #[test]
1871 fn resizing_a_widget_dirties_the_accessibility_tree() {
1872 let mut tree = WidgetTree::new();
1876 let id = tree.add(FillWidget::new().label("Static"));
1877 tree.layout(SizeProposal::exact(200.0, 100.0));
1878 let _ = tree.sync_accessibility();
1879 let walks = tree.a11y_walk_generation();
1880
1881 tree.layout(SizeProposal::exact(300.0, 100.0));
1882 let _ = tree.sync_accessibility();
1883
1884 assert!(
1885 tree.a11y_walk_generation() > walks,
1886 "a resize must be walked, not patched"
1887 );
1888 let _ = id;
1889 }
1890
1891 #[test]
1892 fn changing_the_theme_dirties_the_accessibility_tree() {
1893 let mut tree = WidgetTree::new();
1898 tree.add(FillWidget::new().label("Static"));
1899 tree.layout(SizeProposal::exact(200.0, 100.0));
1900 let _ = tree.sync_accessibility();
1901 let walks = tree.a11y_walk_generation();
1902
1903 tree.set_theme(crate::presets::intui::dark());
1904 let _ = tree.sync_accessibility();
1905
1906 assert!(tree.a11y_walk_generation() > walks);
1907 }
1908
1909 #[test]
1910 fn changing_the_text_scale_dirties_the_accessibility_tree() {
1911 let mut tree = WidgetTree::new();
1912 tree.add(FillWidget::new().label("Static"));
1913 tree.layout(SizeProposal::exact(200.0, 100.0));
1914 let _ = tree.sync_accessibility();
1915 let walks = tree.a11y_walk_generation();
1916
1917 tree.set_user_text_scale(1.5);
1918 let _ = tree.sync_accessibility();
1919
1920 assert!(tree.a11y_walk_generation() > walks);
1921 }
1922
1923 #[test]
1924 fn a_moved_node_absent_from_the_cache_is_skipped() {
1925 use crate::widget_builder::WidgetBuilder;
1930 let mut tree = WidgetTree::new();
1931 let hidden = tree.add(FillWidget::new().label("Inner"));
1932 let offset = crate::signal::Signal::new(0.0f32);
1933 let root = tree.add(
1934 Mover {
1935 offset: offset.clone(),
1936 child: hidden,
1937 }
1938 .access_exclude_subtree(),
1939 );
1940 tree.layout(SizeProposal::exact(200.0, 100.0));
1941 let update = tree.sync_accessibility();
1942 assert!(
1943 find_node(&update, hidden).is_none(),
1944 "the excluded descendant must be absent for this test to mean anything"
1945 );
1946
1947 offset.set(20.0);
1948 tree.arena.mark_needs_layout(root);
1949 tree.layout(SizeProposal::exact(200.0, 100.0));
1950 let update = tree.sync_accessibility();
1951
1952 assert!(find_node(&update, hidden).is_none());
1953 assert_no_dangling_relationships(&update);
1954 }
1955
1956 #[test]
1957 fn focus_on_a_node_the_walk_did_not_emit_resolves_to_an_emitted_ancestor() {
1958 use crate::widget_builder::WidgetBuilder;
1969 let mut tree = WidgetTree::new();
1970 let inner = tree.add(ClickableWidget);
1971 let outer = tree.add(
1972 Mover {
1973 offset: crate::signal::Signal::new(0.0f32),
1974 child: inner,
1975 }
1976 .access_exclude_subtree(),
1977 );
1978 tree.layout(SizeProposal::exact(200.0, 100.0));
1979 tree.focus(inner);
1980
1981 let update = tree.sync_accessibility();
1982 assert!(
1983 find_node(&update, inner).is_none(),
1984 "precondition: the excluded descendant emits nothing"
1985 );
1986 let emitted: std::collections::HashSet<_> =
1987 update.nodes.iter().map(|(id, _)| *id).collect();
1988 assert!(
1989 emitted.contains(&update.focus),
1990 "the published focus must be a node in the published tree"
1991 );
1992 assert_eq!(
1993 update.focus,
1994 crate::accessibility::widget_id_to_node_id(outer),
1995 "…and specifically the nearest ancestor that did emit"
1996 );
1997 accesskit_consumer::Tree::new(update, false);
1999 }
2000
2001 #[test]
2005 fn activation_transition_does_dirty_a11y() {
2006 let mut tree = WidgetTree::new();
2007 let id = tree.add(FillWidget::new().label("Toggle"));
2008 tree.layout(SizeProposal::exact(200.0, 100.0));
2009 let _ = tree.sync_accessibility();
2010 assert!(!tree.a11y_dirty);
2011
2012 tree.set_dormant(id);
2013 tree.layout(SizeProposal::exact(200.0, 100.0));
2014 assert!(
2015 tree.a11y_dirty,
2016 "active→dormant transition must dirty the AT cache so the dormant node is removed"
2017 );
2018 }
2019
2020 #[test]
2021 fn sync_accessibility_includes_focus() {
2022 let mut tree = WidgetTree::new();
2023 let widget = tree.add(FillWidget::new().focusable().label("Focused"));
2024 tree.layout(SizeProposal::exact(100.0, 50.0));
2025 tree.focus(widget);
2026
2027 let update = tree.sync_accessibility();
2028 let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
2029 assert_eq!(update.focus, expected_focus);
2030 assert_a11y_tree_valid(&update);
2031 }
2032
2033 #[test]
2034 fn sync_accessibility_parent_child_relationship() {
2035 let mut tree = WidgetTree::new();
2036 let child = tree.add(FillWidget::new().label("Child"));
2037 let parent = tree.add(
2040 StackWidget::new()
2041 .child(child)
2042 .access_label_literal("Parent"),
2043 );
2044 tree.layout(SizeProposal::exact(100.0, 50.0));
2045
2046 let update = tree.sync_accessibility();
2047 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
2048
2049 let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
2050 let parent_node = update
2051 .nodes
2052 .iter()
2053 .find(|(id, _)| *id == parent_node_id)
2054 .map(|(_, node)| node)
2055 .unwrap();
2056
2057 let child_node_id = crate::accessibility::widget_id_to_node_id(child);
2058 assert!(parent_node.children().contains(&child_node_id));
2059 assert_a11y_tree_valid(&update);
2060 }
2061
2062 #[test]
2063 fn presentational_containers_collapse_and_promote_children() {
2064 let mut tree = WidgetTree::new();
2070 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(
2074 StackWidget::new()
2075 .child(outer)
2076 .access_label_literal("Group"),
2077 );
2078 tree.layout(SizeProposal::exact(100.0, 50.0));
2079 let update = tree.sync_accessibility();
2080
2081 assert!(find_node(&update, inner).is_none(), "bare inner pruned");
2082 assert!(find_node(&update, outer).is_none(), "bare outer pruned");
2083
2084 let labeled_node = find_node(&update, labeled).expect("labeled group survives");
2085 let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
2086 assert!(
2087 labeled_node.children().contains(&leaf_nid),
2088 "leaf promoted past both bare containers to the labeled ancestor"
2089 );
2090 assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
2091 assert_a11y_tree_valid(&update);
2092 }
2093
2094 #[test]
2095 fn find_by_action_finds_clickable() {
2096 let mut tree = WidgetTree::new();
2097 let widget = tree.add(ClickableWidget);
2098 tree.layout(SizeProposal::exact(100.0, 40.0));
2099
2100 assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
2101 assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
2102 }
2103
2104 #[test]
2105 fn text_content_returns_accessibility_name() {
2106 let mut tree = WidgetTree::new();
2107 let widget = tree.add(FillWidget::new().label("Hello World"));
2108 tree.layout(SizeProposal::exact(100.0, 50.0));
2109
2110 assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
2111 }
2112
2113 #[test]
2114 fn text_content_returns_none_without_label() {
2115 let mut tree = WidgetTree::new();
2116 let widget = tree.add(FillWidget::new());
2117 tree.layout(SizeProposal::exact(100.0, 50.0));
2118
2119 assert_eq!(tree.text_content(widget), None);
2120 }
2121
2122 #[test]
2123 fn descendant_of_disabled_ancestor_reports_disabled() {
2124 use crate::signal::Signal;
2125
2126 let mut tree = WidgetTree::new();
2127 let child = tree.add(FillWidget::new().label("Child"));
2128 let parent = tree.add(StackWidget::new().child(child));
2129 tree.enabled_when(parent, Signal::new(false));
2130 tree.layout(SizeProposal::exact(100.0, 50.0));
2131
2132 assert!(
2133 tree.accessibility_node(child).is_disabled(),
2134 "descendant should report disabled when ancestor is disabled"
2135 );
2136 }
2137
2138 #[test]
2139 fn text_value_returns_accessibility_value() {
2140 #[derive(Debug)]
2141 struct ValueWidget;
2142
2143 impl Widget for ValueWidget {
2144 fn layout_response(
2145 &self,
2146 proposal: SizeProposal,
2147 _ctx: &LayoutContext,
2148 ) -> crate::widget::LayoutResponse {
2149 proposal.resolve(0.0, 0.0).into()
2150 }
2151
2152 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
2153 builder.set_role(accesskit::Role::Slider);
2154 builder.set_name("Volume");
2155 builder.set_value("75%");
2156 }
2157 }
2158
2159 let mut tree = WidgetTree::new();
2160 let widget = tree.add(ValueWidget);
2161 tree.layout(SizeProposal::exact(100.0, 40.0));
2162
2163 assert_eq!(tree.text_value(widget), Some("75%".to_string()));
2164 assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
2165 }
2166
2167 #[test]
2168 fn sync_accessibility_has_no_duplicate_children() {
2169 let mut tree = WidgetTree::new();
2173 let grandchild = tree.add(FillWidget::new().label("Grandchild"));
2174 let child_a = tree.add(StackWidget::new().child(grandchild));
2175 let child_b = tree.add(FillWidget::new().label("Sibling"));
2176 let _root = tree.add(StackWidget::new().child(child_a).child(child_b));
2177 tree.layout(SizeProposal::exact(200.0, 100.0));
2178
2179 let update = tree.sync_accessibility();
2180
2181 let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
2182 std::collections::HashMap::new();
2183 for (parent_id, node) in &update.nodes {
2184 for &child_id in node.children() {
2185 let prev = all_children.insert(child_id, *parent_id);
2186 assert!(
2187 prev.is_none(),
2188 "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
2189 );
2190 }
2191 }
2192 assert_a11y_tree_valid(&update);
2193 }
2194
2195 #[test]
2196 fn no_dangling_relationships_in_basic_tree() {
2197 let mut tree = WidgetTree::new();
2198 let child = tree.add(FillWidget::new().label("Child"));
2199 let _parent = tree.add(StackWidget::new().child(child));
2200 tree.layout(SizeProposal::exact(100.0, 50.0));
2201
2202 let update = tree.sync_accessibility();
2203 assert_no_dangling_relationships(&update);
2204 assert_a11y_tree_valid(&update);
2205 }
2206
2207 use crate::widget_builder::WidgetBuilder;
2212 use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
2213
2214 fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
2216 let nid = crate::accessibility::widget_id_to_node_id(id);
2217 update
2218 .nodes
2219 .iter()
2220 .find(|(node_id, _)| *node_id == nid)
2221 .map(|(_, n)| n)
2222 }
2223
2224 #[derive(Debug)]
2227 struct AlwaysHiddenWidget;
2228 impl Widget for AlwaysHiddenWidget {
2229 fn layout_response(
2230 &self,
2231 proposal: SizeProposal,
2232 _ctx: &LayoutContext,
2233 ) -> crate::widget::LayoutResponse {
2234 proposal.resolve(0.0, 0.0).into()
2235 }
2236 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2237 builder.set_role(Role::GenericContainer);
2238 builder.set_hidden();
2239 }
2240 }
2241
2242 #[test]
2244 fn access_label_replaces_widget_label() {
2245 let mut tree = WidgetTree::new();
2246 let id = tree.add(ClickableWidget.access_label_literal("Publish"));
2247 tree.layout(SizeProposal::exact(100.0, 40.0));
2248 assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
2249 }
2250
2251 #[test]
2253 fn access_description_appears_on_bare_widget() {
2254 let mut tree = WidgetTree::new();
2255 let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
2256 tree.layout(SizeProposal::exact(50.0, 50.0));
2257 let update = tree.sync_accessibility();
2258 let node = find_node(&update, id).expect("node present");
2259 assert_eq!(node.description(), Some("Decorative"));
2260 }
2261
2262 #[test]
2264 fn access_value_replaces_widget_value() {
2265 #[derive(Debug)]
2266 struct SliderWidget;
2267 impl Widget for SliderWidget {
2268 fn layout_response(
2269 &self,
2270 proposal: SizeProposal,
2271 _ctx: &LayoutContext,
2272 ) -> crate::widget::LayoutResponse {
2273 proposal.resolve(0.0, 0.0).into()
2274 }
2275 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2276 builder.set_role(Role::Slider);
2277 builder.set_value("50");
2278 }
2279 }
2280 let mut tree = WidgetTree::new();
2281 let id = tree.add(SliderWidget.access_value_literal("Custom"));
2282 tree.layout(SizeProposal::exact(100.0, 40.0));
2283 assert_eq!(tree.text_value(id), Some("Custom".to_string()));
2284 }
2285
2286 #[test]
2288 fn access_role_overrides_widget_role() {
2289 let mut tree = WidgetTree::new();
2290 let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
2291 tree.layout(SizeProposal::exact(100.0, 40.0));
2292 assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
2293 }
2294
2295 #[test]
2296 fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
2297 let mut tree = WidgetTree::new();
2304 let anchor = tree.add(FillWidget::new().label("Save"));
2305 let tip = tree.add(FillWidget::new().label("Save the file"));
2306 tree.layout(SizeProposal::exact(200.0, 100.0));
2307 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
2308
2309 assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
2310 let update = tree.sync_accessibility();
2311 let node = find_node(&update, anchor).expect("anchor node present");
2312 assert_eq!(
2313 node.description(),
2314 Some("Save the file"),
2315 "the anchor must describe itself with its tooltip text while unshown"
2316 );
2317 }
2318
2319 #[test]
2320 fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
2321 let mut tree = WidgetTree::new();
2327 let anchor = tree.add(FillWidget::new().label("Save"));
2328 let tip = tree.add(FillWidget::new().label("Save the file"));
2329 tree.layout(SizeProposal::exact(200.0, 100.0));
2330 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2331
2332 tree.pointer_move(tree.bounds(anchor).center());
2333 tree.advance_time(std::time::Duration::from_millis(150));
2334 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
2335
2336 let update = tree.sync_accessibility();
2337 let node = find_node(&update, anchor).expect("anchor node present");
2338 assert!(
2339 !node.described_by().is_empty(),
2340 "a shown tooltip is wired as a described_by relation"
2341 );
2342 assert_eq!(
2343 node.description(),
2344 Some("Save the file"),
2345 "and the anchor still describes itself with the tooltip's text"
2346 );
2347 }
2348
2349 #[test]
2351 fn access_hint_alias_writes_description_field() {
2352 let mut tree = WidgetTree::new();
2353 let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
2354 tree.layout(SizeProposal::exact(50.0, 50.0));
2355 let update = tree.sync_accessibility();
2356 let node = find_node(&update, id).unwrap();
2357 assert_eq!(node.description(), Some("Tip"));
2358 }
2359
2360 #[test]
2362 fn access_identifier_writes_author_id() {
2363 let mut tree = WidgetTree::new();
2364 let id = tree.add(FillWidget::new().access_identifier("save-button"));
2365 tree.layout(SizeProposal::exact(50.0, 50.0));
2366 let update = tree.sync_accessibility();
2367 let node = find_node(&update, id).unwrap();
2368 assert_eq!(node.author_id(), Some("save-button"));
2369 }
2370
2371 #[test]
2373 fn access_shortcut_literal_set() {
2374 let mut tree = WidgetTree::new();
2375 let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
2376 tree.layout(SizeProposal::exact(50.0, 50.0));
2377 let update = tree.sync_accessibility();
2378 let node = find_node(&update, id).unwrap();
2379 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
2380 }
2381
2382 #[test]
2385 fn access_shortcut_id_resolves_and_tracks_rebinds() {
2386 use crate::event::Key;
2387 use crate::shortcut::{KeyStroke, Shortcut};
2388
2389 let mut tree = WidgetTree::new();
2390 tree.shortcut_registry_mut().register(
2391 Shortcut::new("app.save")
2392 .name("Save")
2393 .primary(KeyStroke::command(Key::S))
2394 .build(),
2395 );
2396 let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
2397 tree.layout(SizeProposal::exact(50.0, 50.0));
2398
2399 let accel = if cfg!(target_os = "macos") {
2403 "Cmd"
2404 } else {
2405 "Ctrl"
2406 };
2407
2408 let update = tree.sync_accessibility();
2410 let node = find_node(&update, id).unwrap();
2411 assert_eq!(
2412 node.keyboard_shortcut(),
2413 Some(format!("{accel}+S").as_str())
2414 );
2415
2416 tree.shortcut_registry_mut()
2418 .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
2419 let update = tree.sync_accessibility();
2420 let node = find_node(&update, id).unwrap();
2421 assert_eq!(
2422 node.keyboard_shortcut(),
2423 Some(format!("{accel}+Q").as_str())
2424 );
2425 }
2426
2427 #[test]
2430 fn access_shortcut_id_unknown_id_omits_announcement() {
2431 let mut tree = WidgetTree::new();
2432 let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
2433 tree.layout(SizeProposal::exact(50.0, 50.0));
2434 let update = tree.sync_accessibility();
2435 let node = find_node(&update, id).unwrap();
2436 assert_eq!(node.keyboard_shortcut(), None);
2437 }
2438
2439 #[test]
2441 fn access_hidden_true_hides_widget() {
2442 let mut tree = WidgetTree::new();
2443 let id = tree.add(ClickableWidget.access_hidden(true));
2444 tree.layout(SizeProposal::exact(50.0, 50.0));
2445 assert!(tree.accessibility_node(id).is_hidden());
2446 }
2447
2448 #[test]
2450 fn access_hidden_false_clears_widget_set_hidden() {
2451 let mut tree = WidgetTree::new();
2452 let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
2453 tree.layout(SizeProposal::exact(50.0, 50.0));
2454 assert!(
2455 !tree.accessibility_node(id).is_hidden(),
2456 "access_hidden(false) should clear widget-emitted hidden"
2457 );
2458 }
2459
2460 #[test]
2462 fn access_disabled_true_marks_disabled() {
2463 let mut tree = WidgetTree::new();
2464 let id = tree.add(FillWidget::new().access_disabled(true));
2465 tree.layout(SizeProposal::exact(50.0, 50.0));
2466 assert!(tree.accessibility_node(id).is_disabled());
2467 }
2468
2469 #[test]
2471 fn access_disabled_false_clears_arena_driven_disabled() {
2472 use crate::signal::Signal;
2473 let mut tree = WidgetTree::new();
2474 let id = tree.add(FillWidget::new().label("X").access_disabled(false));
2475 tree.enabled_when(id, Signal::new(false));
2476 tree.layout(SizeProposal::exact(50.0, 50.0));
2477 assert!(
2478 !tree.accessibility_node(id).is_disabled(),
2479 "access_disabled(false) should clear even arena-driven disabled"
2480 );
2481 }
2482
2483 #[test]
2485 fn access_controls_appends() {
2486 let mut tree = WidgetTree::new();
2487 let target = tree.add(FillWidget::new().label("Target"));
2488 let controller = tree.add(
2489 FillWidget::new()
2490 .label("Controller")
2491 .access_controls(target),
2492 );
2493 tree.layout(SizeProposal::exact(100.0, 50.0));
2494 let update = tree.sync_accessibility();
2495 let node = find_node(&update, controller).unwrap();
2496 let target_nid = crate::accessibility::widget_id_to_node_id(target);
2497 assert!(
2498 node.controls().contains(&target_nid),
2499 "controls list should contain the target NodeId"
2500 );
2501 }
2502
2503 #[test]
2505 fn access_described_by_appends() {
2506 let mut tree = WidgetTree::new();
2507 let other = tree.add(FillWidget::new().label("Desc"));
2508 let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
2509 tree.layout(SizeProposal::exact(100.0, 50.0));
2510 let update = tree.sync_accessibility();
2511 let node = find_node(&update, id).unwrap();
2512 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2513 assert!(node.described_by().contains(&other_nid));
2514 }
2515
2516 #[test]
2518 fn access_labelled_by_appends() {
2519 let mut tree = WidgetTree::new();
2520 let other = tree.add(FillWidget::new().label("Lbl"));
2521 let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
2522 tree.layout(SizeProposal::exact(100.0, 50.0));
2523 let update = tree.sync_accessibility();
2524 let node = find_node(&update, id).unwrap();
2525 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2526 assert!(node.labelled_by().contains(&other_nid));
2527 }
2528
2529 #[test]
2541 fn a_child_attached_to_a_node_that_never_reached_the_tree_is_stripped() {
2542 use crate::accessibility::{AccessNodeBuilder, SyntheticKind, widget_id_to_node_id};
2543
2544 #[derive(Debug)]
2547 struct Grafter {
2548 child: std::cell::Cell<Option<WidgetId>>,
2549 }
2550
2551 impl Widget for Grafter {
2552 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2553 let child = ctx.add(FillWidget::new().label("Card"));
2554 self.child.set(Some(child));
2555 vec![child]
2556 }
2557 fn layout_response(
2558 &self,
2559 _p: SizeProposal,
2560 _c: &crate::widget::LayoutContext,
2561 ) -> crate::widget::LayoutResponse {
2562 teksilo_canvas::Size::new(100.0, 100.0).into()
2563 }
2564 fn children(&self) -> Vec<WidgetId> {
2565 self.child.get().into_iter().collect()
2566 }
2567 fn wants_descendant_redirects(&self) -> bool {
2568 true
2569 }
2570 fn a11y_redirect_descendant(
2571 &self,
2572 _self_id: WidgetId,
2573 descendant: WidgetId,
2574 ) -> Option<accesskit::NodeId> {
2575 (self.child.get() == Some(descendant)).then(|| widget_id_to_node_id(descendant))
2578 }
2579 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2580 builder.set_role(accesskit::Role::Pane);
2581 let group =
2582 builder.push_scene_child_under(None, 7, SyntheticKind::SceneGroup, |c| {
2583 c.set_role(accesskit::Role::GenericContainer);
2584 c.set_name("Group".to_string());
2585 });
2586 if let Some(child) = self.child.get() {
2587 builder.attach_scene_child_under(group, widget_id_to_node_id(child));
2588 }
2589 }
2590 }
2591
2592 let mut tree = WidgetTree::new();
2593 let grafter = tree.add(Grafter {
2594 child: std::cell::Cell::new(None),
2595 });
2596 tree.layout(SizeProposal::exact(200.0, 200.0));
2597 let child = tree.children(grafter)[0];
2598
2599 let update = tree.sync_accessibility();
2601 let child_nid = widget_id_to_node_id(child);
2602 assert!(
2603 update.nodes.iter().any(|(id, _)| *id == child_nid),
2604 "precondition: the grafted child is in the tree"
2605 );
2606 assert!(
2607 update
2608 .nodes
2609 .iter()
2610 .any(|(_, n)| n.children().contains(&child_nid)),
2611 "precondition: something names it as a child"
2612 );
2613 assert_a11y_tree_valid(&update);
2614
2615 tree.set_dormant(child);
2619 let update = tree.sync_accessibility();
2620 assert!(
2621 !update.nodes.iter().any(|(id, _)| *id == child_nid),
2622 "a dormant widget emits no node"
2623 );
2624 let emitted: std::collections::HashSet<accesskit::NodeId> =
2625 update.nodes.iter().map(|(id, _)| *id).collect();
2626 for (parent, node) in &update.nodes {
2627 for c in node.children() {
2628 assert!(
2629 emitted.contains(c),
2630 "node {parent:?} names child {c:?}, absent from the tree"
2631 );
2632 }
2633 }
2634 assert_a11y_tree_valid(&update);
2637 }
2638
2639 #[test]
2640 fn a_labelled_by_target_that_went_dormant_is_stripped() {
2641 let mut tree = WidgetTree::new();
2647 let title = tree.add(FillWidget::new().label("Preferences"));
2648 let dialog = tree.add(FillWidget::new().access_labelled_by(title));
2649 tree.layout(SizeProposal::exact(200.0, 100.0));
2650
2651 let update = tree.sync_accessibility();
2652 assert!(
2653 !find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2654 "the relation is live while the target is in the tree"
2655 );
2656
2657 tree.set_dormant(title);
2658 let update = tree.sync_accessibility();
2659 assert!(
2660 find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2661 "a target absent from the tree must not survive in the relation"
2662 );
2663 assert_no_dangling_relationships(&update);
2664 }
2665
2666 #[test]
2667 fn re_registering_a_labelled_by_relation_does_not_duplicate_it() {
2668 let mut tree = WidgetTree::new();
2672 let title = tree.add(FillWidget::new().label("Name"));
2673 let field = tree.add(FillWidget::new());
2674 tree.layout(SizeProposal::exact(200.0, 100.0));
2675
2676 for _ in 0..3 {
2677 tree.push_access_labelled_by(field, title);
2678 }
2679 let update = tree.sync_accessibility();
2680 assert_eq!(
2681 find_node(&update, field).unwrap().labelled_by().len(),
2682 1,
2683 "the same target registered repeatedly is one relation"
2684 );
2685 }
2686
2687 #[test]
2688 fn re_registering_a_described_by_relation_does_not_duplicate_it() {
2689 let mut tree = WidgetTree::new();
2690 let hint = tree.add(FillWidget::new().label("Must be unique"));
2691 let field = tree.add(FillWidget::new());
2692 tree.layout(SizeProposal::exact(200.0, 100.0));
2693
2694 for _ in 0..3 {
2695 tree.push_access_described_by(field, hint);
2696 }
2697 let update = tree.sync_accessibility();
2698 assert_eq!(
2699 find_node(&update, field).unwrap().described_by().len(),
2700 1,
2701 "the same target registered repeatedly is one relation"
2702 );
2703 }
2704
2705 #[test]
2707 fn access_live_assertive_set() {
2708 let mut tree = WidgetTree::new();
2709 let id = tree.add(FillWidget::new().access_live(Live::Assertive));
2710 tree.layout(SizeProposal::exact(50.0, 50.0));
2711 let update = tree.sync_accessibility();
2712 let node = find_node(&update, id).unwrap();
2713 assert_eq!(node.live(), Some(Live::Assertive));
2714 }
2715
2716 #[test]
2718 fn access_aria_current_set() {
2719 let mut tree = WidgetTree::new();
2720 let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
2721 tree.layout(SizeProposal::exact(50.0, 50.0));
2722 let update = tree.sync_accessibility();
2723 let node = find_node(&update, id).unwrap();
2724 assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
2725 }
2726
2727 #[test]
2729 fn access_has_popup_set() {
2730 let mut tree = WidgetTree::new();
2731 let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
2732 tree.layout(SizeProposal::exact(50.0, 50.0));
2733 let update = tree.sync_accessibility();
2734 let node = find_node(&update, id).unwrap();
2735 assert_eq!(node.has_popup(), Some(HasPopup::Menu));
2736 }
2737
2738 #[test]
2740 fn access_orientation_set() {
2741 let mut tree = WidgetTree::new();
2742 let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
2743 tree.layout(SizeProposal::exact(50.0, 50.0));
2744 let update = tree.sync_accessibility();
2745 let node = find_node(&update, id).unwrap();
2746 assert_eq!(node.orientation(), Some(Orientation::Vertical));
2747 }
2748
2749 #[test]
2751 fn access_numeric_value_and_range() {
2752 let mut tree = WidgetTree::new();
2753 let id = tree.add(
2754 FillWidget::new()
2755 .access_role(Role::Slider)
2756 .access_numeric_value(50.0)
2757 .access_numeric_range(0.0, 100.0)
2758 .access_numeric_step(5.0),
2759 );
2760 tree.layout(SizeProposal::exact(50.0, 50.0));
2761 let update = tree.sync_accessibility();
2762 let node = find_node(&update, id).unwrap();
2763 assert_eq!(node.numeric_value(), Some(50.0));
2764 assert_eq!(node.min_numeric_value(), Some(0.0));
2765 assert_eq!(node.max_numeric_value(), Some(100.0));
2766 assert_eq!(node.numeric_value_step(), Some(5.0));
2767 }
2768
2769 #[test]
2771 fn access_action_advertises_and_routes() {
2772 use crate::signal::Signal;
2773 let flag = Signal::new(false);
2774 let flag_for_cb = flag.clone();
2775 let mut tree = WidgetTree::new();
2776 let id = tree.add(
2777 FillWidget::new()
2778 .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
2779 );
2780 tree.layout(SizeProposal::exact(50.0, 50.0));
2781 let info = tree.accessibility_node(id);
2782 assert!(info.actions().contains(&Action::ShowContextMenu));
2783 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2784 action: Action::ShowContextMenu,
2785 target: Some(id),
2786 target_node: crate::accessibility::widget_id_to_node_id(id),
2787 data: None,
2788 });
2789 assert!(flag.get(), "callback should have been invoked");
2790 }
2791
2792 #[test]
2794 fn access_two_actions_both_route() {
2795 use crate::signal::Signal;
2796 let click = Signal::new(false);
2797 let increment = Signal::new(false);
2798 let click_cb = click.clone();
2799 let inc_cb = increment.clone();
2800 let mut tree = WidgetTree::new();
2801 let id = tree.add(
2802 FillWidget::new()
2803 .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
2804 .access_action(Action::Increment, move |_| inc_cb.set(true)),
2805 );
2806 tree.layout(SizeProposal::exact(50.0, 50.0));
2807 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2808 action: Action::ShowContextMenu,
2809 target: Some(id),
2810 target_node: crate::accessibility::widget_id_to_node_id(id),
2811 data: None,
2812 });
2813 assert!(click.get() && !increment.get(), "only first action fired");
2814 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2815 action: Action::Increment,
2816 target: Some(id),
2817 target_node: crate::accessibility::widget_id_to_node_id(id),
2818 data: None,
2819 });
2820 assert!(
2821 click.get() && increment.get(),
2822 "both actions fired exactly once each"
2823 );
2824 }
2825
2826 #[test]
2828 fn access_remove_action_suppresses_widget_action() {
2829 let mut tree = WidgetTree::new();
2830 let id = tree.add(ActionWidget.access_remove_action(Action::Click));
2831 tree.layout(SizeProposal::exact(50.0, 50.0));
2832 let info = tree.accessibility_node(id);
2833 assert!(!info.actions().contains(&Action::Click));
2834 assert!(info.actions().contains(&Action::Focus));
2835 }
2836
2837 #[test]
2839 fn access_custom_action_uses_localized_label() {
2840 let mut tree = WidgetTree::new();
2841 let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
2842 tree.layout(SizeProposal::exact(50.0, 50.0));
2843 let update = tree.sync_accessibility();
2844 let node = find_node(&update, id).unwrap();
2845 let actions = node.custom_actions();
2846 assert_eq!(actions.len(), 1);
2847 assert_eq!(actions[0].id, 0);
2848 assert_eq!(actions[0].description.as_str(), "Reply");
2849 }
2850
2851 #[test]
2853 fn access_custom_action_routes_by_index() {
2854 use crate::signal::Signal;
2855 let first = Signal::new(false);
2856 let second = Signal::new(false);
2857 let f = first.clone();
2858 let s = second.clone();
2859 let mut tree = WidgetTree::new();
2860 let id = tree.add(
2861 FillWidget::new()
2862 .access_custom_action_literal("First", move |_| f.set(true))
2863 .access_custom_action_literal("Second", move |_| s.set(true)),
2864 );
2865 tree.layout(SizeProposal::exact(50.0, 50.0));
2866 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2867 action: Action::CustomAction,
2868 target: Some(id),
2869 target_node: crate::accessibility::widget_id_to_node_id(id),
2870 data: Some(accesskit::ActionData::CustomAction(1)),
2871 });
2872 assert!(!first.get(), "first should not fire");
2873 assert!(second.get(), "second should fire (index 1)");
2874 }
2875
2876 #[derive(Debug)]
2879 struct OwnCustomActionWidget;
2880
2881 const OWN_CUSTOM_ACTION_ID: i32 = 1000;
2882
2883 impl Widget for OwnCustomActionWidget {
2884 fn layout_response(
2885 &self,
2886 proposal: SizeProposal,
2887 _ctx: &LayoutContext,
2888 ) -> crate::widget::LayoutResponse {
2889 proposal.resolve(0.0, 0.0).into()
2890 }
2891
2892 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2893 builder.set_role(accesskit::Role::Slider);
2894 builder.set_custom_actions(vec![accesskit::CustomAction {
2895 id: OWN_CUSTOM_ACTION_ID,
2896 description: "Reset to default".to_owned(),
2897 }]);
2898 }
2899 }
2900
2901 #[test]
2903 fn access_custom_action_appends_to_the_widgets_own() {
2904 use crate::signal::Signal;
2905 let own = Signal::new(0_u32);
2906 let added = Signal::new(0_u32);
2907 let own_sink = own.clone();
2908 let added_sink = added.clone();
2909 let mut tree = WidgetTree::new();
2910 let id = tree.add(
2911 OwnCustomActionWidget
2912 .access_custom_action_literal("Pin", move |_| added_sink.set(added_sink.get() + 1))
2913 .on_access_action_request(move |action, _node, data, _ctx| {
2914 if action == Action::CustomAction
2915 && data == Some(accesskit::ActionData::CustomAction(OWN_CUSTOM_ACTION_ID))
2916 {
2917 own_sink.set(own_sink.get() + 1);
2918 return crate::event::EventResponse::Handled;
2919 }
2920 crate::event::EventResponse::Ignored
2921 }),
2922 );
2923 tree.layout(SizeProposal::exact(50.0, 50.0));
2924 let update = tree.sync_accessibility();
2925 let node = find_node(&update, id).unwrap();
2926 let offered: Vec<(i32, &str)> = node
2927 .custom_actions()
2928 .iter()
2929 .map(|a| (a.id, a.description.as_str()))
2930 .collect();
2931 assert_eq!(
2932 offered,
2933 vec![(OWN_CUSTOM_ACTION_ID, "Reset to default"), (0, "Pin")],
2934 "the application's action joins the widget's rather than replacing it",
2935 );
2936 assert!(node.supports_action(Action::CustomAction));
2937
2938 let invoke = |tree: &mut WidgetTree, custom: i32| {
2939 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2940 action: Action::CustomAction,
2941 target: Some(id),
2942 target_node: crate::accessibility::widget_id_to_node_id(id),
2943 data: Some(accesskit::ActionData::CustomAction(custom)),
2944 });
2945 };
2946 invoke(&mut tree, 0);
2947 assert_eq!(
2948 (own.get(), added.get()),
2949 (0, 1),
2950 "index 0 is the override's"
2951 );
2952 invoke(&mut tree, OWN_CUSTOM_ACTION_ID);
2953 assert_eq!(
2954 (own.get(), added.get()),
2955 (1, 1),
2956 "and the widget's id is still the widget's"
2957 );
2958 }
2959
2960 #[test]
2962 fn access_action_layered_with_on_access_action() {
2963 use crate::signal::Signal;
2964 let from_override = Signal::new(false);
2965 let from_user = Signal::new(false);
2966 let ov_cb = from_override.clone();
2967 let user_cb = from_user.clone();
2968 let mut tree = WidgetTree::new();
2969 let id = tree.add(
2970 FillWidget::new()
2971 .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
2972 .on_access_action(move |_action, _ctx| {
2973 user_cb.set(true);
2974 crate::event::EventResponse::Handled
2975 }),
2976 );
2977 tree.layout(SizeProposal::exact(50.0, 50.0));
2978 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2979 action: Action::ShowContextMenu,
2980 target: Some(id),
2981 target_node: crate::accessibility::widget_id_to_node_id(id),
2982 data: None,
2983 });
2984 assert!(from_override.get(), "override callback fired");
2985 assert!(from_user.get(), "user catch-all fired");
2986 }
2987
2988 #[test]
2994 fn access_label_accepts_resolved_string_via_into() {
2995 struct ResolvedAtCall(String);
2999 impl From<ResolvedAtCall> for crate::signal::Prop<String> {
3000 fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
3001 crate::signal::Prop::Static(v.0)
3002 }
3003 }
3004 let mut tree = WidgetTree::new();
3005 let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
3006 tree.layout(SizeProposal::exact(50.0, 50.0));
3007 assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
3008 }
3009
3010 #[test]
3012 fn access_exclude_subtree_prunes_children_from_at_tree() {
3013 let mut tree = WidgetTree::new();
3014 let inner1 = tree.add(FillWidget::new().label("A"));
3015 let inner2 = tree.add(FillWidget::new().label("B"));
3016 let outer = tree.add(
3017 StackWidget::new()
3018 .child(inner1)
3019 .child(inner2)
3020 .access_label_literal("Section")
3024 .access_exclude_subtree(),
3025 );
3026 tree.layout(SizeProposal::exact(100.0, 50.0));
3027 let update = tree.sync_accessibility();
3028 assert!(find_node(&update, outer).is_some());
3030 assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
3031 assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
3032 let outer_node = find_node(&update, outer).unwrap();
3033 assert!(
3034 outer_node.children().is_empty(),
3035 "outer should have no AT children when excluded"
3036 );
3037 }
3038
3039 #[test]
3041 fn access_merge_subtree_concatenates_descendant_labels() {
3042 let mut tree = WidgetTree::new();
3043 let title = tree.add(FillWidget::new().label("Title"));
3044 let subtitle = tree.add(FillWidget::new().label("Subtitle"));
3045 let card = tree.add(
3046 StackWidget::new()
3047 .child(title)
3048 .child(subtitle)
3049 .access_merge_subtree(),
3050 );
3051 tree.layout(SizeProposal::exact(100.0, 50.0));
3052 assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
3054 let update = tree.sync_accessibility();
3055 assert!(find_node(&update, title).is_none());
3056 assert!(find_node(&update, subtitle).is_none());
3057 }
3058
3059 #[test]
3060 fn access_merge_subtree_takes_nothing_from_under_a_hidden_descendant() {
3061 let mut tree = WidgetTree::new();
3066 let title = tree.add(FillWidget::new().label("Title"));
3067 let secret = tree.add(FillWidget::new().label("Secret"));
3068 let hidden = tree.add(StackWidget::new().child(secret).access_hidden(true));
3069 let card = tree.add(
3070 StackWidget::new()
3071 .child(title)
3072 .child(hidden)
3073 .access_merge_subtree(),
3074 );
3075 tree.layout(SizeProposal::exact(100.0, 50.0));
3076 assert_eq!(tree.text_content(card), Some("Title".to_string()));
3077 }
3078
3079 #[test]
3081 fn access_merge_subtree_unions_actions() {
3082 let mut tree = WidgetTree::new();
3083 let click_a = tree.add(ClickableWidget);
3084 let click_b = tree.add(ClickableWidget);
3085 let card = tree.add(
3086 StackWidget::new()
3087 .child(click_a)
3088 .child(click_b)
3089 .access_merge_subtree(),
3090 );
3091 tree.layout(SizeProposal::exact(100.0, 50.0));
3092 let actions = tree.accessibility_node(card).actions().to_vec();
3093 let click_count = actions.iter().filter(|a| **a == Action::Click).count();
3094 assert_eq!(
3095 click_count, 1,
3096 "Click should be present exactly once after merge (deduplicated)"
3097 );
3098 }
3099
3100 #[test]
3110 fn access_merge_subtree_first_nonempty_value_wins() {
3111 #[derive(Debug)]
3112 struct ValueWidget(&'static str);
3113 impl Widget for ValueWidget {
3114 fn layout_response(
3115 &self,
3116 proposal: SizeProposal,
3117 _ctx: &LayoutContext,
3118 ) -> crate::widget::LayoutResponse {
3119 proposal.resolve(0.0, 0.0).into()
3120 }
3121 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3122 builder.set_role(Role::Slider);
3123 builder.set_value(self.0);
3124 }
3125 }
3126 let mut tree = WidgetTree::new();
3127 let v1 = tree.add(ValueWidget("first"));
3128 let v2 = tree.add(ValueWidget("second"));
3129 let card = tree.add(
3130 StackWidget::new()
3131 .child(v1)
3132 .child(v2)
3133 .access_merge_subtree(),
3134 );
3135 tree.layout(SizeProposal::exact(100.0, 50.0));
3136 assert_eq!(tree.text_value(card), Some("first".to_string()));
3137 }
3138
3139 #[test]
3141 fn access_exclude_inside_merge() {
3142 let mut tree = WidgetTree::new();
3143 let visible = tree.add(FillWidget::new().label("VISIBLE"));
3144 let pruned = tree.add(FillWidget::new().label("PRUNED"));
3145 let inner_excluded = tree.add(StackWidget::new().child(pruned).access_exclude_subtree());
3146 let card = tree.add(
3147 StackWidget::new()
3148 .child(visible)
3149 .child(inner_excluded)
3150 .access_merge_subtree(),
3151 );
3152 tree.layout(SizeProposal::exact(100.0, 50.0));
3153 let merged = tree.text_content(card).unwrap_or_default();
3154 assert!(merged.contains("VISIBLE"));
3157 assert!(
3158 !merged.contains("PRUNED"),
3159 "excluded subtree should not contribute to merge"
3160 );
3161 }
3162
3163 #[test]
3165 fn access_merge_inside_merge() {
3166 let mut tree = WidgetTree::new();
3167 let inner_a = tree.add(FillWidget::new().label("a"));
3168 let inner_b = tree.add(FillWidget::new().label("b"));
3169 let inner_card = tree.add(
3170 StackWidget::new()
3171 .child(inner_a)
3172 .child(inner_b)
3173 .access_merge_subtree(),
3174 );
3175 let outer_extra = tree.add(FillWidget::new().label("X"));
3176 let outer = tree.add(
3177 StackWidget::new()
3178 .child(inner_card)
3179 .child(outer_extra)
3180 .access_merge_subtree(),
3181 );
3182 tree.layout(SizeProposal::exact(200.0, 100.0));
3183 let merged = tree.text_content(outer).unwrap_or_default();
3188 assert!(merged.contains("a"));
3189 assert!(merged.contains("b"));
3190 assert!(merged.contains("X"));
3191 let update = tree.sync_accessibility();
3193 assert!(find_node(&update, inner_a).is_none());
3194 assert!(find_node(&update, inner_b).is_none());
3195 assert!(find_node(&update, inner_card).is_none());
3196 assert!(find_node(&update, outer_extra).is_none());
3197 }
3198
3199 #[test]
3201 fn access_customize_runs_last() {
3202 let mut tree = WidgetTree::new();
3203 let id = tree.add(
3204 FillWidget::new()
3205 .access_label_literal("A")
3206 .access_customize(|b| b.set_name("B")),
3207 );
3208 tree.layout(SizeProposal::exact(50.0, 50.0));
3209 assert_eq!(tree.accessibility_node(id).name(), Some("B"));
3210 }
3211
3212 #[test]
3214 fn access_customize_can_reach_inner_mut() {
3215 let mut tree = WidgetTree::new();
3216 let id = tree.add(FillWidget::new().access_customize(|b| {
3217 b.inner_mut().set_author_id("from-customize");
3218 }));
3219 tree.layout(SizeProposal::exact(50.0, 50.0));
3220 let update = tree.sync_accessibility();
3221 let node = find_node(&update, id).unwrap();
3222 assert_eq!(node.author_id(), Some("from-customize"));
3223 }
3224
3225 #[test]
3227 fn access_overrides_zero_cost_when_unused() {
3228 use std::mem::size_of;
3235 assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
3236 assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
3237 }
3238
3239 #[test]
3240 fn accessibility_children_overrides_at_reading_order() {
3241 #[derive(Debug)]
3246 struct NamedLeaf(&'static str);
3247 impl Widget for NamedLeaf {
3248 fn layout_response(
3249 &self,
3250 proposal: SizeProposal,
3251 _ctx: &LayoutContext,
3252 ) -> crate::widget::LayoutResponse {
3253 proposal.resolve(10.0, 10.0).into()
3254 }
3255 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3256 builder.set_role(accesskit::Role::Button);
3260 builder.set_name(self.0);
3261 }
3262 }
3263
3264 #[derive(Debug, Default)]
3265 struct ReorderContainer {
3266 a: Option<WidgetId>,
3267 b: Option<WidgetId>,
3268 }
3269 impl Widget for ReorderContainer {
3270 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
3271 let a = ctx.add(NamedLeaf("A"));
3272 let b = ctx.add(NamedLeaf("B"));
3273 self.a = Some(a);
3274 self.b = Some(b);
3275 vec![a, b]
3276 }
3277 fn layout_response(
3278 &self,
3279 proposal: SizeProposal,
3280 _ctx: &LayoutContext,
3281 ) -> crate::widget::LayoutResponse {
3282 proposal.resolve(100.0, 100.0).into()
3283 }
3284 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3285 builder.set_role(accesskit::Role::Group);
3288 builder.set_name("Container");
3289 }
3290 fn children(&self) -> Vec<WidgetId> {
3291 [self.a, self.b].into_iter().flatten().collect()
3292 }
3293 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
3294 Some([self.b, self.a].into_iter().flatten().collect())
3296 }
3297 }
3298
3299 let mut tree = WidgetTree::new();
3300 let container = tree.add(ReorderContainer::default());
3301 tree.layout(SizeProposal::exact(100.0, 100.0));
3302 let update = tree.sync_accessibility();
3303
3304 let a_nid = update
3305 .nodes
3306 .iter()
3307 .find(|(_, n)| n.label() == Some("A"))
3308 .map(|(id, _)| *id)
3309 .expect("A node present");
3310 let b_nid = update
3311 .nodes
3312 .iter()
3313 .find(|(_, n)| n.label() == Some("B"))
3314 .map(|(id, _)| *id)
3315 .expect("B node present");
3316 let cnid = crate::accessibility::widget_id_to_node_id(container);
3317 let cnode = update
3318 .nodes
3319 .iter()
3320 .find(|(id, _)| *id == cnid)
3321 .map(|(_, n)| n)
3322 .expect("container node present");
3323 let kids = cnode.children();
3324 let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
3325 let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
3326 assert!(
3327 pb < pa,
3328 "accessibility_children() must set AT order B-before-A; got {kids:?}"
3329 );
3330 }
3331
3332 fn consumer_bounding_box(update: &accesskit::TreeUpdate, label: &str) -> accesskit::Rect {
3346 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
3347 let state = consumer.state();
3348 let mut stack = vec![state.root()];
3349 while let Some(node) = stack.pop() {
3350 let text = node.value().or_else(|| node.label());
3353 if text.as_deref() == Some(label) {
3354 return node.bounding_box().expect("node has bounds");
3355 }
3356 for child in node.children() {
3357 stack.push(child);
3358 }
3359 }
3360 panic!("no node labelled {label:?} in the consumer tree");
3361 }
3362
3363 fn tree_at_scale(scale: f32) -> (WidgetTree, WidgetId) {
3364 let mut tree = WidgetTree::new();
3365 let id = tree.add(FillWidget::new().label("content"));
3366 tree.set_device_scale_factor(scale);
3367 tree.layout(SizeProposal::exact(200.0, 100.0));
3368 (tree, id)
3369 }
3370
3371 #[test]
3372 fn the_root_carries_the_device_scale_as_its_transform() {
3373 let (mut tree, _) = tree_at_scale(2.0);
3374 let update = tree.sync_accessibility();
3375 let root = update
3376 .nodes
3377 .iter()
3378 .find(|(id, _)| *id == crate::accessibility::root_node_id())
3379 .map(|(_, n)| n)
3380 .expect("root node present");
3381 assert_eq!(
3382 root.transform().copied(),
3383 Some(accesskit::Affine::scale(2.0)),
3384 "AccessKit wants physical coordinates; the tree emits logical ones"
3385 );
3386 assert_a11y_tree_valid(&update);
3387 }
3388
3389 #[test]
3390 fn emitted_bounds_stay_logical_at_scale_two() {
3391 let (mut tree, id) = tree_at_scale(2.0);
3400 let update = tree.sync_accessibility();
3401 let layout = tree.bounds(id);
3402 let nid = crate::accessibility::widget_id_to_node_id(id);
3403 let raw = update
3404 .nodes
3405 .iter()
3406 .find(|(n, _)| *n == nid)
3407 .and_then(|(_, n)| n.bounds())
3408 .expect("widget node has bounds");
3409 assert_eq!(
3410 (raw.x0, raw.y0, raw.x1, raw.y1),
3411 (
3412 layout.x as f64,
3413 layout.y as f64,
3414 (layout.x + layout.width) as f64,
3415 (layout.y + layout.height) as f64,
3416 ),
3417 "an emitted rectangle must be the logical layout rectangle, unscaled"
3418 );
3419 }
3420
3421 #[test]
3440 fn an_interactive_node_emits_its_exact_layout_rectangle() {
3441 let mut tree = WidgetTree::new();
3442 let id = tree.add(ActionWidget);
3443 tree.layout(SizeProposal::exact(200.0, 100.0));
3444 let update = tree.sync_accessibility();
3445 let nid = crate::accessibility::widget_id_to_node_id(id);
3446 let node = update
3447 .nodes
3448 .iter()
3449 .find(|(n, _)| *n == nid)
3450 .map(|(_, n)| n)
3451 .expect("the interactive node is in the update");
3452 assert!(
3453 node.supports_action(accesskit::Action::Click),
3454 "the fixture has to be the clickable shape the rule is about, \
3455 or this test guards nothing"
3456 );
3457 let layout = tree.bounds(id);
3458 assert!(
3459 layout.width > 0.0 && layout.height > 0.0,
3460 "a real, non-degenerate rectangle to compare against: {layout:?}"
3461 );
3462 let raw = node.bounds().expect("the interactive node has bounds");
3463 assert_eq!(
3464 (raw.x0, raw.y0, raw.x1, raw.y1),
3465 (
3466 layout.x as f64,
3467 layout.y as f64,
3468 (layout.x + layout.width) as f64,
3469 (layout.y + layout.height) as f64,
3470 ),
3471 "a clickable node's emitted rectangle is its layout rectangle, \
3472 not an outset one"
3473 );
3474 }
3475
3476 #[test]
3477 fn what_an_assistive_technology_reads_is_the_physical_rectangle() {
3478 let (mut one, _) = tree_at_scale(1.0);
3482 let at_one = consumer_bounding_box(&one.sync_accessibility(), "content");
3483 let (mut two, _) = tree_at_scale(2.0);
3484 let at_two = consumer_bounding_box(&two.sync_accessibility(), "content");
3485 assert_eq!(at_two.x0, at_one.x0 * 2.0);
3486 assert_eq!(at_two.y0, at_one.y0 * 2.0);
3487 assert_eq!(at_two.x1, at_one.x1 * 2.0);
3488 assert_eq!(at_two.y1, at_one.y1 * 2.0);
3489 assert!(at_two.x1 > at_two.x0, "a real, non-degenerate rectangle");
3490 }
3491
3492 #[test]
3493 fn a_scale_change_invalidates_the_accessibility_cache() {
3494 let (mut tree, _) = tree_at_scale(1.0);
3500 let _ = tree.sync_accessibility();
3501 tree.set_device_scale_factor(2.0);
3502 let update = tree.sync_accessibility();
3503 let root = update
3504 .nodes
3505 .iter()
3506 .find(|(id, _)| *id == crate::accessibility::root_node_id())
3507 .map(|(_, n)| n)
3508 .expect("root node present");
3509 assert_eq!(
3510 root.transform().copied(),
3511 Some(accesskit::Affine::scale(2.0))
3512 );
3513 }
3514
3515 #[test]
3516 fn setting_the_same_scale_does_not_dirty_the_tree() {
3517 let (mut tree, _) = tree_at_scale(2.0);
3521 let first = tree.sync_accessibility();
3522 tree.set_device_scale_factor(2.0);
3523 let second = tree.sync_accessibility();
3524 assert_eq!(first, second);
3525 }
3526
3527 #[test]
3530 fn explore_by_touch_is_off_by_default() {
3531 let tree = WidgetTree::new();
3532 assert_eq!(tree.explore_by_touch(), crate::ExploreByTouch::Off);
3533 assert_eq!(
3534 tree.screen_reader_state(),
3535 crate::ScreenReaderState::Unknown
3536 );
3537 assert!(!tree.explore_by_touch_active());
3538 }
3539
3540 #[test]
3541 fn auto_follows_the_operating_systems_screen_reader_flag() {
3542 let mut tree = WidgetTree::new();
3543 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3544 assert!(!tree.explore_by_touch_active(), "Unknown is not a yes");
3545 tree.set_screen_reader_state(crate::ScreenReaderState::Inactive);
3546 assert!(!tree.explore_by_touch_active());
3547 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3548 assert!(tree.explore_by_touch_active());
3549 }
3550
3551 #[test]
3552 fn on_ignores_the_screen_reader_flag_and_off_ignores_everything() {
3553 let mut tree = WidgetTree::new();
3554 tree.set_explore_by_touch(crate::ExploreByTouch::On);
3555 assert!(tree.explore_by_touch_active(), "On is the app's own choice");
3556 tree.set_explore_by_touch(crate::ExploreByTouch::Off);
3557 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3558 assert!(!tree.explore_by_touch_active());
3559 }
3560
3561 #[test]
3562 fn an_attached_accesskit_client_does_not_switch_exploring_on() {
3563 let mut tree = WidgetTree::new();
3569 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3570 tree.set_at_client_attached(true);
3571 assert!(tree.at_client_attached());
3572 assert_eq!(
3573 tree.screen_reader_state(),
3574 crate::ScreenReaderState::Unknown,
3575 "attaching says nothing about screen readers"
3576 );
3577 assert!(!tree.explore_by_touch_active());
3578 }
3579
3580 #[test]
3581 fn touch_still_reaches_a_handler_with_an_accesskit_client_attached() {
3582 use crate::Modifiers;
3588 use crate::pointer::{
3589 BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase,
3590 PointerSample,
3591 };
3592 use crate::widget_builder::WidgetBuilder;
3593 use std::cell::Cell;
3594 use std::rc::Rc;
3595
3596 let taps = Rc::new(Cell::new(0u32));
3597 let seen = Rc::clone(&taps);
3598 let mut tree = WidgetTree::new();
3599 let id = tree.add(
3600 FillWidget::new()
3601 .label("target")
3602 .on_tap(move |_e, _ctx| seen.set(seen.get() + 1)),
3603 );
3604 tree.layout(SizeProposal::exact(200.0, 100.0));
3605 tree.set_at_client_attached(true);
3606 assert!(!tree.explore_by_touch_active());
3607
3608 let at = Point::new(50.0, 50.0);
3609 let pid = PointerIdAllocator::global().begin(BackendDeviceKey::new(0x0A11), 1);
3610 let contact = |phase| PointerSample {
3611 pointer: PointerInfo::touch(pid, EventTime::ZERO),
3612 phase,
3613 position: at,
3614 button: None,
3615 modifiers: Modifiers::NONE,
3616 coalesced: Vec::new(),
3617 };
3618 tree.dispatch_pointer(contact(PointerPhase::Down));
3619 tree.dispatch_pointer(contact(PointerPhase::Up));
3620 assert!(tree.bounds(id).width > 0.0);
3621 assert_eq!(taps.get(), 1, "a touch tap must still activate its handler");
3622 }
3623
3624 #[test]
3625 fn a_detaching_client_is_evidence_that_no_screen_reader_is_reading() {
3626 let mut tree = WidgetTree::new();
3630 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3631 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3632 tree.set_at_client_attached(true);
3633 assert!(tree.explore_by_touch_active());
3634 tree.set_at_client_attached(false);
3635 assert_eq!(
3636 tree.screen_reader_state(),
3637 crate::ScreenReaderState::Inactive
3638 );
3639 assert!(!tree.explore_by_touch_active());
3640 }
3641
3642 #[test]
3643 fn a_repeated_detach_notice_does_not_clobber_a_fresh_os_reading() {
3644 let mut tree = WidgetTree::new();
3648 tree.set_at_client_attached(false);
3649 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3650 tree.set_at_client_attached(false);
3651 assert_eq!(tree.screen_reader_state(), crate::ScreenReaderState::Active);
3652 }
3653
3654 #[test]
3657 fn a_density_switch_announces_once_in_the_applications_own_words() {
3658 use teksilo_tokens::TargetDensity;
3659 let mut tree = tree_with_one_widget();
3660 tree.set_density_announcement(Some(std::rc::Rc::new(|d: TargetDensity| {
3661 format!("layout {d:?}")
3662 })));
3663 let _ = tree.sync_accessibility();
3664
3665 let mut arrivals = Arrivals::attach(tree.accessibility_tree_snapshot());
3666
3667 tree.set_input_density(TargetDensity::Comfortable);
3668 let update = tree.sync_accessibility();
3669 assert_eq!(
3670 arrivals.feed(update),
3671 vec![("layout Comfortable".to_string(), accesskit::Live::Polite)]
3672 );
3673
3674 let _ = arrivals.feed(tree.sync_accessibility());
3676 tree.set_input_density(TargetDensity::Comfortable);
3677 let update = tree.sync_accessibility();
3678 assert_eq!(arrivals.feed(update), Vec::new());
3679 }
3680
3681 #[test]
3682 fn a_density_switch_is_silent_without_a_registered_wording() {
3683 use teksilo_tokens::TargetDensity;
3688 let mut tree = tree_with_one_widget();
3689 let _ = tree.sync_accessibility();
3690 tree.set_input_density(TargetDensity::Comfortable);
3691 let update = tree.sync_accessibility();
3692 assert_eq!(announced_by_consumer(&update), Vec::new());
3693 }
3694
3695 #[test]
3696 fn an_announcement_stays_quiet_when_the_widget_already_speaks() {
3697 let mut tree = WidgetTree::new();
3698 use crate::widget_builder::WidgetBuilder;
3699 let widget = tree.add(
3700 FillWidget::new()
3701 .label("Saved")
3702 .access_live(accesskit::Live::Polite),
3703 );
3704 tree.layout(SizeProposal::exact(200.0, 100.0));
3705 let _ = tree.sync_accessibility();
3707
3708 assert!(
3709 !tree.announce_unless_widget_speaks(widget, "Saved"),
3710 "a widget with its own live region must not be doubled"
3711 );
3712 let update = tree.sync_accessibility();
3713 let live = live_nodes_in_filtered_tree(&update);
3717 assert_eq!(live.len(), 1, "exactly one voice, not two: {live:?}");
3718 }
3719
3720 #[test]
3721 fn an_announcement_goes_ahead_when_the_widget_is_silent() {
3722 let mut tree = WidgetTree::new();
3723 let widget = tree.add(FillWidget::new().label("Row"));
3724 tree.layout(SizeProposal::exact(200.0, 100.0));
3725 let _ = tree.sync_accessibility();
3726
3727 assert!(tree.announce_unless_widget_speaks(widget, "Moved to 3 of 12"));
3728 let update = tree.sync_accessibility();
3729 assert_eq!(
3730 announced_by_consumer(&update),
3731 vec![("Moved to 3 of 12".to_string(), accesskit::Live::Polite)]
3732 );
3733 }
3734
3735 #[test]
3736 fn a_tree_that_records_no_announcements_still_hears_the_widget_speak() {
3737 let mut tree = WidgetTree::new();
3742 tree.set_records_announcements(false);
3743 use crate::widget_builder::WidgetBuilder;
3744 let widget = tree.add(
3745 FillWidget::new()
3746 .label("Saved")
3747 .access_live(accesskit::Live::Polite),
3748 );
3749 tree.layout(SizeProposal::exact(200.0, 100.0));
3750 let _ = tree.sync_accessibility();
3751 assert!(!tree.announce_unless_widget_speaks(widget, "Saved"));
3752 }
3753
3754 #[test]
3755 fn a_live_region_no_platform_hears_does_not_count_as_speaking() {
3756 let mut tree = WidgetTree::new();
3761 use crate::widget_builder::WidgetBuilder;
3762 let widget = tree.add(
3763 FillWidget::new()
3764 .access_role(accesskit::Role::Status)
3765 .access_value("Saved")
3766 .access_live(accesskit::Live::Polite),
3767 );
3768 tree.layout(SizeProposal::exact(200.0, 100.0));
3769 let _ = tree.sync_accessibility();
3770 assert!(tree.announce_unless_widget_speaks(widget, "Saved"));
3771 }
3772
3773 #[test]
3774 fn a_hidden_live_region_does_not_count_as_speaking() {
3775 let mut tree = WidgetTree::new();
3777 use crate::widget_builder::WidgetBuilder;
3778 let widget = tree.add(
3779 FillWidget::new()
3780 .label("Saved")
3781 .access_live(accesskit::Live::Polite)
3782 .access_hidden(true),
3783 );
3784 tree.layout(SizeProposal::exact(200.0, 100.0));
3785 let _ = tree.sync_accessibility();
3786 assert!(tree.announce_unless_widget_speaks(widget, "Saved"));
3787 }
3788
3789 #[test]
3790 fn a_live_region_around_the_widget_does_not_count_as_the_widget_speaking() {
3791 let mut tree = WidgetTree::new();
3797 use crate::widget_builder::WidgetBuilder;
3798 let region = tree.add(
3799 StackWidget::new()
3800 .access_role(accesskit::Role::Group)
3801 .access_label("Notification")
3802 .access_live(accesskit::Live::Polite),
3803 );
3804 let button = tree.add_child(
3805 region,
3806 FillWidget::new()
3807 .label("Annuler")
3808 .access_role(accesskit::Role::Button),
3809 );
3810 tree.layout(SizeProposal::exact(200.0, 100.0));
3811 let _ = tree.sync_accessibility();
3812 assert!(tree.announce_unless_widget_speaks(button, "Menu des actions ouvert"));
3813 }
3814
3815 #[test]
3816 fn a_live_region_with_no_text_does_not_count_as_speaking() {
3817 let mut tree = WidgetTree::new();
3820 use crate::widget_builder::WidgetBuilder;
3821 let widget = tree.add(FillWidget::new().access_live(accesskit::Live::Polite));
3822 tree.layout(SizeProposal::exact(200.0, 100.0));
3823 let _ = tree.sync_accessibility();
3824 assert!(tree.announce_unless_widget_speaks(widget, "Something happened"));
3825 }
3826
3827 #[derive(Debug)]
3835 struct SpeakingSynthetic;
3836
3837 impl crate::widget::Widget for SpeakingSynthetic {
3838 fn layout_response(
3839 &self,
3840 proposal: SizeProposal,
3841 _ctx: &crate::widget::LayoutContext,
3842 ) -> crate::widget::LayoutResponse {
3843 proposal.resolve(0.0, 0.0).into()
3844 }
3845
3846 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3847 builder.set_role(accesskit::Role::Group);
3848 let child = builder.push_scene_child(
3849 1,
3850 crate::accessibility::SyntheticKind::SceneItem,
3851 |item| {
3852 item.set_role(accesskit::Role::Status);
3853 item.set_name("2 items selected");
3854 },
3855 );
3856 builder.with_collected_node(child, |node| node.set_live(accesskit::Live::Polite));
3857 }
3858 }
3859
3860 #[test]
3861 fn a_live_synthetic_child_counts_as_the_widget_speaking() {
3862 let mut tree = WidgetTree::new();
3867 let widget = tree.add(SpeakingSynthetic);
3868 tree.layout(SizeProposal::exact(200.0, 100.0));
3869 let update = tree.sync_accessibility();
3870 assert!(
3871 update
3872 .nodes
3873 .iter()
3874 .any(|(id, _)| crate::accessibility::is_synthetic(*id)),
3875 "the fixture must actually emit a synthetic child"
3876 );
3877 assert!(!tree.announce_unless_widget_speaks(widget, "2 items selected"));
3878 }
3879
3880 #[test]
3881 fn a_descendants_live_region_silences_its_ancestor_too() {
3882 let mut tree = WidgetTree::new();
3885 let container = tree.add(StackWidget::new());
3886 use crate::widget_builder::WidgetBuilder;
3887 let inner = FillWidget::new()
3888 .label("3 results")
3889 .access_live(accesskit::Live::Polite);
3890 tree.add_child(container, inner);
3891 tree.layout(SizeProposal::exact(200.0, 100.0));
3892 let _ = tree.sync_accessibility();
3893 assert!(!tree.announce_unless_widget_speaks(container, "3 results"));
3894 }
3895}