1use super::*;
5
6use crate::accessibility::{AccessNodeBuilder, AccessibilityInfo};
7
8pub(super) fn to_accesskit_rect(rect: teksilo_canvas::Rect) -> accesskit::Rect {
11 accesskit::Rect {
12 x0: rect.x as f64,
13 y0: rect.y as f64,
14 x1: (rect.x + rect.width) as f64,
15 y1: (rect.y + rect.height) as f64,
16 }
17}
18
19impl WidgetTree {
20 pub fn sync_accessibility(&mut self) -> accesskit::TreeUpdate {
31 if self.a11y_update_requested.replace(false) {
36 self.a11y_dirty = true;
37 }
38 let current_shortcut_version = self.shortcut_registry.version().get();
46 if current_shortcut_version != self.last_synced_shortcut_version {
47 self.a11y_dirty = true;
48 self.last_synced_shortcut_version = current_shortcut_version;
49 }
50
51 let current_locale = self.locale_signal.get();
60 if current_locale != self.last_synced_locale {
61 self.a11y_dirty = true;
62 self.last_synced_locale = current_locale;
63 }
64
65 let current_title = self.window_title_for_accessibility();
68 if current_title != self.last_synced_window_title {
69 self.a11y_dirty = true;
70 self.last_synced_window_title = current_title;
71 }
72
73 self.step_announcers();
84
85 if !self.a11y_dirty
86 && let Some(cached) = &self.cached_a11y
87 {
88 let _ = cached;
101 let moved = self.patch_accessibility_bounds();
102 if moved
112 && self.announcement_ring.is_recording()
113 && let Some(patched) = &self.cached_a11y
114 && patched.nodes.iter().any(|(id, node)| {
115 !crate::announcer::is_announcer_node(*id)
116 && node.live().is_some_and(|live| live != accesskit::Live::Off)
117 })
118 {
119 self.announcement_ring.observe(patched);
120 }
121 return self
122 .cached_a11y
123 .as_ref()
124 .expect("the cache was present a line ago")
125 .clone();
126 }
127
128 let (update, parents, local_bounds, descriptions, admitted) =
129 self.build_accessibility_tree(true);
130 let announcers_busy = self.settle_announcers(admitted);
131 self.announcement_ring.observe(&update);
136 let content_changed = self.cached_a11y.as_ref() != Some(&update);
143 self.cached_a11y = Some(update.clone());
144 if content_changed {
145 self.adapter_ids.source_changed();
146 }
147 self.synthetic_parent_map = parents;
148 self.synthetic_local_bounds = local_bounds;
149 let descriptions_catching_up = descriptions.catching_up;
156 self.description_memory = descriptions;
157 self.a11y_dirty = false;
158 self.arena.take_a11y_moved();
161 self.arena.take_a11y_resized();
162 self.a11y_walk_generation = self.a11y_walk_generation.saturating_add(1);
163 if content_changed {
164 self.at_version.set(self.at_version.get().saturating_add(1));
167 }
168 if announcers_busy || descriptions_catching_up {
169 self.request_accessibility_update();
170 self.request_frame();
171 }
172 update
173 }
174
175 pub fn deliver_accessibility(&mut self, window_focused: bool) -> accesskit::TreeUpdate {
197 if self.cached_a11y.is_none() {
198 let _ = self.sync_accessibility();
199 }
200 let arena = &self.arena;
201 let source = self
202 .cached_a11y
203 .as_ref()
204 .expect("sync_accessibility leaves an update behind");
205 self.adapter_ids.deliver(source, window_focused, |id| {
206 crate::accessibility::node_id_to_widget_id_maybe(id)
207 .is_none_or(|widget| arena.get(widget).is_some())
208 })
209 }
210
211 pub fn resolve_adapter_action(
216 &self,
217 request: accesskit::ActionRequest,
218 ) -> Option<accesskit::ActionRequest> {
219 self.adapter_ids.own_request(request)
220 }
221
222 pub fn a11y_walk_generation(&self) -> u64 {
228 self.a11y_walk_generation
229 }
230
231 fn patch_accessibility_bounds(&mut self) -> bool {
245 let moved = self.arena.take_a11y_moved();
246 if moved.is_empty() {
247 return false;
248 }
249 let Some(cached) = self.cached_a11y.as_mut() else {
250 return false;
251 };
252 let index: std::collections::HashMap<accesskit::NodeId, usize> = cached
253 .nodes
254 .iter()
255 .enumerate()
256 .map(|(i, (id, _))| (*id, i))
257 .collect();
258
259 for &id in moved.keys() {
260 let bounds = self.arena.bounds(id);
261 if let Some(&slot) = index.get(&crate::accessibility::widget_id_to_node_id(id)) {
262 cached.nodes[slot].1.set_bounds(to_accesskit_rect(bounds));
263 }
264 }
265
266 for (&syn_id, &owner) in &self.synthetic_parent_map {
271 let Some(delta) = moved.get(&owner) else {
272 continue;
273 };
274 let Some(&slot) = index.get(&syn_id) else {
275 continue;
276 };
277 let node = &mut cached.nodes[slot].1;
278 if node.transform().is_some() {
286 continue;
287 }
288 if let Some(local) = self.synthetic_local_bounds.get(&syn_id) {
289 let origin = self.arena.bounds(owner).origin();
290 node.set_bounds(to_accesskit_rect(teksilo_canvas::Rect::new(
291 origin.x + local.x,
292 origin.y + local.y,
293 local.width,
294 local.height,
295 )));
296 } else if let Some(rect) = node.bounds() {
297 node.set_bounds(accesskit::Rect {
298 x0: rect.x0 + delta.x as f64,
299 y0: rect.y0 + delta.y as f64,
300 x1: rect.x1 + delta.x as f64,
301 y1: rect.y1 + delta.y as f64,
302 });
303 }
304 }
305 self.adapter_ids.source_changed();
308 true
309 }
310
311 pub fn accessibility_tree_snapshot(&self) -> accesskit::TreeUpdate {
325 self.build_accessibility_tree(false).0
326 }
327
328 fn step_announcers(&mut self) {
331 let now = self.animation_clock();
335 let polite = self.announcer_polite.step(now, &mut self.announcer_ids);
336 let assertive = self.announcer_assertive.step(now, &mut self.announcer_ids);
337 if polite || assertive {
338 self.a11y_dirty = true;
339 }
340 self.wake_for_retirement();
341 }
342
343 fn settle_announcers(&mut self, admitted: bool) -> bool {
347 if admitted {
348 let now = self.animation_clock();
349 self.announcer_polite.admit(now);
350 self.announcer_assertive.admit(now);
351 self.wake_for_retirement();
352 }
353 self.announcer_polite.busy() || self.announcer_assertive.busy()
354 }
355
356 fn wake_for_retirement(&self) {
361 if !self.sim_time_frozen
362 && let Some(at) = [
363 self.announcer_polite.next_retirement(),
364 self.announcer_assertive.next_retirement(),
365 ]
366 .into_iter()
367 .flatten()
368 .min()
369 {
370 self.request_wake_at(at);
371 }
372 }
373
374 pub fn dispatch_access_action(
397 &mut self,
398 node_id: accesskit::NodeId,
399 action: accesskit::Action,
400 data: Option<accesskit::ActionData>,
401 ops: &mut dyn crate::window::WindowOps,
402 ) -> bool {
403 let target = crate::accessibility::node_id_to_widget_id_maybe(node_id)
404 .or_else(|| self.widget_for_synthetic(node_id));
405 let event = crate::event::WidgetEvent::AccessAction {
406 action,
407 target,
408 target_node: node_id,
409 data,
410 };
411 self.access_action_handled = false;
412 self.dispatch_event_with_ops(event, ops);
413 self.access_action_handled
414 }
415
416 pub fn widget_for_synthetic(&self, node_id: accesskit::NodeId) -> Option<WidgetId> {
425 self.synthetic_parent_map.get(&node_id).copied()
426 }
427
428 pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
453 self.arena
454 .get(content_id)
455 .and_then(|node| node.widget.as_any())
456 .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
457 .and_then(|d| d.materialized_child())
458 .unwrap_or(content_id)
459 }
460
461 pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
462 let content_id = self.tooltip_content_node(content_id);
463 let node = self.arena.get(content_id)?;
464 let mut probe = AccessNodeBuilder::for_name_probe(content_id);
465 node.widget.accessibility(&mut probe);
466 if let Some(ov) = node.access_overrides.as_deref() {
467 ov.apply(&mut probe);
468 }
469 probe
470 .name()
471 .map(str::trim)
472 .filter(|name| !name.is_empty())
473 .map(str::to_owned)
474 }
475
476 pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
477 let builder = self.build_overridden_builder(id);
479 let role = builder.role();
480 let name = builder.name().map(|s| s.to_string());
481 let actions = builder.actions().to_vec();
482 let mut info = AccessibilityInfo::new(role, name, actions);
483 if let Some(toggled) = builder.toggled() {
484 info = info.with_toggled(toggled);
485 }
486 if let Some(expanded) = builder.expanded() {
487 info = info.with_expanded(expanded);
488 }
489 if let Some(selected) = builder.selected() {
490 info = info.with_selected(selected);
491 }
492 let disabled = self.access_disabled_override(
497 id,
498 self.live_accessibility_proxy(id).is_some(),
499 &self.composites_standing_behind(id),
500 );
501 let force_clear_disabled = disabled == Some(false);
502 let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
503 let disabled_override = disabled == Some(true);
508 if disabled_arena || disabled_override {
509 info = info.with_disabled(true);
510 }
511 if builder.is_hidden() {
512 info = info.with_hidden(true);
513 }
514 info
515 }
516
517 pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
518 self.arena
519 .active_ids_iter()
520 .find(|&id| self.build_overridden_builder(id).role() == role)
521 }
522
523 pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
524 self.arena
525 .active_ids_iter()
526 .find(|&id| self.build_overridden_builder(id).name() == Some(label))
527 }
528
529 pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
530 self.arena.active_ids_iter().find(|&id| {
531 self.build_overridden_builder(id)
532 .actions()
533 .contains(&action)
534 })
535 }
536
537 pub fn text_content(&self, id: WidgetId) -> Option<String> {
541 self.arena.get(id)?;
542 self.build_overridden_builder(id)
543 .name()
544 .map(|s| s.to_string())
545 }
546
547 pub fn text_value(&self, id: WidgetId) -> Option<String> {
551 self.arena.get(id)?;
552 self.build_overridden_builder(id)
553 .value()
554 .map(|s| s.to_string())
555 }
556}
557
558pub(crate) fn emit_root_transform(root: &mut accesskit::Node, device_scale_factor: f32) {
587 root.set_transform(accesskit::Affine::scale(device_scale_factor as f64));
588}
589
590#[cfg(test)]
591pub(crate) mod test_helpers {
592 pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
598 accesskit_consumer::Tree::new(update.clone(), false);
599 }
600
601 pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
610 let emitted: std::collections::HashSet<accesskit::NodeId> =
611 update.nodes.iter().map(|(id, _)| *id).collect();
612 for (parent_id, node) in &update.nodes {
613 for &target in node.controls() {
614 assert!(
615 emitted.contains(&target),
616 "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
617 );
618 }
619 for &target in node.described_by() {
620 assert!(
621 emitted.contains(&target),
622 "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
623 );
624 }
625 for &target in node.labelled_by() {
626 assert!(
627 emitted.contains(&target),
628 "node {parent_id:?} has labelled_by() → {target:?} which is absent from the tree"
629 );
630 }
631 if let Some(target) = node.next_on_line() {
632 assert!(
633 emitted.contains(&target),
634 "node {parent_id:?} has next_on_line() → {target:?} which is absent from the tree"
635 );
636 }
637 if let Some(target) = node.previous_on_line() {
638 assert!(
639 emitted.contains(&target),
640 "node {parent_id:?} has previous_on_line() → {target:?} which is absent from the tree"
641 );
642 }
643 }
644 }
645
646 #[allow(dead_code)]
648 pub(crate) fn nodes_with_role(
649 update: &accesskit::TreeUpdate,
650 role: accesskit::Role,
651 ) -> Vec<accesskit::NodeId> {
652 update
653 .nodes
654 .iter()
655 .filter(|(_, node)| node.role() == role)
656 .map(|(id, _)| *id)
657 .collect()
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::test_helpers::*;
664 use super::*;
665 use crate::test_widgets::{FillWidget, StackWidget};
666
667 const ANNOUNCER_NODES: usize = 0;
672
673 struct Arrivals {
679 replay: accesskit_consumer::Tree,
680 }
681
682 impl Arrivals {
683 fn attach(update: accesskit::TreeUpdate) -> Self {
684 Self {
685 replay: accesskit_consumer::Tree::new(update, false),
686 }
687 }
688
689 fn feed(&mut self, update: accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
690 struct Handler(Vec<(String, accesskit::Live)>);
691 fn included(node: &accesskit_consumer::NodeRef) -> bool {
692 accesskit_consumer::common_filter(node) == accesskit_consumer::FilterResult::Include
693 }
694 impl Handler {
695 fn entered(&mut self, node: &accesskit_consumer::NodeRef) {
696 if node.live() != accesskit::Live::Off
697 && let Some(label) = node.label()
698 {
699 self.0.push((label, node.live()));
700 }
701 }
702 }
703 impl accesskit_consumer::TreeChangeHandler for Handler {
704 fn node_added(&mut self, node: &accesskit_consumer::NodeRef) {
705 if included(node) {
706 self.entered(node);
707 }
708 }
709 fn node_updated(
710 &mut self,
711 old: &accesskit_consumer::NodeRef,
712 new: &accesskit_consumer::NodeRef,
713 ) {
714 if !included(old) && included(new) {
715 self.entered(new);
716 }
717 }
718 fn focus_moved(
719 &mut self,
720 _old: Option<&accesskit_consumer::NodeRef>,
721 _new: Option<&accesskit_consumer::NodeRef>,
722 ) {
723 }
724 fn node_removed(&mut self, _node: &accesskit_consumer::NodeRef) {}
725 }
726 let mut handler = Handler(Vec::new());
727 self.replay.update_and_process_changes(update, &mut handler);
728 handler.0
729 }
730 }
731
732 fn announced_by_consumer(update: &accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
749 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
750 let state = consumer.state();
751 let mut found = Vec::new();
752 let mut stack = vec![state.root()];
753 while let Some(node) = stack.pop() {
754 if node.live() != accesskit::Live::Off
755 && let Some(label) = node.label()
756 {
757 found.push((label, node.live()));
758 }
759 for child in node.filtered_children(&accesskit_consumer::common_filter) {
760 stack.push(child);
761 }
762 }
763 found
764 }
765
766 fn live_nodes_in_filtered_tree(update: &accesskit::TreeUpdate) -> Vec<String> {
775 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
776 let state = consumer.state();
777 let mut found = Vec::new();
778 let mut stack = vec![state.root()];
779 while let Some(node) = stack.pop() {
780 if node.live() != accesskit::Live::Off {
781 found.push(format!("{:?}", node.id()));
782 }
783 for child in node.filtered_children(&accesskit_consumer::common_filter) {
784 stack.push(child);
785 }
786 }
787 found.sort();
788 found
789 }
790
791 fn tree_with_one_widget() -> WidgetTree {
792 let mut tree = WidgetTree::new();
793 tree.add(FillWidget::new().label("content"));
794 tree.layout(SizeProposal::exact(200.0, 100.0));
795 tree
796 }
797
798 #[test]
802 fn an_announcement_enters_the_filtered_tree_then_leaves_it() {
803 let mut tree = tree_with_one_widget();
804 tree.advance_time(std::time::Duration::ZERO);
805 let update = tree.sync_accessibility();
808 assert_eq!(announced_by_consumer(&update), Vec::new());
809 assert_eq!(
810 live_nodes_in_filtered_tree(&update),
811 Vec::<String>::new(),
812 "an idle announcer must put nothing in the filtered tree"
813 );
814 assert_a11y_tree_valid(&update);
815 let mut arrivals = Arrivals::attach(update);
816
817 tree.announce("Event added");
818
819 let exposed = tree.sync_accessibility();
820 assert_eq!(
821 announced_by_consumer(&exposed),
822 vec![("Event added".to_string(), accesskit::Live::Polite)],
823 "the message must be a label on a live node inside the filtered tree"
824 );
825 assert_eq!(
826 live_nodes_in_filtered_tree(&exposed).len(),
827 1,
828 "exactly one announcer node must have entered the filtered tree"
829 );
830 assert_a11y_tree_valid(&exposed);
831 assert_eq!(
832 arrivals.feed(exposed),
833 vec![("Event added".to_string(), accesskit::Live::Polite)]
834 );
835
836 tree.advance_time(std::time::Duration::from_millis(16));
839 let lingering = tree.sync_accessibility();
840 assert_eq!(live_nodes_in_filtered_tree(&lingering).len(), 1);
841 assert_eq!(
842 arrivals.feed(lingering),
843 Vec::new(),
844 "a message is said once"
845 );
846
847 tree.advance_time(crate::announcer::LINGER);
849 let gone = tree.sync_accessibility();
850 assert_eq!(
851 live_nodes_in_filtered_tree(&gone),
852 Vec::<String>::new(),
853 "the live node must leave the filtered tree entirely"
854 );
855 assert_a11y_tree_valid(&gone);
856 }
857
858 #[test]
863 fn the_same_message_announced_twice_is_exposed_twice() {
864 let mut tree = tree_with_one_widget();
865 tree.advance_time(std::time::Duration::ZERO);
866 let mut arrivals = Arrivals::attach(tree.sync_accessibility());
867 tree.announce("Saved");
868 tree.announce("Saved");
869
870 let mut heard = Vec::new();
871 let mut ids = std::collections::HashSet::new();
872 for _ in 0..6 {
873 let update = tree.sync_accessibility();
874 ids.extend(
875 update
876 .nodes
877 .iter()
878 .filter(|(id, _)| crate::announcer::is_announcer_node(*id))
879 .map(|(id, _)| *id),
880 );
881 heard.extend(arrivals.feed(update).into_iter().map(|(text, _)| text));
882 tree.advance_time(std::time::Duration::from_millis(16));
883 }
884 assert_eq!(
885 heard,
886 ["Saved", "Saved"],
887 "each announcement is its own arrival"
888 );
889 assert_eq!(
890 ids.len(),
891 2,
892 "each announcement is spoken from its own node"
893 );
894 }
895
896 #[test]
897 fn an_assertive_announcement_uses_the_assertive_live_setting() {
898 let mut tree = tree_with_one_widget();
899 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
900 let update = tree.sync_accessibility();
901 assert_eq!(
902 announced_by_consumer(&update),
903 vec![("Could not save".to_string(), accesskit::Live::Assertive)]
904 );
905 }
906
907 #[test]
910 fn the_two_politeness_levels_announce_independently() {
911 let mut tree = tree_with_one_widget();
912 tree.announce("Event added");
913 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
914
915 let mut spoken = announced_by_consumer(&tree.sync_accessibility());
916 spoken.sort_by(|a, b| a.0.cmp(&b.0));
917 assert_eq!(
918 spoken,
919 vec![
920 ("Could not save".to_string(), accesskit::Live::Assertive),
921 ("Event added".to_string(), accesskit::Live::Polite),
922 ],
923 "both levels must be exposed on the same update"
924 );
925 }
926
927 #[test]
931 fn announcing_requests_the_syncs_it_needs() {
932 let mut tree = tree_with_one_widget();
933 let _ = tree.sync_accessibility();
934 tree.a11y_update_requested.set(false);
935 tree.frame_tick_requested.set(false);
936
937 tree.announce("Event added");
938 assert!(
939 tree.a11y_update_requested.get(),
940 "an announcement must dirty the accessibility tree"
941 );
942 assert!(
943 tree.frame_requested(),
944 "an announcement must ask for the frame that carries it"
945 );
946
947 tree.announce("3 events");
950 tree.a11y_update_requested.set(false);
951 tree.frame_tick_requested.set(false);
952 let _ = tree.sync_accessibility();
953 assert!(tree.a11y_update_requested.get());
954 assert!(tree.frame_requested());
955
956 tree.a11y_update_requested.set(false);
959 tree.frame_tick_requested.set(false);
960 let before = std::time::Instant::now();
961 let _ = tree.sync_accessibility();
962 assert!(!tree.a11y_update_requested.get());
963 assert!(!tree.frame_requested());
964 let wake = tree
965 .next_timer_deadline()
966 .expect("a message in the tree asks to be woken when it leaves");
967 assert!(wake > before + crate::announcer::LINGER / 2);
968 }
969
970 #[test]
980 fn a_throttled_subscriber_does_not_hold_back_an_announcement() {
981 const NORMAL_FRAME: std::time::Duration = std::time::Duration::from_micros(17_500);
983 const CLOCK: std::time::Duration = std::time::Duration::from_secs(60);
984
985 fn frame(tree: &mut WidgetTree) -> accesskit::TreeUpdate {
986 tree.layout(SizeProposal::exact(200.0, 100.0));
987 let update = tree.sync_accessibility();
988 let _ = tree.render();
989 update
990 }
991 fn wait_for_next_frame(tree: &WidgetTree) -> std::time::Duration {
992 let deadline = tree
993 .frame_tick_deadline()
994 .expect("a visible subscriber keeps a frame scheduled");
995 let last = tree.last_frame_time.expect("a frame has run");
996 deadline.saturating_duration_since(last)
997 }
998
999 let mut tree = WidgetTree::new();
1000 let row = tree.add(FillWidget::new().label("content"));
1001 let _clock = tree.subscribe_frame_tick_throttled(row, CLOCK);
1002 let _ = frame(&mut tree);
1003 let _ = frame(&mut tree);
1004 assert!(
1005 wait_for_next_frame(&tree) >= CLOCK - NORMAL_FRAME,
1006 "precondition: with nothing else going on, the clock alone paces the loop"
1007 );
1008
1009 let messages = ["Annulé : l'ajout d'un évènement", "3 évènements"];
1010 for message in messages {
1011 tree.announce(message);
1012 }
1013 assert!(
1017 wait_for_next_frame(&tree) <= NORMAL_FRAME,
1018 "an announcement must be carried by the next normal frame, not the clock's \
1019 (waited {:?})",
1020 wait_for_next_frame(&tree)
1021 );
1022
1023 let mut exposed = Vec::new();
1024 let mut frames = 0;
1025 let mut arrivals = Arrivals::attach(tree.accessibility_tree_snapshot());
1026 let settled = loop {
1027 let update = frame(&mut tree);
1028 frames += 1;
1029 exposed.extend(arrivals.feed(update).into_iter().map(|(text, _)| text));
1030 let wait = wait_for_next_frame(&tree);
1031 if wait > NORMAL_FRAME {
1032 break wait;
1033 }
1034 assert!(frames < 16, "the announcer never went idle");
1035 };
1036 assert_eq!(
1037 exposed, messages,
1038 "every queued message must be exposed before the loop goes back to sleeping on the \
1039 clock; one left behind is heard only at the next key press"
1040 );
1041 assert!(
1042 settled >= CLOCK - NORMAL_FRAME,
1043 "once the queue is spoken the clock must pace alone again, not free-run at 60 Hz \
1044 (waited {settled:?})"
1045 );
1046 }
1047
1048 #[test]
1051 fn an_announcement_reaches_the_automation_ring() {
1052 let mut tree = tree_with_one_widget();
1053 let before = tree.announcements_since(0).len() as u64;
1054 tree.announce("Event added");
1055 let _ = tree.sync_accessibility();
1056 let got = tree.announcements_since(before);
1057 assert_eq!(got.len(), 1);
1058 assert_eq!(got[0].text, "Event added");
1059 assert!(!got[0].assertive);
1060 }
1061
1062 #[test]
1066 fn a_tree_snapshot_does_not_consume_a_pending_announcement() {
1067 let mut tree = tree_with_one_widget();
1068 tree.announce("Event added");
1069
1070 let snapshot = tree.accessibility_tree_snapshot();
1071 assert_eq!(
1072 announced_by_consumer(&snapshot),
1073 Vec::new(),
1074 "a snapshot must not advance the announcer"
1075 );
1076
1077 let delivered = tree.sync_accessibility();
1078 assert_eq!(
1079 announced_by_consumer(&delivered),
1080 vec![("Event added".to_string(), accesskit::Live::Polite)],
1081 "the message must still be there to deliver"
1082 );
1083 }
1084
1085 #[test]
1088 fn an_empty_announcement_is_not_exposed() {
1089 let mut tree = tree_with_one_widget();
1090 let _ = tree.sync_accessibility();
1091 tree.announce(" ");
1092 let update = tree.sync_accessibility();
1093 assert_eq!(announced_by_consumer(&update), Vec::new());
1094 }
1095
1096 #[derive(Debug)]
1097 struct ActionWidget;
1098
1099 impl Widget for ActionWidget {
1100 fn layout_response(
1101 &self,
1102 proposal: SizeProposal,
1103 _ctx: &LayoutContext,
1104 ) -> crate::widget::LayoutResponse {
1105 proposal.resolve(0.0, 0.0).into()
1106 }
1107
1108 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1109 builder.set_role(accesskit::Role::Button);
1110 builder.set_name("Save");
1111 builder.add_action(accesskit::Action::Click);
1112 builder.add_action(accesskit::Action::Focus);
1113 }
1114 }
1115
1116 #[derive(Debug)]
1117 struct ClickableWidget;
1118
1119 impl Widget for ClickableWidget {
1120 fn layout_response(
1121 &self,
1122 proposal: SizeProposal,
1123 _ctx: &LayoutContext,
1124 ) -> crate::widget::LayoutResponse {
1125 proposal.resolve(0.0, 0.0).into()
1126 }
1127
1128 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1129 builder.set_role(accesskit::Role::Button);
1130 builder.set_name("Click Me");
1131 builder.add_action(accesskit::Action::Click);
1132 }
1133 }
1134
1135 #[test]
1140 fn a_focusable_node_advertises_focus_without_the_widget_saying_so() {
1141 let mut tree = WidgetTree::new();
1142 let id = tree.add(ClickableWidget.focusable(true));
1144 tree.layout(SizeProposal::exact(100.0, 100.0));
1145
1146 let info = tree.accessibility_node(id);
1147 assert!(
1148 info.actions().contains(&accesskit::Action::Focus),
1149 "the arena knows the node is focusable; the AT node must say so"
1150 );
1151
1152 let mut ops = crate::window::NoopWindowOps;
1154 assert!(tree.dispatch_access_action(
1155 crate::accessibility::widget_id_to_node_id(id),
1156 accesskit::Action::Focus,
1157 None,
1158 &mut ops,
1159 ));
1160 assert_eq!(tree.focused(), Some(id));
1161 }
1162
1163 #[derive(Debug)]
1169 struct CompositeWidget {
1170 child_ids: Vec<WidgetId>,
1171 }
1172
1173 impl Widget for CompositeWidget {
1174 fn layout_response(
1175 &self,
1176 proposal: SizeProposal,
1177 _ctx: &LayoutContext,
1178 ) -> crate::widget::LayoutResponse {
1179 proposal.resolve(0.0, 0.0).into()
1180 }
1181
1182 fn place_children(
1183 &self,
1184 bounds: Rect,
1185 _proposal: SizeProposal,
1186 children: &mut [crate::widget::WidgetPlacement],
1187 _ctx: &LayoutContext,
1188 ) {
1189 for child in children.iter_mut() {
1190 child.origin = bounds.origin();
1191 child.size = bounds.size();
1192 }
1193 }
1194
1195 fn children(&self) -> Vec<WidgetId> {
1196 self.child_ids.clone()
1197 }
1198
1199 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1200 builder.set_role(accesskit::Role::SpinButton);
1201 builder.add_action(accesskit::Action::Focus);
1202 }
1203 }
1204
1205 #[test]
1216 fn an_at_focus_on_a_non_focusable_composite_lands_on_its_focusable_leaf() {
1217 let mut tree = WidgetTree::new();
1218 let leaf = tree.add(ClickableWidget.focusable(true));
1219 let root = tree.add(CompositeWidget {
1220 child_ids: vec![leaf],
1221 });
1222 tree.layout(SizeProposal::exact(100.0, 100.0));
1223
1224 let mut ops = crate::window::NoopWindowOps;
1225 assert!(tree.dispatch_access_action(
1226 crate::accessibility::widget_id_to_node_id(root),
1227 accesskit::Action::Focus,
1228 None,
1229 &mut ops,
1230 ));
1231 assert_eq!(
1232 tree.focused(),
1233 Some(leaf),
1234 "focus must reach the focusable leaf, not the composite root"
1235 );
1236 }
1237
1238 #[test]
1247 fn an_at_focus_on_a_plain_container_does_not_walk_into_it() {
1248 let mut tree = WidgetTree::new();
1249 let leaf = tree.add(ClickableWidget.focusable(true));
1250 let root = tree.add(StackWidget::new().child(leaf));
1251 tree.layout(SizeProposal::exact(100.0, 100.0));
1252
1253 let mut ops = crate::window::NoopWindowOps;
1254 assert!(
1255 !tree.dispatch_access_action(
1256 crate::accessibility::widget_id_to_node_id(root),
1257 accesskit::Action::Focus,
1258 None,
1259 &mut ops,
1260 ),
1261 "a node that never advertised `Focus` must report the action unhandled"
1262 );
1263 assert_eq!(
1264 tree.focused(),
1265 None,
1266 "focus must stay where it was, not fall into the container's first control"
1267 );
1268 }
1269
1270 #[test]
1273 fn an_at_focus_on_a_focusable_node_still_lands_on_that_node() {
1274 let mut tree = WidgetTree::new();
1275 let outer = tree.add(ClickableWidget.focusable(true));
1276 tree.layout(SizeProposal::exact(100.0, 100.0));
1277
1278 let mut ops = crate::window::NoopWindowOps;
1279 assert!(tree.dispatch_access_action(
1280 crate::accessibility::widget_id_to_node_id(outer),
1281 accesskit::Action::Focus,
1282 None,
1283 &mut ops,
1284 ));
1285 assert_eq!(tree.focused(), Some(outer));
1286 }
1287
1288 #[test]
1291 fn a_derived_focus_action_is_still_removable_by_an_override() {
1292 let mut tree = WidgetTree::new();
1293 let id = tree.add(
1294 ClickableWidget
1295 .focusable(true)
1296 .access_remove_action(accesskit::Action::Focus),
1297 );
1298 tree.layout(SizeProposal::exact(100.0, 100.0));
1299 assert!(
1300 !tree
1301 .accessibility_node(id)
1302 .actions()
1303 .contains(&accesskit::Action::Focus)
1304 );
1305 }
1306
1307 #[test]
1310 fn a_non_focusable_node_does_not_advertise_focus() {
1311 let mut tree = WidgetTree::new();
1312 let id = tree.add(ClickableWidget);
1313 tree.layout(SizeProposal::exact(100.0, 100.0));
1314 assert!(
1315 !tree
1316 .accessibility_node(id)
1317 .actions()
1318 .contains(&accesskit::Action::Focus)
1319 );
1320 }
1321
1322 #[test]
1323 fn labeled_widget_has_accessibility() {
1324 let mut tree = WidgetTree::new();
1325 let widget = tree.add(FillWidget::new().label("Hello"));
1326 tree.layout(SizeProposal::exact(100.0, 20.0));
1327 let info = tree.accessibility_node(widget);
1328 assert_eq!(info.role(), accesskit::Role::Label);
1329 assert_eq!(info.name(), Some("Hello"));
1330 }
1331
1332 #[test]
1333 fn find_by_label_works() {
1334 let mut tree = WidgetTree::new();
1335 let widget = tree.add(FillWidget::new().label("Save"));
1336 tree.layout(SizeProposal::exact(100.0, 20.0));
1337 assert_eq!(tree.find_by_label("Save"), Some(widget));
1338 }
1339
1340 #[test]
1341 fn label_role_emits_text_as_value_not_label() {
1342 let mut tree = WidgetTree::new();
1349 let widget = tree.add(FillWidget::new().label("Section")); tree.layout(SizeProposal::exact(100.0, 20.0));
1351
1352 assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1354
1355 let update = tree.sync_accessibility();
1357 let node = &update
1358 .nodes
1359 .iter()
1360 .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1361 .expect("label node must be present in the tree update")
1362 .1;
1363 assert_eq!(node.role(), accesskit::Role::Label);
1364 assert_eq!(
1365 node.value(),
1366 Some("Section"),
1367 "Role::Label text must be exposed via `value`"
1368 );
1369 assert_eq!(
1370 node.label(),
1371 None,
1372 "Role::Label must not leave text in the `label` property"
1373 );
1374 }
1375
1376 #[test]
1377 fn find_by_role_works() {
1378 let mut tree = WidgetTree::new();
1379 tree.add(FillWidget::new().label("Text"));
1380 tree.layout(SizeProposal::exact(100.0, 20.0));
1381 assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1382 }
1383
1384 #[test]
1385 fn access_hidden_bound_to_signal_toggles_reactively() {
1386 use crate::signal::Signal;
1387 use crate::widget_builder::WidgetBuilder;
1388 let hidden = Signal::new(false);
1389 let mut tree = WidgetTree::new();
1390 let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1391 tree.layout(SizeProposal::exact(100.0, 30.0));
1392 assert!(
1393 !tree.accessibility_node(w).is_hidden(),
1394 "node should be visible to AT while the signal is false"
1395 );
1396 hidden.set(true);
1397 assert!(
1399 tree.accessibility_node(w).is_hidden(),
1400 "node should hide from AT when the bound signal flips to true"
1401 );
1402 hidden.set(false);
1403 assert!(!tree.accessibility_node(w).is_hidden());
1404 }
1405
1406 #[test]
1410 fn a_widget_with_a_context_menu_advertises_the_action() {
1411 let mut tree = WidgetTree::new();
1412 let plain = tree.add(FillWidget::new().label("no menu"));
1413 let with_menu = tree.add(
1414 FillWidget::new()
1415 .label("has menu")
1416 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1417 );
1418 tree.layout(SizeProposal::exact(100.0, 20.0));
1419
1420 assert!(
1421 tree.accessibility_node(with_menu)
1422 .actions()
1423 .contains(&Action::ShowContextMenu),
1424 "a node owning a context-menu factory must announce it"
1425 );
1426 assert!(
1427 !tree
1428 .accessibility_node(plain)
1429 .actions()
1430 .contains(&Action::ShowContextMenu),
1431 "and a node without one must not"
1432 );
1433 }
1434
1435 #[test]
1438 fn a_removed_context_menu_action_stays_removed() {
1439 let mut tree = WidgetTree::new();
1440 let id = tree.add(
1441 FillWidget::new()
1442 .label("suppressed")
1443 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1444 .access_remove_action(Action::ShowContextMenu),
1445 );
1446 tree.layout(SizeProposal::exact(100.0, 20.0));
1447 assert!(
1448 !tree
1449 .accessibility_node(id)
1450 .actions()
1451 .contains(&Action::ShowContextMenu)
1452 );
1453 }
1454
1455 #[test]
1460 fn a_child_does_not_advertise_its_parents_context_menu() {
1461 let mut tree = WidgetTree::new();
1462 let child = tree.add(FillWidget::new().label("inner"));
1463 let parent = tree.add(
1464 StackWidget::new()
1465 .child(child)
1466 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1467 );
1468 tree.layout(SizeProposal::exact(100.0, 20.0));
1469
1470 assert!(
1471 tree.accessibility_node(parent)
1472 .actions()
1473 .contains(&Action::ShowContextMenu)
1474 );
1475 assert!(
1476 !tree
1477 .accessibility_node(child)
1478 .actions()
1479 .contains(&Action::ShowContextMenu),
1480 "the menu belongs to the widget that declared it"
1481 );
1482 }
1483
1484 #[test]
1485 fn accessibility_node_collects_actions() {
1486 let mut tree = WidgetTree::new();
1487 let widget = tree.add(ActionWidget);
1488 tree.layout(SizeProposal::exact(100.0, 40.0));
1489
1490 let info = tree.accessibility_node(widget);
1491 assert_eq!(info.role(), accesskit::Role::Button);
1492 assert_eq!(info.name(), Some("Save"));
1493 assert_eq!(info.actions().len(), 2);
1494 assert!(info.actions().contains(&accesskit::Action::Click));
1495 assert!(info.actions().contains(&accesskit::Action::Focus));
1496 }
1497
1498 #[test]
1499 fn sync_accessibility_produces_tree_update() {
1500 let mut tree = WidgetTree::new();
1501 tree.add(FillWidget::new().label("First"));
1502 tree.add(FillWidget::new().label("Second"));
1503 tree.layout(SizeProposal::exact(200.0, 100.0));
1504
1505 let update = tree.sync_accessibility();
1506 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1509 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1510 assert!(update.tree.is_some());
1511 assert_a11y_tree_valid(&update);
1512 }
1513
1514 #[test]
1515 fn root_node_carries_locale_as_language() {
1516 let mut tree = WidgetTree::new();
1517 tree.add(FillWidget::new().label("Bonjour"));
1518
1519 tree.layout(SizeProposal::exact(200.0, 100.0));
1521 let update = tree.sync_accessibility();
1522 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1523 assert_eq!(
1524 update.nodes[0].1.language(),
1525 None,
1526 "no language before a locale is set"
1527 );
1528
1529 tree.set_locale("fr-FR".to_string());
1532 let update = tree.sync_accessibility();
1533 assert_eq!(
1534 update.nodes[0].1.language(),
1535 Some("fr-FR"),
1536 "root node must advertise the active locale as its language"
1537 );
1538 }
1539
1540 #[test]
1541 fn sync_accessibility_excludes_dormant_widgets() {
1542 let mut tree = WidgetTree::new();
1543 tree.add(FillWidget::new().label("Active"));
1544 let dormant = tree.add(FillWidget::new().label("Dormant"));
1545 tree.layout(SizeProposal::exact(200.0, 100.0));
1546
1547 tree.set_dormant(dormant);
1548
1549 let update = tree.sync_accessibility();
1550 assert_eq!(update.nodes.len(), 2 + ANNOUNCER_NODES);
1551 assert_a11y_tree_valid(&update);
1552 }
1553
1554 #[test]
1561 fn relayout_without_activation_change_does_not_dirty_a11y() {
1562 let mut tree = WidgetTree::new();
1563 let id = tree.add(FillWidget::new().label("Static"));
1564 tree.layout(SizeProposal::exact(200.0, 100.0));
1565
1566 let _ = tree.sync_accessibility();
1568 assert!(
1569 !tree.a11y_dirty,
1570 "sync_accessibility must clear the dirty flag"
1571 );
1572
1573 tree.arena.mark_needs_layout(id);
1577 tree.layout(SizeProposal::exact(200.0, 100.0));
1578
1579 assert!(
1580 !tree.a11y_dirty,
1581 "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1582 );
1583 }
1584
1585 #[derive(Debug)]
1588 struct Mover {
1589 offset: crate::signal::Signal<f32>,
1590 child: WidgetId,
1591 }
1592
1593 impl crate::widget::Widget for Mover {
1594 fn children(&self) -> Vec<WidgetId> {
1595 vec![self.child]
1596 }
1597 fn layout_response(
1598 &self,
1599 proposal: SizeProposal,
1600 _ctx: &crate::widget::LayoutContext,
1601 ) -> crate::widget::LayoutResponse {
1602 proposal.resolve(200.0, 100.0).into()
1603 }
1604 fn place_children(
1605 &self,
1606 bounds: teksilo_canvas::Rect,
1607 _proposal: SizeProposal,
1608 children: &mut [crate::widget::WidgetPlacement],
1609 _ctx: &crate::widget::LayoutContext,
1610 ) {
1611 if let Some(placement) = children.first_mut() {
1612 placement.origin =
1613 teksilo_canvas::Point::new(bounds.x + self.offset.get(), bounds.y);
1614 placement.size = teksilo_canvas::Size::new(50.0, 20.0);
1615 }
1616 }
1617 }
1618
1619 #[test]
1626 fn translating_a_widget_patches_the_cache_without_a_walk() {
1627 let mut tree = WidgetTree::new();
1628 let inner = tree.add(FillWidget::new().label("Row"));
1629 let offset = crate::signal::Signal::new(0.0f32);
1630 let root = tree.add(Mover {
1631 offset: offset.clone(),
1632 child: inner,
1633 });
1634 tree.layout(SizeProposal::exact(200.0, 100.0));
1635 let update = tree.sync_accessibility();
1636 let walks = tree.a11y_walk_generation();
1637 let before = find_node(&update, inner)
1638 .unwrap()
1639 .bounds()
1640 .expect("the node carries bounds");
1641
1642 offset.set(20.0);
1644 tree.arena.mark_needs_layout(root);
1645 tree.layout(SizeProposal::exact(200.0, 100.0));
1646
1647 let update = tree.sync_accessibility();
1648 assert_eq!(
1649 tree.a11y_walk_generation(),
1650 walks,
1651 "a pure translation must not cost a walk"
1652 );
1653 let after = find_node(&update, inner)
1654 .unwrap()
1655 .bounds()
1656 .expect("the node still carries bounds");
1657 assert!(
1658 (after.x0 - before.x0 - 20.0).abs() < 0.01,
1659 "the cached node must follow the move: {before:?} -> {after:?}"
1660 );
1661 }
1662
1663 #[test]
1664 fn at_version_does_not_bump_for_a_move() {
1665 let mut tree = WidgetTree::new();
1669 let inner = tree.add(FillWidget::new().label("Row"));
1670 let offset = crate::signal::Signal::new(0.0f32);
1671 let root = tree.add(Mover {
1672 offset: offset.clone(),
1673 child: inner,
1674 });
1675 tree.layout(SizeProposal::exact(200.0, 100.0));
1676 let _ = tree.sync_accessibility();
1677 let version = tree.at_version().get();
1678
1679 offset.set(20.0);
1680 tree.arena.mark_needs_layout(root);
1681 tree.layout(SizeProposal::exact(200.0, 100.0));
1682 let _ = tree.sync_accessibility();
1683
1684 assert_eq!(tree.at_version().get(), version);
1685 }
1686
1687 #[test]
1688 fn resizing_a_widget_dirties_the_accessibility_tree() {
1689 let mut tree = WidgetTree::new();
1693 let id = tree.add(FillWidget::new().label("Static"));
1694 tree.layout(SizeProposal::exact(200.0, 100.0));
1695 let _ = tree.sync_accessibility();
1696 let walks = tree.a11y_walk_generation();
1697
1698 tree.layout(SizeProposal::exact(300.0, 100.0));
1699 let _ = tree.sync_accessibility();
1700
1701 assert!(
1702 tree.a11y_walk_generation() > walks,
1703 "a resize must be walked, not patched"
1704 );
1705 let _ = id;
1706 }
1707
1708 #[test]
1709 fn changing_the_theme_dirties_the_accessibility_tree() {
1710 let mut tree = WidgetTree::new();
1715 tree.add(FillWidget::new().label("Static"));
1716 tree.layout(SizeProposal::exact(200.0, 100.0));
1717 let _ = tree.sync_accessibility();
1718 let walks = tree.a11y_walk_generation();
1719
1720 tree.set_theme(crate::presets::intui::dark());
1721 let _ = tree.sync_accessibility();
1722
1723 assert!(tree.a11y_walk_generation() > walks);
1724 }
1725
1726 #[test]
1727 fn changing_the_text_scale_dirties_the_accessibility_tree() {
1728 let mut tree = WidgetTree::new();
1729 tree.add(FillWidget::new().label("Static"));
1730 tree.layout(SizeProposal::exact(200.0, 100.0));
1731 let _ = tree.sync_accessibility();
1732 let walks = tree.a11y_walk_generation();
1733
1734 tree.set_user_text_scale(1.5);
1735 let _ = tree.sync_accessibility();
1736
1737 assert!(tree.a11y_walk_generation() > walks);
1738 }
1739
1740 #[test]
1741 fn a_moved_node_absent_from_the_cache_is_skipped() {
1742 use crate::widget_builder::WidgetBuilder;
1747 let mut tree = WidgetTree::new();
1748 let hidden = tree.add(FillWidget::new().label("Inner"));
1749 let offset = crate::signal::Signal::new(0.0f32);
1750 let root = tree.add(
1751 Mover {
1752 offset: offset.clone(),
1753 child: hidden,
1754 }
1755 .access_exclude_subtree(),
1756 );
1757 tree.layout(SizeProposal::exact(200.0, 100.0));
1758 let update = tree.sync_accessibility();
1759 assert!(
1760 find_node(&update, hidden).is_none(),
1761 "the excluded descendant must be absent for this test to mean anything"
1762 );
1763
1764 offset.set(20.0);
1765 tree.arena.mark_needs_layout(root);
1766 tree.layout(SizeProposal::exact(200.0, 100.0));
1767 let update = tree.sync_accessibility();
1768
1769 assert!(find_node(&update, hidden).is_none());
1770 assert_no_dangling_relationships(&update);
1771 }
1772
1773 #[test]
1774 fn focus_on_a_node_the_walk_did_not_emit_resolves_to_an_emitted_ancestor() {
1775 use crate::widget_builder::WidgetBuilder;
1786 let mut tree = WidgetTree::new();
1787 let inner = tree.add(ClickableWidget);
1788 let outer = tree.add(
1789 Mover {
1790 offset: crate::signal::Signal::new(0.0f32),
1791 child: inner,
1792 }
1793 .access_exclude_subtree(),
1794 );
1795 tree.layout(SizeProposal::exact(200.0, 100.0));
1796 tree.focus(inner);
1797
1798 let update = tree.sync_accessibility();
1799 assert!(
1800 find_node(&update, inner).is_none(),
1801 "precondition: the excluded descendant emits nothing"
1802 );
1803 let emitted: std::collections::HashSet<_> =
1804 update.nodes.iter().map(|(id, _)| *id).collect();
1805 assert!(
1806 emitted.contains(&update.focus),
1807 "the published focus must be a node in the published tree"
1808 );
1809 assert_eq!(
1810 update.focus,
1811 crate::accessibility::widget_id_to_node_id(outer),
1812 "…and specifically the nearest ancestor that did emit"
1813 );
1814 accesskit_consumer::Tree::new(update, false);
1816 }
1817
1818 #[test]
1822 fn activation_transition_does_dirty_a11y() {
1823 let mut tree = WidgetTree::new();
1824 let id = tree.add(FillWidget::new().label("Toggle"));
1825 tree.layout(SizeProposal::exact(200.0, 100.0));
1826 let _ = tree.sync_accessibility();
1827 assert!(!tree.a11y_dirty);
1828
1829 tree.set_dormant(id);
1830 tree.layout(SizeProposal::exact(200.0, 100.0));
1831 assert!(
1832 tree.a11y_dirty,
1833 "active→dormant transition must dirty the AT cache so the dormant node is removed"
1834 );
1835 }
1836
1837 #[test]
1838 fn sync_accessibility_includes_focus() {
1839 let mut tree = WidgetTree::new();
1840 let widget = tree.add(FillWidget::new().focusable().label("Focused"));
1841 tree.layout(SizeProposal::exact(100.0, 50.0));
1842 tree.focus(widget);
1843
1844 let update = tree.sync_accessibility();
1845 let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
1846 assert_eq!(update.focus, expected_focus);
1847 assert_a11y_tree_valid(&update);
1848 }
1849
1850 #[test]
1851 fn sync_accessibility_parent_child_relationship() {
1852 let mut tree = WidgetTree::new();
1853 let child = tree.add(FillWidget::new().label("Child"));
1854 let parent = tree.add(
1857 StackWidget::new()
1858 .child(child)
1859 .access_label_literal("Parent"),
1860 );
1861 tree.layout(SizeProposal::exact(100.0, 50.0));
1862
1863 let update = tree.sync_accessibility();
1864 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1865
1866 let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
1867 let parent_node = update
1868 .nodes
1869 .iter()
1870 .find(|(id, _)| *id == parent_node_id)
1871 .map(|(_, node)| node)
1872 .unwrap();
1873
1874 let child_node_id = crate::accessibility::widget_id_to_node_id(child);
1875 assert!(parent_node.children().contains(&child_node_id));
1876 assert_a11y_tree_valid(&update);
1877 }
1878
1879 #[test]
1880 fn presentational_containers_collapse_and_promote_children() {
1881 let mut tree = WidgetTree::new();
1887 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(
1891 StackWidget::new()
1892 .child(outer)
1893 .access_label_literal("Group"),
1894 );
1895 tree.layout(SizeProposal::exact(100.0, 50.0));
1896 let update = tree.sync_accessibility();
1897
1898 assert!(find_node(&update, inner).is_none(), "bare inner pruned");
1899 assert!(find_node(&update, outer).is_none(), "bare outer pruned");
1900
1901 let labeled_node = find_node(&update, labeled).expect("labeled group survives");
1902 let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
1903 assert!(
1904 labeled_node.children().contains(&leaf_nid),
1905 "leaf promoted past both bare containers to the labeled ancestor"
1906 );
1907 assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
1908 assert_a11y_tree_valid(&update);
1909 }
1910
1911 #[test]
1912 fn find_by_action_finds_clickable() {
1913 let mut tree = WidgetTree::new();
1914 let widget = tree.add(ClickableWidget);
1915 tree.layout(SizeProposal::exact(100.0, 40.0));
1916
1917 assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
1918 assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
1919 }
1920
1921 #[test]
1922 fn text_content_returns_accessibility_name() {
1923 let mut tree = WidgetTree::new();
1924 let widget = tree.add(FillWidget::new().label("Hello World"));
1925 tree.layout(SizeProposal::exact(100.0, 50.0));
1926
1927 assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
1928 }
1929
1930 #[test]
1931 fn text_content_returns_none_without_label() {
1932 let mut tree = WidgetTree::new();
1933 let widget = tree.add(FillWidget::new());
1934 tree.layout(SizeProposal::exact(100.0, 50.0));
1935
1936 assert_eq!(tree.text_content(widget), None);
1937 }
1938
1939 #[test]
1940 fn descendant_of_disabled_ancestor_reports_disabled() {
1941 use crate::signal::Signal;
1942
1943 let mut tree = WidgetTree::new();
1944 let child = tree.add(FillWidget::new().label("Child"));
1945 let parent = tree.add(StackWidget::new().child(child));
1946 tree.enabled_when(parent, Signal::new(false));
1947 tree.layout(SizeProposal::exact(100.0, 50.0));
1948
1949 assert!(
1950 tree.accessibility_node(child).is_disabled(),
1951 "descendant should report disabled when ancestor is disabled"
1952 );
1953 }
1954
1955 #[test]
1956 fn text_value_returns_accessibility_value() {
1957 #[derive(Debug)]
1958 struct ValueWidget;
1959
1960 impl Widget for ValueWidget {
1961 fn layout_response(
1962 &self,
1963 proposal: SizeProposal,
1964 _ctx: &LayoutContext,
1965 ) -> crate::widget::LayoutResponse {
1966 proposal.resolve(0.0, 0.0).into()
1967 }
1968
1969 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1970 builder.set_role(accesskit::Role::Slider);
1971 builder.set_name("Volume");
1972 builder.set_value("75%");
1973 }
1974 }
1975
1976 let mut tree = WidgetTree::new();
1977 let widget = tree.add(ValueWidget);
1978 tree.layout(SizeProposal::exact(100.0, 40.0));
1979
1980 assert_eq!(tree.text_value(widget), Some("75%".to_string()));
1981 assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
1982 }
1983
1984 #[test]
1985 fn sync_accessibility_has_no_duplicate_children() {
1986 let mut tree = WidgetTree::new();
1990 let grandchild = tree.add(FillWidget::new().label("Grandchild"));
1991 let child_a = tree.add(StackWidget::new().child(grandchild));
1992 let child_b = tree.add(FillWidget::new().label("Sibling"));
1993 let _root = tree.add(StackWidget::new().child(child_a).child(child_b));
1994 tree.layout(SizeProposal::exact(200.0, 100.0));
1995
1996 let update = tree.sync_accessibility();
1997
1998 let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
1999 std::collections::HashMap::new();
2000 for (parent_id, node) in &update.nodes {
2001 for &child_id in node.children() {
2002 let prev = all_children.insert(child_id, *parent_id);
2003 assert!(
2004 prev.is_none(),
2005 "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
2006 );
2007 }
2008 }
2009 assert_a11y_tree_valid(&update);
2010 }
2011
2012 #[test]
2013 fn no_dangling_relationships_in_basic_tree() {
2014 let mut tree = WidgetTree::new();
2015 let child = tree.add(FillWidget::new().label("Child"));
2016 let _parent = tree.add(StackWidget::new().child(child));
2017 tree.layout(SizeProposal::exact(100.0, 50.0));
2018
2019 let update = tree.sync_accessibility();
2020 assert_no_dangling_relationships(&update);
2021 assert_a11y_tree_valid(&update);
2022 }
2023
2024 use crate::widget_builder::WidgetBuilder;
2029 use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
2030
2031 fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
2033 let nid = crate::accessibility::widget_id_to_node_id(id);
2034 update
2035 .nodes
2036 .iter()
2037 .find(|(node_id, _)| *node_id == nid)
2038 .map(|(_, n)| n)
2039 }
2040
2041 #[derive(Debug)]
2044 struct AlwaysHiddenWidget;
2045 impl Widget for AlwaysHiddenWidget {
2046 fn layout_response(
2047 &self,
2048 proposal: SizeProposal,
2049 _ctx: &LayoutContext,
2050 ) -> crate::widget::LayoutResponse {
2051 proposal.resolve(0.0, 0.0).into()
2052 }
2053 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2054 builder.set_role(Role::GenericContainer);
2055 builder.set_hidden();
2056 }
2057 }
2058
2059 #[test]
2061 fn access_label_replaces_widget_label() {
2062 let mut tree = WidgetTree::new();
2063 let id = tree.add(ClickableWidget.access_label_literal("Publish"));
2064 tree.layout(SizeProposal::exact(100.0, 40.0));
2065 assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
2066 }
2067
2068 #[test]
2070 fn access_description_appears_on_bare_widget() {
2071 let mut tree = WidgetTree::new();
2072 let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
2073 tree.layout(SizeProposal::exact(50.0, 50.0));
2074 let update = tree.sync_accessibility();
2075 let node = find_node(&update, id).expect("node present");
2076 assert_eq!(node.description(), Some("Decorative"));
2077 }
2078
2079 #[test]
2081 fn access_value_replaces_widget_value() {
2082 #[derive(Debug)]
2083 struct SliderWidget;
2084 impl Widget for SliderWidget {
2085 fn layout_response(
2086 &self,
2087 proposal: SizeProposal,
2088 _ctx: &LayoutContext,
2089 ) -> crate::widget::LayoutResponse {
2090 proposal.resolve(0.0, 0.0).into()
2091 }
2092 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2093 builder.set_role(Role::Slider);
2094 builder.set_value("50");
2095 }
2096 }
2097 let mut tree = WidgetTree::new();
2098 let id = tree.add(SliderWidget.access_value_literal("Custom"));
2099 tree.layout(SizeProposal::exact(100.0, 40.0));
2100 assert_eq!(tree.text_value(id), Some("Custom".to_string()));
2101 }
2102
2103 #[test]
2105 fn access_role_overrides_widget_role() {
2106 let mut tree = WidgetTree::new();
2107 let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
2108 tree.layout(SizeProposal::exact(100.0, 40.0));
2109 assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
2110 }
2111
2112 #[test]
2113 fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
2114 let mut tree = WidgetTree::new();
2121 let anchor = tree.add(FillWidget::new().label("Save"));
2122 let tip = tree.add(FillWidget::new().label("Save the file"));
2123 tree.layout(SizeProposal::exact(200.0, 100.0));
2124 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
2125
2126 assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
2127 let update = tree.sync_accessibility();
2128 let node = find_node(&update, anchor).expect("anchor node present");
2129 assert_eq!(
2130 node.description(),
2131 Some("Save the file"),
2132 "the anchor must describe itself with its tooltip text while unshown"
2133 );
2134 }
2135
2136 #[test]
2137 fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
2138 let mut tree = WidgetTree::new();
2144 let anchor = tree.add(FillWidget::new().label("Save"));
2145 let tip = tree.add(FillWidget::new().label("Save the file"));
2146 tree.layout(SizeProposal::exact(200.0, 100.0));
2147 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2148
2149 tree.pointer_move(tree.bounds(anchor).center());
2150 tree.advance_time(std::time::Duration::from_millis(150));
2151 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
2152
2153 let update = tree.sync_accessibility();
2154 let node = find_node(&update, anchor).expect("anchor node present");
2155 assert!(
2156 !node.described_by().is_empty(),
2157 "a shown tooltip is wired as a described_by relation"
2158 );
2159 assert_eq!(
2160 node.description(),
2161 Some("Save the file"),
2162 "and the anchor still describes itself with the tooltip's text"
2163 );
2164 }
2165
2166 #[test]
2168 fn access_hint_alias_writes_description_field() {
2169 let mut tree = WidgetTree::new();
2170 let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
2171 tree.layout(SizeProposal::exact(50.0, 50.0));
2172 let update = tree.sync_accessibility();
2173 let node = find_node(&update, id).unwrap();
2174 assert_eq!(node.description(), Some("Tip"));
2175 }
2176
2177 #[test]
2179 fn access_identifier_writes_author_id() {
2180 let mut tree = WidgetTree::new();
2181 let id = tree.add(FillWidget::new().access_identifier("save-button"));
2182 tree.layout(SizeProposal::exact(50.0, 50.0));
2183 let update = tree.sync_accessibility();
2184 let node = find_node(&update, id).unwrap();
2185 assert_eq!(node.author_id(), Some("save-button"));
2186 }
2187
2188 #[test]
2190 fn access_shortcut_literal_set() {
2191 let mut tree = WidgetTree::new();
2192 let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
2193 tree.layout(SizeProposal::exact(50.0, 50.0));
2194 let update = tree.sync_accessibility();
2195 let node = find_node(&update, id).unwrap();
2196 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
2197 }
2198
2199 #[test]
2202 fn access_shortcut_id_resolves_and_tracks_rebinds() {
2203 use crate::event::Key;
2204 use crate::shortcut::{KeyStroke, Shortcut};
2205
2206 let mut tree = WidgetTree::new();
2207 tree.shortcut_registry_mut().register(
2208 Shortcut::new("app.save")
2209 .name("Save")
2210 .primary(KeyStroke::command(Key::S))
2211 .build(),
2212 );
2213 let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
2214 tree.layout(SizeProposal::exact(50.0, 50.0));
2215
2216 let accel = if cfg!(target_os = "macos") {
2220 "Cmd"
2221 } else {
2222 "Ctrl"
2223 };
2224
2225 let update = tree.sync_accessibility();
2227 let node = find_node(&update, id).unwrap();
2228 assert_eq!(
2229 node.keyboard_shortcut(),
2230 Some(format!("{accel}+S").as_str())
2231 );
2232
2233 tree.shortcut_registry_mut()
2235 .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
2236 let update = tree.sync_accessibility();
2237 let node = find_node(&update, id).unwrap();
2238 assert_eq!(
2239 node.keyboard_shortcut(),
2240 Some(format!("{accel}+Q").as_str())
2241 );
2242 }
2243
2244 #[test]
2247 fn access_shortcut_id_unknown_id_omits_announcement() {
2248 let mut tree = WidgetTree::new();
2249 let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
2250 tree.layout(SizeProposal::exact(50.0, 50.0));
2251 let update = tree.sync_accessibility();
2252 let node = find_node(&update, id).unwrap();
2253 assert_eq!(node.keyboard_shortcut(), None);
2254 }
2255
2256 #[test]
2258 fn access_hidden_true_hides_widget() {
2259 let mut tree = WidgetTree::new();
2260 let id = tree.add(ClickableWidget.access_hidden(true));
2261 tree.layout(SizeProposal::exact(50.0, 50.0));
2262 assert!(tree.accessibility_node(id).is_hidden());
2263 }
2264
2265 #[test]
2267 fn access_hidden_false_clears_widget_set_hidden() {
2268 let mut tree = WidgetTree::new();
2269 let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
2270 tree.layout(SizeProposal::exact(50.0, 50.0));
2271 assert!(
2272 !tree.accessibility_node(id).is_hidden(),
2273 "access_hidden(false) should clear widget-emitted hidden"
2274 );
2275 }
2276
2277 #[test]
2279 fn access_disabled_true_marks_disabled() {
2280 let mut tree = WidgetTree::new();
2281 let id = tree.add(FillWidget::new().access_disabled(true));
2282 tree.layout(SizeProposal::exact(50.0, 50.0));
2283 assert!(tree.accessibility_node(id).is_disabled());
2284 }
2285
2286 #[test]
2288 fn access_disabled_false_clears_arena_driven_disabled() {
2289 use crate::signal::Signal;
2290 let mut tree = WidgetTree::new();
2291 let id = tree.add(FillWidget::new().label("X").access_disabled(false));
2292 tree.enabled_when(id, Signal::new(false));
2293 tree.layout(SizeProposal::exact(50.0, 50.0));
2294 assert!(
2295 !tree.accessibility_node(id).is_disabled(),
2296 "access_disabled(false) should clear even arena-driven disabled"
2297 );
2298 }
2299
2300 #[test]
2302 fn access_controls_appends() {
2303 let mut tree = WidgetTree::new();
2304 let target = tree.add(FillWidget::new().label("Target"));
2305 let controller = tree.add(
2306 FillWidget::new()
2307 .label("Controller")
2308 .access_controls(target),
2309 );
2310 tree.layout(SizeProposal::exact(100.0, 50.0));
2311 let update = tree.sync_accessibility();
2312 let node = find_node(&update, controller).unwrap();
2313 let target_nid = crate::accessibility::widget_id_to_node_id(target);
2314 assert!(
2315 node.controls().contains(&target_nid),
2316 "controls list should contain the target NodeId"
2317 );
2318 }
2319
2320 #[test]
2322 fn access_described_by_appends() {
2323 let mut tree = WidgetTree::new();
2324 let other = tree.add(FillWidget::new().label("Desc"));
2325 let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
2326 tree.layout(SizeProposal::exact(100.0, 50.0));
2327 let update = tree.sync_accessibility();
2328 let node = find_node(&update, id).unwrap();
2329 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2330 assert!(node.described_by().contains(&other_nid));
2331 }
2332
2333 #[test]
2335 fn access_labelled_by_appends() {
2336 let mut tree = WidgetTree::new();
2337 let other = tree.add(FillWidget::new().label("Lbl"));
2338 let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
2339 tree.layout(SizeProposal::exact(100.0, 50.0));
2340 let update = tree.sync_accessibility();
2341 let node = find_node(&update, id).unwrap();
2342 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2343 assert!(node.labelled_by().contains(&other_nid));
2344 }
2345
2346 #[test]
2358 fn a_child_attached_to_a_node_that_never_reached_the_tree_is_stripped() {
2359 use crate::accessibility::{AccessNodeBuilder, SyntheticKind, widget_id_to_node_id};
2360
2361 #[derive(Debug)]
2364 struct Grafter {
2365 child: std::cell::Cell<Option<WidgetId>>,
2366 }
2367
2368 impl Widget for Grafter {
2369 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2370 let child = ctx.add(FillWidget::new().label("Card"));
2371 self.child.set(Some(child));
2372 vec![child]
2373 }
2374 fn layout_response(
2375 &self,
2376 _p: SizeProposal,
2377 _c: &crate::widget::LayoutContext,
2378 ) -> crate::widget::LayoutResponse {
2379 teksilo_canvas::Size::new(100.0, 100.0).into()
2380 }
2381 fn children(&self) -> Vec<WidgetId> {
2382 self.child.get().into_iter().collect()
2383 }
2384 fn wants_descendant_redirects(&self) -> bool {
2385 true
2386 }
2387 fn a11y_redirect_descendant(
2388 &self,
2389 _self_id: WidgetId,
2390 descendant: WidgetId,
2391 ) -> Option<accesskit::NodeId> {
2392 (self.child.get() == Some(descendant)).then(|| widget_id_to_node_id(descendant))
2395 }
2396 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2397 builder.set_role(accesskit::Role::Pane);
2398 let group =
2399 builder.push_scene_child_under(None, 7, SyntheticKind::SceneGroup, |c| {
2400 c.set_role(accesskit::Role::GenericContainer);
2401 c.set_name("Group".to_string());
2402 });
2403 if let Some(child) = self.child.get() {
2404 builder.attach_scene_child_under(group, widget_id_to_node_id(child));
2405 }
2406 }
2407 }
2408
2409 let mut tree = WidgetTree::new();
2410 let grafter = tree.add(Grafter {
2411 child: std::cell::Cell::new(None),
2412 });
2413 tree.layout(SizeProposal::exact(200.0, 200.0));
2414 let child = tree.children(grafter)[0];
2415
2416 let update = tree.sync_accessibility();
2418 let child_nid = widget_id_to_node_id(child);
2419 assert!(
2420 update.nodes.iter().any(|(id, _)| *id == child_nid),
2421 "precondition: the grafted child is in the tree"
2422 );
2423 assert!(
2424 update
2425 .nodes
2426 .iter()
2427 .any(|(_, n)| n.children().contains(&child_nid)),
2428 "precondition: something names it as a child"
2429 );
2430 assert_a11y_tree_valid(&update);
2431
2432 tree.set_dormant(child);
2436 let update = tree.sync_accessibility();
2437 assert!(
2438 !update.nodes.iter().any(|(id, _)| *id == child_nid),
2439 "a dormant widget emits no node"
2440 );
2441 let emitted: std::collections::HashSet<accesskit::NodeId> =
2442 update.nodes.iter().map(|(id, _)| *id).collect();
2443 for (parent, node) in &update.nodes {
2444 for c in node.children() {
2445 assert!(
2446 emitted.contains(c),
2447 "node {parent:?} names child {c:?}, absent from the tree"
2448 );
2449 }
2450 }
2451 assert_a11y_tree_valid(&update);
2454 }
2455
2456 #[test]
2457 fn a_labelled_by_target_that_went_dormant_is_stripped() {
2458 let mut tree = WidgetTree::new();
2464 let title = tree.add(FillWidget::new().label("Preferences"));
2465 let dialog = tree.add(FillWidget::new().access_labelled_by(title));
2466 tree.layout(SizeProposal::exact(200.0, 100.0));
2467
2468 let update = tree.sync_accessibility();
2469 assert!(
2470 !find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2471 "the relation is live while the target is in the tree"
2472 );
2473
2474 tree.set_dormant(title);
2475 let update = tree.sync_accessibility();
2476 assert!(
2477 find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2478 "a target absent from the tree must not survive in the relation"
2479 );
2480 assert_no_dangling_relationships(&update);
2481 }
2482
2483 #[test]
2484 fn re_registering_a_labelled_by_relation_does_not_duplicate_it() {
2485 let mut tree = WidgetTree::new();
2489 let title = tree.add(FillWidget::new().label("Name"));
2490 let field = tree.add(FillWidget::new());
2491 tree.layout(SizeProposal::exact(200.0, 100.0));
2492
2493 for _ in 0..3 {
2494 tree.push_access_labelled_by(field, title);
2495 }
2496 let update = tree.sync_accessibility();
2497 assert_eq!(
2498 find_node(&update, field).unwrap().labelled_by().len(),
2499 1,
2500 "the same target registered repeatedly is one relation"
2501 );
2502 }
2503
2504 #[test]
2505 fn re_registering_a_described_by_relation_does_not_duplicate_it() {
2506 let mut tree = WidgetTree::new();
2507 let hint = tree.add(FillWidget::new().label("Must be unique"));
2508 let field = tree.add(FillWidget::new());
2509 tree.layout(SizeProposal::exact(200.0, 100.0));
2510
2511 for _ in 0..3 {
2512 tree.push_access_described_by(field, hint);
2513 }
2514 let update = tree.sync_accessibility();
2515 assert_eq!(
2516 find_node(&update, field).unwrap().described_by().len(),
2517 1,
2518 "the same target registered repeatedly is one relation"
2519 );
2520 }
2521
2522 #[test]
2524 fn access_live_assertive_set() {
2525 let mut tree = WidgetTree::new();
2526 let id = tree.add(FillWidget::new().access_live(Live::Assertive));
2527 tree.layout(SizeProposal::exact(50.0, 50.0));
2528 let update = tree.sync_accessibility();
2529 let node = find_node(&update, id).unwrap();
2530 assert_eq!(node.live(), Some(Live::Assertive));
2531 }
2532
2533 #[test]
2535 fn access_aria_current_set() {
2536 let mut tree = WidgetTree::new();
2537 let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
2538 tree.layout(SizeProposal::exact(50.0, 50.0));
2539 let update = tree.sync_accessibility();
2540 let node = find_node(&update, id).unwrap();
2541 assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
2542 }
2543
2544 #[test]
2546 fn access_has_popup_set() {
2547 let mut tree = WidgetTree::new();
2548 let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
2549 tree.layout(SizeProposal::exact(50.0, 50.0));
2550 let update = tree.sync_accessibility();
2551 let node = find_node(&update, id).unwrap();
2552 assert_eq!(node.has_popup(), Some(HasPopup::Menu));
2553 }
2554
2555 #[test]
2557 fn access_orientation_set() {
2558 let mut tree = WidgetTree::new();
2559 let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
2560 tree.layout(SizeProposal::exact(50.0, 50.0));
2561 let update = tree.sync_accessibility();
2562 let node = find_node(&update, id).unwrap();
2563 assert_eq!(node.orientation(), Some(Orientation::Vertical));
2564 }
2565
2566 #[test]
2568 fn access_numeric_value_and_range() {
2569 let mut tree = WidgetTree::new();
2570 let id = tree.add(
2571 FillWidget::new()
2572 .access_role(Role::Slider)
2573 .access_numeric_value(50.0)
2574 .access_numeric_range(0.0, 100.0)
2575 .access_numeric_step(5.0),
2576 );
2577 tree.layout(SizeProposal::exact(50.0, 50.0));
2578 let update = tree.sync_accessibility();
2579 let node = find_node(&update, id).unwrap();
2580 assert_eq!(node.numeric_value(), Some(50.0));
2581 assert_eq!(node.min_numeric_value(), Some(0.0));
2582 assert_eq!(node.max_numeric_value(), Some(100.0));
2583 assert_eq!(node.numeric_value_step(), Some(5.0));
2584 }
2585
2586 #[test]
2588 fn access_action_advertises_and_routes() {
2589 use crate::signal::Signal;
2590 let flag = Signal::new(false);
2591 let flag_for_cb = flag.clone();
2592 let mut tree = WidgetTree::new();
2593 let id = tree.add(
2594 FillWidget::new()
2595 .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
2596 );
2597 tree.layout(SizeProposal::exact(50.0, 50.0));
2598 let info = tree.accessibility_node(id);
2599 assert!(info.actions().contains(&Action::ShowContextMenu));
2600 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2601 action: Action::ShowContextMenu,
2602 target: Some(id),
2603 target_node: crate::accessibility::widget_id_to_node_id(id),
2604 data: None,
2605 });
2606 assert!(flag.get(), "callback should have been invoked");
2607 }
2608
2609 #[test]
2611 fn access_two_actions_both_route() {
2612 use crate::signal::Signal;
2613 let click = Signal::new(false);
2614 let increment = Signal::new(false);
2615 let click_cb = click.clone();
2616 let inc_cb = increment.clone();
2617 let mut tree = WidgetTree::new();
2618 let id = tree.add(
2619 FillWidget::new()
2620 .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
2621 .access_action(Action::Increment, move |_| inc_cb.set(true)),
2622 );
2623 tree.layout(SizeProposal::exact(50.0, 50.0));
2624 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2625 action: Action::ShowContextMenu,
2626 target: Some(id),
2627 target_node: crate::accessibility::widget_id_to_node_id(id),
2628 data: None,
2629 });
2630 assert!(click.get() && !increment.get(), "only first action fired");
2631 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2632 action: Action::Increment,
2633 target: Some(id),
2634 target_node: crate::accessibility::widget_id_to_node_id(id),
2635 data: None,
2636 });
2637 assert!(
2638 click.get() && increment.get(),
2639 "both actions fired exactly once each"
2640 );
2641 }
2642
2643 #[test]
2645 fn access_remove_action_suppresses_widget_action() {
2646 let mut tree = WidgetTree::new();
2647 let id = tree.add(ActionWidget.access_remove_action(Action::Click));
2648 tree.layout(SizeProposal::exact(50.0, 50.0));
2649 let info = tree.accessibility_node(id);
2650 assert!(!info.actions().contains(&Action::Click));
2651 assert!(info.actions().contains(&Action::Focus));
2652 }
2653
2654 #[test]
2656 fn access_custom_action_uses_localized_label() {
2657 let mut tree = WidgetTree::new();
2658 let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
2659 tree.layout(SizeProposal::exact(50.0, 50.0));
2660 let update = tree.sync_accessibility();
2661 let node = find_node(&update, id).unwrap();
2662 let actions = node.custom_actions();
2663 assert_eq!(actions.len(), 1);
2664 assert_eq!(actions[0].id, 0);
2665 assert_eq!(actions[0].description.as_str(), "Reply");
2666 }
2667
2668 #[test]
2670 fn access_custom_action_routes_by_index() {
2671 use crate::signal::Signal;
2672 let first = Signal::new(false);
2673 let second = Signal::new(false);
2674 let f = first.clone();
2675 let s = second.clone();
2676 let mut tree = WidgetTree::new();
2677 let id = tree.add(
2678 FillWidget::new()
2679 .access_custom_action_literal("First", move |_| f.set(true))
2680 .access_custom_action_literal("Second", move |_| s.set(true)),
2681 );
2682 tree.layout(SizeProposal::exact(50.0, 50.0));
2683 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2684 action: Action::CustomAction,
2685 target: Some(id),
2686 target_node: crate::accessibility::widget_id_to_node_id(id),
2687 data: Some(accesskit::ActionData::CustomAction(1)),
2688 });
2689 assert!(!first.get(), "first should not fire");
2690 assert!(second.get(), "second should fire (index 1)");
2691 }
2692
2693 #[test]
2695 fn access_action_layered_with_on_access_action() {
2696 use crate::signal::Signal;
2697 let from_override = Signal::new(false);
2698 let from_user = Signal::new(false);
2699 let ov_cb = from_override.clone();
2700 let user_cb = from_user.clone();
2701 let mut tree = WidgetTree::new();
2702 let id = tree.add(
2703 FillWidget::new()
2704 .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
2705 .on_access_action(move |_action, _ctx| {
2706 user_cb.set(true);
2707 crate::event::EventResponse::Handled
2708 }),
2709 );
2710 tree.layout(SizeProposal::exact(50.0, 50.0));
2711 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2712 action: Action::ShowContextMenu,
2713 target: Some(id),
2714 target_node: crate::accessibility::widget_id_to_node_id(id),
2715 data: None,
2716 });
2717 assert!(from_override.get(), "override callback fired");
2718 assert!(from_user.get(), "user catch-all fired");
2719 }
2720
2721 #[test]
2727 fn access_label_accepts_resolved_string_via_into() {
2728 struct ResolvedAtCall(String);
2732 impl From<ResolvedAtCall> for crate::signal::Prop<String> {
2733 fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
2734 crate::signal::Prop::Static(v.0)
2735 }
2736 }
2737 let mut tree = WidgetTree::new();
2738 let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
2739 tree.layout(SizeProposal::exact(50.0, 50.0));
2740 assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
2741 }
2742
2743 #[test]
2745 fn access_exclude_subtree_prunes_children_from_at_tree() {
2746 let mut tree = WidgetTree::new();
2747 let inner1 = tree.add(FillWidget::new().label("A"));
2748 let inner2 = tree.add(FillWidget::new().label("B"));
2749 let outer = tree.add(
2750 StackWidget::new()
2751 .child(inner1)
2752 .child(inner2)
2753 .access_label_literal("Section")
2757 .access_exclude_subtree(),
2758 );
2759 tree.layout(SizeProposal::exact(100.0, 50.0));
2760 let update = tree.sync_accessibility();
2761 assert!(find_node(&update, outer).is_some());
2763 assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
2764 assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
2765 let outer_node = find_node(&update, outer).unwrap();
2766 assert!(
2767 outer_node.children().is_empty(),
2768 "outer should have no AT children when excluded"
2769 );
2770 }
2771
2772 #[test]
2774 fn access_merge_subtree_concatenates_descendant_labels() {
2775 let mut tree = WidgetTree::new();
2776 let title = tree.add(FillWidget::new().label("Title"));
2777 let subtitle = tree.add(FillWidget::new().label("Subtitle"));
2778 let card = tree.add(
2779 StackWidget::new()
2780 .child(title)
2781 .child(subtitle)
2782 .access_merge_subtree(),
2783 );
2784 tree.layout(SizeProposal::exact(100.0, 50.0));
2785 assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
2787 let update = tree.sync_accessibility();
2788 assert!(find_node(&update, title).is_none());
2789 assert!(find_node(&update, subtitle).is_none());
2790 }
2791
2792 #[test]
2793 fn access_merge_subtree_takes_nothing_from_under_a_hidden_descendant() {
2794 let mut tree = WidgetTree::new();
2799 let title = tree.add(FillWidget::new().label("Title"));
2800 let secret = tree.add(FillWidget::new().label("Secret"));
2801 let hidden = tree.add(StackWidget::new().child(secret).access_hidden(true));
2802 let card = tree.add(
2803 StackWidget::new()
2804 .child(title)
2805 .child(hidden)
2806 .access_merge_subtree(),
2807 );
2808 tree.layout(SizeProposal::exact(100.0, 50.0));
2809 assert_eq!(tree.text_content(card), Some("Title".to_string()));
2810 }
2811
2812 #[test]
2814 fn access_merge_subtree_unions_actions() {
2815 let mut tree = WidgetTree::new();
2816 let click_a = tree.add(ClickableWidget);
2817 let click_b = tree.add(ClickableWidget);
2818 let card = tree.add(
2819 StackWidget::new()
2820 .child(click_a)
2821 .child(click_b)
2822 .access_merge_subtree(),
2823 );
2824 tree.layout(SizeProposal::exact(100.0, 50.0));
2825 let actions = tree.accessibility_node(card).actions().to_vec();
2826 let click_count = actions.iter().filter(|a| **a == Action::Click).count();
2827 assert_eq!(
2828 click_count, 1,
2829 "Click should be present exactly once after merge (deduplicated)"
2830 );
2831 }
2832
2833 #[test]
2843 fn access_merge_subtree_first_nonempty_value_wins() {
2844 #[derive(Debug)]
2845 struct ValueWidget(&'static str);
2846 impl Widget for ValueWidget {
2847 fn layout_response(
2848 &self,
2849 proposal: SizeProposal,
2850 _ctx: &LayoutContext,
2851 ) -> crate::widget::LayoutResponse {
2852 proposal.resolve(0.0, 0.0).into()
2853 }
2854 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2855 builder.set_role(Role::Slider);
2856 builder.set_value(self.0);
2857 }
2858 }
2859 let mut tree = WidgetTree::new();
2860 let v1 = tree.add(ValueWidget("first"));
2861 let v2 = tree.add(ValueWidget("second"));
2862 let card = tree.add(
2863 StackWidget::new()
2864 .child(v1)
2865 .child(v2)
2866 .access_merge_subtree(),
2867 );
2868 tree.layout(SizeProposal::exact(100.0, 50.0));
2869 assert_eq!(tree.text_value(card), Some("first".to_string()));
2870 }
2871
2872 #[test]
2874 fn access_exclude_inside_merge() {
2875 let mut tree = WidgetTree::new();
2876 let visible = tree.add(FillWidget::new().label("VISIBLE"));
2877 let pruned = tree.add(FillWidget::new().label("PRUNED"));
2878 let inner_excluded = tree.add(StackWidget::new().child(pruned).access_exclude_subtree());
2879 let card = tree.add(
2880 StackWidget::new()
2881 .child(visible)
2882 .child(inner_excluded)
2883 .access_merge_subtree(),
2884 );
2885 tree.layout(SizeProposal::exact(100.0, 50.0));
2886 let merged = tree.text_content(card).unwrap_or_default();
2887 assert!(merged.contains("VISIBLE"));
2890 assert!(
2891 !merged.contains("PRUNED"),
2892 "excluded subtree should not contribute to merge"
2893 );
2894 }
2895
2896 #[test]
2898 fn access_merge_inside_merge() {
2899 let mut tree = WidgetTree::new();
2900 let inner_a = tree.add(FillWidget::new().label("a"));
2901 let inner_b = tree.add(FillWidget::new().label("b"));
2902 let inner_card = tree.add(
2903 StackWidget::new()
2904 .child(inner_a)
2905 .child(inner_b)
2906 .access_merge_subtree(),
2907 );
2908 let outer_extra = tree.add(FillWidget::new().label("X"));
2909 let outer = tree.add(
2910 StackWidget::new()
2911 .child(inner_card)
2912 .child(outer_extra)
2913 .access_merge_subtree(),
2914 );
2915 tree.layout(SizeProposal::exact(200.0, 100.0));
2916 let merged = tree.text_content(outer).unwrap_or_default();
2921 assert!(merged.contains("a"));
2922 assert!(merged.contains("b"));
2923 assert!(merged.contains("X"));
2924 let update = tree.sync_accessibility();
2926 assert!(find_node(&update, inner_a).is_none());
2927 assert!(find_node(&update, inner_b).is_none());
2928 assert!(find_node(&update, inner_card).is_none());
2929 assert!(find_node(&update, outer_extra).is_none());
2930 }
2931
2932 #[test]
2934 fn access_customize_runs_last() {
2935 let mut tree = WidgetTree::new();
2936 let id = tree.add(
2937 FillWidget::new()
2938 .access_label_literal("A")
2939 .access_customize(|b| b.set_name("B")),
2940 );
2941 tree.layout(SizeProposal::exact(50.0, 50.0));
2942 assert_eq!(tree.accessibility_node(id).name(), Some("B"));
2943 }
2944
2945 #[test]
2947 fn access_customize_can_reach_inner_mut() {
2948 let mut tree = WidgetTree::new();
2949 let id = tree.add(FillWidget::new().access_customize(|b| {
2950 b.inner_mut().set_author_id("from-customize");
2951 }));
2952 tree.layout(SizeProposal::exact(50.0, 50.0));
2953 let update = tree.sync_accessibility();
2954 let node = find_node(&update, id).unwrap();
2955 assert_eq!(node.author_id(), Some("from-customize"));
2956 }
2957
2958 #[test]
2960 fn access_overrides_zero_cost_when_unused() {
2961 use std::mem::size_of;
2968 assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
2969 assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
2970 }
2971
2972 #[test]
2973 fn accessibility_children_overrides_at_reading_order() {
2974 #[derive(Debug)]
2979 struct NamedLeaf(&'static str);
2980 impl Widget for NamedLeaf {
2981 fn layout_response(
2982 &self,
2983 proposal: SizeProposal,
2984 _ctx: &LayoutContext,
2985 ) -> crate::widget::LayoutResponse {
2986 proposal.resolve(10.0, 10.0).into()
2987 }
2988 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2989 builder.set_role(accesskit::Role::Button);
2993 builder.set_name(self.0);
2994 }
2995 }
2996
2997 #[derive(Debug, Default)]
2998 struct ReorderContainer {
2999 a: Option<WidgetId>,
3000 b: Option<WidgetId>,
3001 }
3002 impl Widget for ReorderContainer {
3003 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
3004 let a = ctx.add(NamedLeaf("A"));
3005 let b = ctx.add(NamedLeaf("B"));
3006 self.a = Some(a);
3007 self.b = Some(b);
3008 vec![a, b]
3009 }
3010 fn layout_response(
3011 &self,
3012 proposal: SizeProposal,
3013 _ctx: &LayoutContext,
3014 ) -> crate::widget::LayoutResponse {
3015 proposal.resolve(100.0, 100.0).into()
3016 }
3017 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3018 builder.set_role(accesskit::Role::Group);
3021 builder.set_name("Container");
3022 }
3023 fn children(&self) -> Vec<WidgetId> {
3024 [self.a, self.b].into_iter().flatten().collect()
3025 }
3026 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
3027 Some([self.b, self.a].into_iter().flatten().collect())
3029 }
3030 }
3031
3032 let mut tree = WidgetTree::new();
3033 let container = tree.add(ReorderContainer::default());
3034 tree.layout(SizeProposal::exact(100.0, 100.0));
3035 let update = tree.sync_accessibility();
3036
3037 let a_nid = update
3038 .nodes
3039 .iter()
3040 .find(|(_, n)| n.label() == Some("A"))
3041 .map(|(id, _)| *id)
3042 .expect("A node present");
3043 let b_nid = update
3044 .nodes
3045 .iter()
3046 .find(|(_, n)| n.label() == Some("B"))
3047 .map(|(id, _)| *id)
3048 .expect("B node present");
3049 let cnid = crate::accessibility::widget_id_to_node_id(container);
3050 let cnode = update
3051 .nodes
3052 .iter()
3053 .find(|(id, _)| *id == cnid)
3054 .map(|(_, n)| n)
3055 .expect("container node present");
3056 let kids = cnode.children();
3057 let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
3058 let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
3059 assert!(
3060 pb < pa,
3061 "accessibility_children() must set AT order B-before-A; got {kids:?}"
3062 );
3063 }
3064
3065 fn consumer_bounding_box(update: &accesskit::TreeUpdate, label: &str) -> accesskit::Rect {
3079 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
3080 let state = consumer.state();
3081 let mut stack = vec![state.root()];
3082 while let Some(node) = stack.pop() {
3083 let text = node.value().or_else(|| node.label());
3086 if text.as_deref() == Some(label) {
3087 return node.bounding_box().expect("node has bounds");
3088 }
3089 for child in node.children() {
3090 stack.push(child);
3091 }
3092 }
3093 panic!("no node labelled {label:?} in the consumer tree");
3094 }
3095
3096 fn tree_at_scale(scale: f32) -> (WidgetTree, WidgetId) {
3097 let mut tree = WidgetTree::new();
3098 let id = tree.add(FillWidget::new().label("content"));
3099 tree.set_device_scale_factor(scale);
3100 tree.layout(SizeProposal::exact(200.0, 100.0));
3101 (tree, id)
3102 }
3103
3104 #[test]
3105 fn the_root_carries_the_device_scale_as_its_transform() {
3106 let (mut tree, _) = tree_at_scale(2.0);
3107 let update = tree.sync_accessibility();
3108 let root = update
3109 .nodes
3110 .iter()
3111 .find(|(id, _)| *id == crate::accessibility::root_node_id())
3112 .map(|(_, n)| n)
3113 .expect("root node present");
3114 assert_eq!(
3115 root.transform().copied(),
3116 Some(accesskit::Affine::scale(2.0)),
3117 "AccessKit wants physical coordinates; the tree emits logical ones"
3118 );
3119 assert_a11y_tree_valid(&update);
3120 }
3121
3122 #[test]
3123 fn emitted_bounds_stay_logical_at_scale_two() {
3124 let (mut tree, id) = tree_at_scale(2.0);
3133 let update = tree.sync_accessibility();
3134 let layout = tree.bounds(id);
3135 let nid = crate::accessibility::widget_id_to_node_id(id);
3136 let raw = update
3137 .nodes
3138 .iter()
3139 .find(|(n, _)| *n == nid)
3140 .and_then(|(_, n)| n.bounds())
3141 .expect("widget node has bounds");
3142 assert_eq!(
3143 (raw.x0, raw.y0, raw.x1, raw.y1),
3144 (
3145 layout.x as f64,
3146 layout.y as f64,
3147 (layout.x + layout.width) as f64,
3148 (layout.y + layout.height) as f64,
3149 ),
3150 "an emitted rectangle must be the logical layout rectangle, unscaled"
3151 );
3152 }
3153
3154 #[test]
3173 fn an_interactive_node_emits_its_exact_layout_rectangle() {
3174 let mut tree = WidgetTree::new();
3175 let id = tree.add(ActionWidget);
3176 tree.layout(SizeProposal::exact(200.0, 100.0));
3177 let update = tree.sync_accessibility();
3178 let nid = crate::accessibility::widget_id_to_node_id(id);
3179 let node = update
3180 .nodes
3181 .iter()
3182 .find(|(n, _)| *n == nid)
3183 .map(|(_, n)| n)
3184 .expect("the interactive node is in the update");
3185 assert!(
3186 node.supports_action(accesskit::Action::Click),
3187 "the fixture has to be the clickable shape the rule is about, \
3188 or this test guards nothing"
3189 );
3190 let layout = tree.bounds(id);
3191 assert!(
3192 layout.width > 0.0 && layout.height > 0.0,
3193 "a real, non-degenerate rectangle to compare against: {layout:?}"
3194 );
3195 let raw = node.bounds().expect("the interactive node has bounds");
3196 assert_eq!(
3197 (raw.x0, raw.y0, raw.x1, raw.y1),
3198 (
3199 layout.x as f64,
3200 layout.y as f64,
3201 (layout.x + layout.width) as f64,
3202 (layout.y + layout.height) as f64,
3203 ),
3204 "a clickable node's emitted rectangle is its layout rectangle, \
3205 not an outset one"
3206 );
3207 }
3208
3209 #[test]
3210 fn what_an_assistive_technology_reads_is_the_physical_rectangle() {
3211 let (mut one, _) = tree_at_scale(1.0);
3215 let at_one = consumer_bounding_box(&one.sync_accessibility(), "content");
3216 let (mut two, _) = tree_at_scale(2.0);
3217 let at_two = consumer_bounding_box(&two.sync_accessibility(), "content");
3218 assert_eq!(at_two.x0, at_one.x0 * 2.0);
3219 assert_eq!(at_two.y0, at_one.y0 * 2.0);
3220 assert_eq!(at_two.x1, at_one.x1 * 2.0);
3221 assert_eq!(at_two.y1, at_one.y1 * 2.0);
3222 assert!(at_two.x1 > at_two.x0, "a real, non-degenerate rectangle");
3223 }
3224
3225 #[test]
3226 fn a_scale_change_invalidates_the_accessibility_cache() {
3227 let (mut tree, _) = tree_at_scale(1.0);
3233 let _ = tree.sync_accessibility();
3234 tree.set_device_scale_factor(2.0);
3235 let update = tree.sync_accessibility();
3236 let root = update
3237 .nodes
3238 .iter()
3239 .find(|(id, _)| *id == crate::accessibility::root_node_id())
3240 .map(|(_, n)| n)
3241 .expect("root node present");
3242 assert_eq!(
3243 root.transform().copied(),
3244 Some(accesskit::Affine::scale(2.0))
3245 );
3246 }
3247
3248 #[test]
3249 fn setting_the_same_scale_does_not_dirty_the_tree() {
3250 let (mut tree, _) = tree_at_scale(2.0);
3254 let first = tree.sync_accessibility();
3255 tree.set_device_scale_factor(2.0);
3256 let second = tree.sync_accessibility();
3257 assert_eq!(first, second);
3258 }
3259
3260 #[test]
3263 fn explore_by_touch_is_off_by_default() {
3264 let tree = WidgetTree::new();
3265 assert_eq!(tree.explore_by_touch(), crate::ExploreByTouch::Off);
3266 assert_eq!(
3267 tree.screen_reader_state(),
3268 crate::ScreenReaderState::Unknown
3269 );
3270 assert!(!tree.explore_by_touch_active());
3271 }
3272
3273 #[test]
3274 fn auto_follows_the_operating_systems_screen_reader_flag() {
3275 let mut tree = WidgetTree::new();
3276 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3277 assert!(!tree.explore_by_touch_active(), "Unknown is not a yes");
3278 tree.set_screen_reader_state(crate::ScreenReaderState::Inactive);
3279 assert!(!tree.explore_by_touch_active());
3280 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3281 assert!(tree.explore_by_touch_active());
3282 }
3283
3284 #[test]
3285 fn on_ignores_the_screen_reader_flag_and_off_ignores_everything() {
3286 let mut tree = WidgetTree::new();
3287 tree.set_explore_by_touch(crate::ExploreByTouch::On);
3288 assert!(tree.explore_by_touch_active(), "On is the app's own choice");
3289 tree.set_explore_by_touch(crate::ExploreByTouch::Off);
3290 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3291 assert!(!tree.explore_by_touch_active());
3292 }
3293
3294 #[test]
3295 fn an_attached_accesskit_client_does_not_switch_exploring_on() {
3296 let mut tree = WidgetTree::new();
3302 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3303 tree.set_at_client_attached(true);
3304 assert!(tree.at_client_attached());
3305 assert_eq!(
3306 tree.screen_reader_state(),
3307 crate::ScreenReaderState::Unknown,
3308 "attaching says nothing about screen readers"
3309 );
3310 assert!(!tree.explore_by_touch_active());
3311 }
3312
3313 #[test]
3314 fn touch_still_reaches_a_handler_with_an_accesskit_client_attached() {
3315 use crate::Modifiers;
3321 use crate::pointer::{
3322 BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase,
3323 PointerSample,
3324 };
3325 use crate::widget_builder::WidgetBuilder;
3326 use std::cell::Cell;
3327 use std::rc::Rc;
3328
3329 let taps = Rc::new(Cell::new(0u32));
3330 let seen = Rc::clone(&taps);
3331 let mut tree = WidgetTree::new();
3332 let id = tree.add(
3333 FillWidget::new()
3334 .label("target")
3335 .on_tap(move |_e, _ctx| seen.set(seen.get() + 1)),
3336 );
3337 tree.layout(SizeProposal::exact(200.0, 100.0));
3338 tree.set_at_client_attached(true);
3339 assert!(!tree.explore_by_touch_active());
3340
3341 let at = Point::new(50.0, 50.0);
3342 let pid = PointerIdAllocator::global().begin(BackendDeviceKey::new(0x0A11), 1);
3343 let contact = |phase| PointerSample {
3344 pointer: PointerInfo::touch(pid, EventTime::ZERO),
3345 phase,
3346 position: at,
3347 button: None,
3348 modifiers: Modifiers::NONE,
3349 coalesced: Vec::new(),
3350 };
3351 tree.dispatch_pointer(contact(PointerPhase::Down));
3352 tree.dispatch_pointer(contact(PointerPhase::Up));
3353 assert!(tree.bounds(id).width > 0.0);
3354 assert_eq!(taps.get(), 1, "a touch tap must still activate its handler");
3355 }
3356
3357 #[test]
3358 fn a_detaching_client_is_evidence_that_no_screen_reader_is_reading() {
3359 let mut tree = WidgetTree::new();
3363 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3364 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3365 tree.set_at_client_attached(true);
3366 assert!(tree.explore_by_touch_active());
3367 tree.set_at_client_attached(false);
3368 assert_eq!(
3369 tree.screen_reader_state(),
3370 crate::ScreenReaderState::Inactive
3371 );
3372 assert!(!tree.explore_by_touch_active());
3373 }
3374
3375 #[test]
3376 fn a_repeated_detach_notice_does_not_clobber_a_fresh_os_reading() {
3377 let mut tree = WidgetTree::new();
3381 tree.set_at_client_attached(false);
3382 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3383 tree.set_at_client_attached(false);
3384 assert_eq!(tree.screen_reader_state(), crate::ScreenReaderState::Active);
3385 }
3386
3387 #[test]
3390 fn a_density_switch_announces_once_in_the_applications_own_words() {
3391 use teksilo_tokens::TargetDensity;
3392 let mut tree = tree_with_one_widget();
3393 tree.set_density_announcement(Some(std::rc::Rc::new(|d: TargetDensity| {
3394 format!("layout {d:?}")
3395 })));
3396 let _ = tree.sync_accessibility();
3397
3398 let mut arrivals = Arrivals::attach(tree.accessibility_tree_snapshot());
3399
3400 tree.set_input_density(TargetDensity::Comfortable);
3401 let update = tree.sync_accessibility();
3402 assert_eq!(
3403 arrivals.feed(update),
3404 vec![("layout Comfortable".to_string(), accesskit::Live::Polite)]
3405 );
3406
3407 let _ = arrivals.feed(tree.sync_accessibility());
3409 tree.set_input_density(TargetDensity::Comfortable);
3410 let update = tree.sync_accessibility();
3411 assert_eq!(arrivals.feed(update), Vec::new());
3412 }
3413
3414 #[test]
3415 fn a_density_switch_is_silent_without_a_registered_wording() {
3416 use teksilo_tokens::TargetDensity;
3421 let mut tree = tree_with_one_widget();
3422 let _ = tree.sync_accessibility();
3423 tree.set_input_density(TargetDensity::Comfortable);
3424 let update = tree.sync_accessibility();
3425 assert_eq!(announced_by_consumer(&update), Vec::new());
3426 }
3427
3428 #[test]
3429 fn an_announcement_stays_quiet_when_the_widget_already_speaks() {
3430 let mut tree = WidgetTree::new();
3431 use crate::widget_builder::WidgetBuilder;
3432 let widget = tree.add(
3433 FillWidget::new()
3434 .label("Saved")
3435 .access_live(accesskit::Live::Polite),
3436 );
3437 tree.layout(SizeProposal::exact(200.0, 100.0));
3438 let _ = tree.sync_accessibility();
3440
3441 assert!(
3442 !tree.announce_unless_widget_speaks(widget, "Saved"),
3443 "a widget with its own live region must not be doubled"
3444 );
3445 let update = tree.sync_accessibility();
3446 let live = live_nodes_in_filtered_tree(&update);
3450 assert_eq!(live.len(), 1, "exactly one voice, not two: {live:?}");
3451 }
3452
3453 #[test]
3454 fn an_announcement_goes_ahead_when_the_widget_is_silent() {
3455 let mut tree = WidgetTree::new();
3456 let widget = tree.add(FillWidget::new().label("Row"));
3457 tree.layout(SizeProposal::exact(200.0, 100.0));
3458 let _ = tree.sync_accessibility();
3459
3460 assert!(tree.announce_unless_widget_speaks(widget, "Moved to 3 of 12"));
3461 let update = tree.sync_accessibility();
3462 assert_eq!(
3463 announced_by_consumer(&update),
3464 vec![("Moved to 3 of 12".to_string(), accesskit::Live::Polite)]
3465 );
3466 }
3467
3468 #[test]
3469 fn a_tree_that_records_no_announcements_still_hears_the_widget_speak() {
3470 let mut tree = WidgetTree::new();
3475 tree.set_records_announcements(false);
3476 use crate::widget_builder::WidgetBuilder;
3477 let widget = tree.add(
3478 FillWidget::new()
3479 .label("Saved")
3480 .access_live(accesskit::Live::Polite),
3481 );
3482 tree.layout(SizeProposal::exact(200.0, 100.0));
3483 let _ = tree.sync_accessibility();
3484 assert!(!tree.announce_unless_widget_speaks(widget, "Saved"));
3485 }
3486
3487 #[test]
3488 fn a_live_region_no_platform_hears_does_not_count_as_speaking() {
3489 let mut tree = WidgetTree::new();
3494 use crate::widget_builder::WidgetBuilder;
3495 let widget = tree.add(
3496 FillWidget::new()
3497 .access_role(accesskit::Role::Status)
3498 .access_value("Saved")
3499 .access_live(accesskit::Live::Polite),
3500 );
3501 tree.layout(SizeProposal::exact(200.0, 100.0));
3502 let _ = tree.sync_accessibility();
3503 assert!(tree.announce_unless_widget_speaks(widget, "Saved"));
3504 }
3505
3506 #[test]
3507 fn a_hidden_live_region_does_not_count_as_speaking() {
3508 let mut tree = WidgetTree::new();
3510 use crate::widget_builder::WidgetBuilder;
3511 let widget = tree.add(
3512 FillWidget::new()
3513 .label("Saved")
3514 .access_live(accesskit::Live::Polite)
3515 .access_hidden(true),
3516 );
3517 tree.layout(SizeProposal::exact(200.0, 100.0));
3518 let _ = tree.sync_accessibility();
3519 assert!(tree.announce_unless_widget_speaks(widget, "Saved"));
3520 }
3521
3522 #[test]
3523 fn a_live_region_around_the_widget_does_not_count_as_the_widget_speaking() {
3524 let mut tree = WidgetTree::new();
3530 use crate::widget_builder::WidgetBuilder;
3531 let region = tree.add(
3532 StackWidget::new()
3533 .access_role(accesskit::Role::Group)
3534 .access_label("Notification")
3535 .access_live(accesskit::Live::Polite),
3536 );
3537 let button = tree.add_child(
3538 region,
3539 FillWidget::new()
3540 .label("Annuler")
3541 .access_role(accesskit::Role::Button),
3542 );
3543 tree.layout(SizeProposal::exact(200.0, 100.0));
3544 let _ = tree.sync_accessibility();
3545 assert!(tree.announce_unless_widget_speaks(button, "Menu des actions ouvert"));
3546 }
3547
3548 #[test]
3549 fn a_live_region_with_no_text_does_not_count_as_speaking() {
3550 let mut tree = WidgetTree::new();
3553 use crate::widget_builder::WidgetBuilder;
3554 let widget = tree.add(FillWidget::new().access_live(accesskit::Live::Polite));
3555 tree.layout(SizeProposal::exact(200.0, 100.0));
3556 let _ = tree.sync_accessibility();
3557 assert!(tree.announce_unless_widget_speaks(widget, "Something happened"));
3558 }
3559
3560 #[derive(Debug)]
3568 struct SpeakingSynthetic;
3569
3570 impl crate::widget::Widget for SpeakingSynthetic {
3571 fn layout_response(
3572 &self,
3573 proposal: SizeProposal,
3574 _ctx: &crate::widget::LayoutContext,
3575 ) -> crate::widget::LayoutResponse {
3576 proposal.resolve(0.0, 0.0).into()
3577 }
3578
3579 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3580 builder.set_role(accesskit::Role::Group);
3581 let child = builder.push_scene_child(
3582 1,
3583 crate::accessibility::SyntheticKind::SceneItem,
3584 |item| {
3585 item.set_role(accesskit::Role::Status);
3586 item.set_name("2 items selected");
3587 },
3588 );
3589 builder.with_collected_node(child, |node| node.set_live(accesskit::Live::Polite));
3590 }
3591 }
3592
3593 #[test]
3594 fn a_live_synthetic_child_counts_as_the_widget_speaking() {
3595 let mut tree = WidgetTree::new();
3600 let widget = tree.add(SpeakingSynthetic);
3601 tree.layout(SizeProposal::exact(200.0, 100.0));
3602 let update = tree.sync_accessibility();
3603 assert!(
3604 update
3605 .nodes
3606 .iter()
3607 .any(|(id, _)| crate::accessibility::is_synthetic(*id)),
3608 "the fixture must actually emit a synthetic child"
3609 );
3610 assert!(!tree.announce_unless_widget_speaks(widget, "2 items selected"));
3611 }
3612
3613 #[test]
3614 fn a_descendants_live_region_silences_its_ancestor_too() {
3615 let mut tree = WidgetTree::new();
3618 let container = tree.add(StackWidget::new());
3619 use crate::widget_builder::WidgetBuilder;
3620 let inner = FillWidget::new()
3621 .label("3 results")
3622 .access_live(accesskit::Live::Polite);
3623 tree.add_child(container, inner);
3624 tree.layout(SizeProposal::exact(200.0, 100.0));
3625 let _ = tree.sync_accessibility();
3626 assert!(!tree.announce_unless_widget_speaks(container, "3 results"));
3627 }
3628}