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 #[doc(hidden)]
220 pub fn accessibility_delivery_owed(&self, window_focused: bool) -> bool {
221 self.cached_a11y.is_none() || self.adapter_ids.owes(window_focused)
222 }
223
224 pub fn resolve_adapter_action(
229 &self,
230 request: accesskit::ActionRequest,
231 ) -> Option<accesskit::ActionRequest> {
232 self.adapter_ids.own_request(request)
233 }
234
235 pub fn a11y_walk_generation(&self) -> u64 {
241 self.a11y_walk_generation
242 }
243
244 fn patch_accessibility_bounds(&mut self) -> bool {
258 let moved = self.arena.take_a11y_moved();
259 if moved.is_empty() {
260 return false;
261 }
262 let Some(cached) = self.cached_a11y.as_mut() else {
263 return false;
264 };
265 let index: std::collections::HashMap<accesskit::NodeId, usize> = cached
266 .nodes
267 .iter()
268 .enumerate()
269 .map(|(i, (id, _))| (*id, i))
270 .collect();
271
272 for &id in moved.keys() {
273 let bounds = self.arena.bounds(id);
274 if let Some(&slot) = index.get(&crate::accessibility::widget_id_to_node_id(id)) {
275 cached.nodes[slot].1.set_bounds(to_accesskit_rect(bounds));
276 }
277 }
278
279 for (&syn_id, &owner) in &self.synthetic_parent_map {
284 let Some(delta) = moved.get(&owner) else {
285 continue;
286 };
287 let Some(&slot) = index.get(&syn_id) else {
288 continue;
289 };
290 let node = &mut cached.nodes[slot].1;
291 if node.transform().is_some() {
299 continue;
300 }
301 if let Some(local) = self.synthetic_local_bounds.get(&syn_id) {
302 let origin = self.arena.bounds(owner).origin();
303 node.set_bounds(to_accesskit_rect(teksilo_canvas::Rect::new(
304 origin.x + local.x,
305 origin.y + local.y,
306 local.width,
307 local.height,
308 )));
309 } else if let Some(rect) = node.bounds() {
310 node.set_bounds(accesskit::Rect {
311 x0: rect.x0 + delta.x as f64,
312 y0: rect.y0 + delta.y as f64,
313 x1: rect.x1 + delta.x as f64,
314 y1: rect.y1 + delta.y as f64,
315 });
316 }
317 }
318 self.adapter_ids.source_changed();
321 true
322 }
323
324 pub fn accessibility_tree_snapshot(&self) -> accesskit::TreeUpdate {
338 self.build_accessibility_tree(false).0
339 }
340
341 fn step_announcers(&mut self) {
344 let now = self.animation_clock();
348 let polite = self.announcer_polite.step(now, &mut self.announcer_ids);
349 let assertive = self.announcer_assertive.step(now, &mut self.announcer_ids);
350 if polite || assertive {
351 self.a11y_dirty = true;
352 }
353 self.wake_for_retirement();
354 }
355
356 fn settle_announcers(&mut self, admitted: bool) -> bool {
360 if admitted {
361 let now = self.animation_clock();
362 self.announcer_polite.admit(now);
363 self.announcer_assertive.admit(now);
364 self.wake_for_retirement();
365 }
366 self.announcer_polite.busy() || self.announcer_assertive.busy()
367 }
368
369 fn wake_for_retirement(&self) {
374 if !self.sim_time_frozen
375 && let Some(at) = [
376 self.announcer_polite.next_retirement(),
377 self.announcer_assertive.next_retirement(),
378 ]
379 .into_iter()
380 .flatten()
381 .min()
382 {
383 self.request_wake_at(at);
384 }
385 }
386
387 pub fn dispatch_access_action(
410 &mut self,
411 node_id: accesskit::NodeId,
412 action: accesskit::Action,
413 data: Option<accesskit::ActionData>,
414 ops: &mut dyn crate::window::WindowOps,
415 ) -> bool {
416 let target = crate::accessibility::node_id_to_widget_id_maybe(node_id)
417 .or_else(|| self.widget_for_synthetic(node_id));
418 let event = crate::event::WidgetEvent::AccessAction {
419 action,
420 target,
421 target_node: node_id,
422 data,
423 };
424 self.access_action_handled = false;
425 self.dispatch_event_with_ops(event, ops);
426 self.access_action_handled
427 }
428
429 pub fn widget_for_synthetic(&self, node_id: accesskit::NodeId) -> Option<WidgetId> {
438 self.synthetic_parent_map.get(&node_id).copied()
439 }
440
441 pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
466 self.arena
467 .get(content_id)
468 .and_then(|node| node.widget.as_any())
469 .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
470 .and_then(|d| d.materialized_child())
471 .unwrap_or(content_id)
472 }
473
474 pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
475 let content_id = self.tooltip_content_node(content_id);
476 let node = self.arena.get(content_id)?;
477 let mut probe = AccessNodeBuilder::for_name_probe(content_id);
478 node.widget.accessibility(&mut probe);
479 if let Some(ov) = node.access_overrides.as_deref() {
480 ov.apply(&mut probe);
481 }
482 probe
483 .name()
484 .map(str::trim)
485 .filter(|name| !name.is_empty())
486 .map(str::to_owned)
487 }
488
489 pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
490 let builder = self.build_overridden_builder(id);
492 let role = builder.role();
493 let name = builder.name().map(|s| s.to_string());
494 let actions = builder.actions().to_vec();
495 let mut info = AccessibilityInfo::new(role, name, actions);
496 if let Some(toggled) = builder.toggled() {
497 info = info.with_toggled(toggled);
498 }
499 if let Some(expanded) = builder.expanded() {
500 info = info.with_expanded(expanded);
501 }
502 if let Some(selected) = builder.selected() {
503 info = info.with_selected(selected);
504 }
505 let disabled = self.access_disabled_override(
510 id,
511 self.live_accessibility_proxy(id).is_some(),
512 &self.composites_standing_behind(id),
513 );
514 let force_clear_disabled = disabled == Some(false);
515 let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
516 let disabled_override = disabled == Some(true);
521 if disabled_arena || disabled_override {
522 info = info.with_disabled(true);
523 }
524 if builder.is_hidden() {
525 info = info.with_hidden(true);
526 }
527 info
528 }
529
530 pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
531 self.arena
532 .active_ids_iter()
533 .find(|&id| self.build_overridden_builder(id).role() == role)
534 }
535
536 pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
537 self.arena
538 .active_ids_iter()
539 .find(|&id| self.build_overridden_builder(id).name() == Some(label))
540 }
541
542 pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
543 self.arena.active_ids_iter().find(|&id| {
544 self.build_overridden_builder(id)
545 .actions()
546 .contains(&action)
547 })
548 }
549
550 pub fn text_content(&self, id: WidgetId) -> Option<String> {
554 self.arena.get(id)?;
555 self.build_overridden_builder(id)
556 .name()
557 .map(|s| s.to_string())
558 }
559
560 pub fn text_value(&self, id: WidgetId) -> Option<String> {
564 self.arena.get(id)?;
565 self.build_overridden_builder(id)
566 .value()
567 .map(|s| s.to_string())
568 }
569}
570
571pub(crate) fn emit_root_transform(root: &mut accesskit::Node, device_scale_factor: f32) {
600 root.set_transform(accesskit::Affine::scale(device_scale_factor as f64));
601}
602
603#[cfg(test)]
604pub(crate) mod test_helpers {
605 pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
611 accesskit_consumer::Tree::new(update.clone(), false);
612 }
613
614 pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
623 let emitted: std::collections::HashSet<accesskit::NodeId> =
624 update.nodes.iter().map(|(id, _)| *id).collect();
625 for (parent_id, node) in &update.nodes {
626 for &target in node.controls() {
627 assert!(
628 emitted.contains(&target),
629 "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
630 );
631 }
632 for &target in node.described_by() {
633 assert!(
634 emitted.contains(&target),
635 "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
636 );
637 }
638 for &target in node.labelled_by() {
639 assert!(
640 emitted.contains(&target),
641 "node {parent_id:?} has labelled_by() → {target:?} which is absent from the tree"
642 );
643 }
644 if let Some(target) = node.next_on_line() {
645 assert!(
646 emitted.contains(&target),
647 "node {parent_id:?} has next_on_line() → {target:?} which is absent from the tree"
648 );
649 }
650 if let Some(target) = node.previous_on_line() {
651 assert!(
652 emitted.contains(&target),
653 "node {parent_id:?} has previous_on_line() → {target:?} which is absent from the tree"
654 );
655 }
656 }
657 }
658
659 #[allow(dead_code)]
661 pub(crate) fn nodes_with_role(
662 update: &accesskit::TreeUpdate,
663 role: accesskit::Role,
664 ) -> Vec<accesskit::NodeId> {
665 update
666 .nodes
667 .iter()
668 .filter(|(_, node)| node.role() == role)
669 .map(|(id, _)| *id)
670 .collect()
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::test_helpers::*;
677 use super::*;
678 use crate::test_widgets::{FillWidget, StackWidget};
679
680 const ANNOUNCER_NODES: usize = 0;
685
686 struct Arrivals {
692 replay: accesskit_consumer::Tree,
693 }
694
695 impl Arrivals {
696 fn attach(update: accesskit::TreeUpdate) -> Self {
697 Self {
698 replay: accesskit_consumer::Tree::new(update, false),
699 }
700 }
701
702 fn feed(&mut self, update: accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
703 struct Handler(Vec<(String, accesskit::Live)>);
704 fn included(node: &accesskit_consumer::NodeRef) -> bool {
705 accesskit_consumer::common_filter(node) == accesskit_consumer::FilterResult::Include
706 }
707 impl Handler {
708 fn entered(&mut self, node: &accesskit_consumer::NodeRef) {
709 if node.live() != accesskit::Live::Off
710 && let Some(label) = node.label()
711 {
712 self.0.push((label, node.live()));
713 }
714 }
715 }
716 impl accesskit_consumer::TreeChangeHandler for Handler {
717 fn node_added(&mut self, node: &accesskit_consumer::NodeRef) {
718 if included(node) {
719 self.entered(node);
720 }
721 }
722 fn node_updated(
723 &mut self,
724 old: &accesskit_consumer::NodeRef,
725 new: &accesskit_consumer::NodeRef,
726 ) {
727 if !included(old) && included(new) {
728 self.entered(new);
729 }
730 }
731 fn focus_moved(
732 &mut self,
733 _old: Option<&accesskit_consumer::NodeRef>,
734 _new: Option<&accesskit_consumer::NodeRef>,
735 ) {
736 }
737 fn node_removed(&mut self, _node: &accesskit_consumer::NodeRef) {}
738 }
739 let mut handler = Handler(Vec::new());
740 self.replay.update_and_process_changes(update, &mut handler);
741 handler.0
742 }
743 }
744
745 fn announced_by_consumer(update: &accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
762 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
763 let state = consumer.state();
764 let mut found = Vec::new();
765 let mut stack = vec![state.root()];
766 while let Some(node) = stack.pop() {
767 if node.live() != accesskit::Live::Off
768 && let Some(label) = node.label()
769 {
770 found.push((label, node.live()));
771 }
772 for child in node.filtered_children(&accesskit_consumer::common_filter) {
773 stack.push(child);
774 }
775 }
776 found
777 }
778
779 fn live_nodes_in_filtered_tree(update: &accesskit::TreeUpdate) -> Vec<String> {
788 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
789 let state = consumer.state();
790 let mut found = Vec::new();
791 let mut stack = vec![state.root()];
792 while let Some(node) = stack.pop() {
793 if node.live() != accesskit::Live::Off {
794 found.push(format!("{:?}", node.id()));
795 }
796 for child in node.filtered_children(&accesskit_consumer::common_filter) {
797 stack.push(child);
798 }
799 }
800 found.sort();
801 found
802 }
803
804 fn tree_with_one_widget() -> WidgetTree {
805 let mut tree = WidgetTree::new();
806 tree.add(FillWidget::new().label("content"));
807 tree.layout(SizeProposal::exact(200.0, 100.0));
808 tree
809 }
810
811 #[test]
815 fn an_announcement_enters_the_filtered_tree_then_leaves_it() {
816 let mut tree = tree_with_one_widget();
817 tree.advance_time(std::time::Duration::ZERO);
818 let update = tree.sync_accessibility();
821 assert_eq!(announced_by_consumer(&update), Vec::new());
822 assert_eq!(
823 live_nodes_in_filtered_tree(&update),
824 Vec::<String>::new(),
825 "an idle announcer must put nothing in the filtered tree"
826 );
827 assert_a11y_tree_valid(&update);
828 let mut arrivals = Arrivals::attach(update);
829
830 tree.announce("Event added");
831
832 let exposed = tree.sync_accessibility();
833 assert_eq!(
834 announced_by_consumer(&exposed),
835 vec![("Event added".to_string(), accesskit::Live::Polite)],
836 "the message must be a label on a live node inside the filtered tree"
837 );
838 assert_eq!(
839 live_nodes_in_filtered_tree(&exposed).len(),
840 1,
841 "exactly one announcer node must have entered the filtered tree"
842 );
843 assert_a11y_tree_valid(&exposed);
844 assert_eq!(
845 arrivals.feed(exposed),
846 vec![("Event added".to_string(), accesskit::Live::Polite)]
847 );
848
849 tree.advance_time(std::time::Duration::from_millis(16));
852 let lingering = tree.sync_accessibility();
853 assert_eq!(live_nodes_in_filtered_tree(&lingering).len(), 1);
854 assert_eq!(
855 arrivals.feed(lingering),
856 Vec::new(),
857 "a message is said once"
858 );
859
860 tree.advance_time(crate::announcer::LINGER);
862 let gone = tree.sync_accessibility();
863 assert_eq!(
864 live_nodes_in_filtered_tree(&gone),
865 Vec::<String>::new(),
866 "the live node must leave the filtered tree entirely"
867 );
868 assert_a11y_tree_valid(&gone);
869 }
870
871 #[test]
876 fn the_same_message_announced_twice_is_exposed_twice() {
877 let mut tree = tree_with_one_widget();
878 tree.advance_time(std::time::Duration::ZERO);
879 let mut arrivals = Arrivals::attach(tree.sync_accessibility());
880 tree.announce("Saved");
881 tree.announce("Saved");
882
883 let mut heard = Vec::new();
884 let mut ids = std::collections::HashSet::new();
885 for _ in 0..6 {
886 let update = tree.sync_accessibility();
887 ids.extend(
888 update
889 .nodes
890 .iter()
891 .filter(|(id, _)| crate::announcer::is_announcer_node(*id))
892 .map(|(id, _)| *id),
893 );
894 heard.extend(arrivals.feed(update).into_iter().map(|(text, _)| text));
895 tree.advance_time(std::time::Duration::from_millis(16));
896 }
897 assert_eq!(
898 heard,
899 ["Saved", "Saved"],
900 "each announcement is its own arrival"
901 );
902 assert_eq!(
903 ids.len(),
904 2,
905 "each announcement is spoken from its own node"
906 );
907 }
908
909 #[test]
910 fn an_assertive_announcement_uses_the_assertive_live_setting() {
911 let mut tree = tree_with_one_widget();
912 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
913 let update = tree.sync_accessibility();
914 assert_eq!(
915 announced_by_consumer(&update),
916 vec![("Could not save".to_string(), accesskit::Live::Assertive)]
917 );
918 }
919
920 #[test]
923 fn the_two_politeness_levels_announce_independently() {
924 let mut tree = tree_with_one_widget();
925 tree.announce("Event added");
926 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
927
928 let mut spoken = announced_by_consumer(&tree.sync_accessibility());
929 spoken.sort_by(|a, b| a.0.cmp(&b.0));
930 assert_eq!(
931 spoken,
932 vec![
933 ("Could not save".to_string(), accesskit::Live::Assertive),
934 ("Event added".to_string(), accesskit::Live::Polite),
935 ],
936 "both levels must be exposed on the same update"
937 );
938 }
939
940 #[test]
944 fn announcing_requests_the_syncs_it_needs() {
945 let mut tree = tree_with_one_widget();
946 let _ = tree.sync_accessibility();
947 tree.a11y_update_requested.set(false);
948 tree.frame_tick_requested.set(false);
949
950 tree.announce("Event added");
951 assert!(
952 tree.a11y_update_requested.get(),
953 "an announcement must dirty the accessibility tree"
954 );
955 assert!(
956 tree.frame_requested(),
957 "an announcement must ask for the frame that carries it"
958 );
959
960 tree.announce("3 events");
963 tree.a11y_update_requested.set(false);
964 tree.frame_tick_requested.set(false);
965 let _ = tree.sync_accessibility();
966 assert!(tree.a11y_update_requested.get());
967 assert!(tree.frame_requested());
968
969 tree.a11y_update_requested.set(false);
972 tree.frame_tick_requested.set(false);
973 let before = std::time::Instant::now();
974 let _ = tree.sync_accessibility();
975 assert!(!tree.a11y_update_requested.get());
976 assert!(!tree.frame_requested());
977 let wake = tree
978 .next_timer_deadline()
979 .expect("a message in the tree asks to be woken when it leaves");
980 assert!(wake > before + crate::announcer::LINGER / 2);
981 }
982
983 #[test]
993 fn a_throttled_subscriber_does_not_hold_back_an_announcement() {
994 const NORMAL_FRAME: std::time::Duration = std::time::Duration::from_micros(17_500);
996 const CLOCK: std::time::Duration = std::time::Duration::from_secs(60);
997
998 fn frame(tree: &mut WidgetTree) -> accesskit::TreeUpdate {
999 tree.layout(SizeProposal::exact(200.0, 100.0));
1000 let update = tree.sync_accessibility();
1001 let _ = tree.render();
1002 update
1003 }
1004 fn wait_for_next_frame(tree: &WidgetTree) -> std::time::Duration {
1005 let deadline = tree
1006 .frame_tick_deadline()
1007 .expect("a visible subscriber keeps a frame scheduled");
1008 let last = tree.last_frame_time.expect("a frame has run");
1009 deadline.saturating_duration_since(last)
1010 }
1011
1012 let mut tree = WidgetTree::new();
1013 let row = tree.add(FillWidget::new().label("content"));
1014 let _clock = tree.subscribe_frame_tick_throttled(row, CLOCK);
1015 let _ = frame(&mut tree);
1016 let _ = frame(&mut tree);
1017 assert!(
1018 wait_for_next_frame(&tree) >= CLOCK - NORMAL_FRAME,
1019 "precondition: with nothing else going on, the clock alone paces the loop"
1020 );
1021
1022 let messages = ["Annulé : l'ajout d'un évènement", "3 évènements"];
1023 for message in messages {
1024 tree.announce(message);
1025 }
1026 assert!(
1030 wait_for_next_frame(&tree) <= NORMAL_FRAME,
1031 "an announcement must be carried by the next normal frame, not the clock's \
1032 (waited {:?})",
1033 wait_for_next_frame(&tree)
1034 );
1035
1036 let mut exposed = Vec::new();
1037 let mut frames = 0;
1038 let mut arrivals = Arrivals::attach(tree.accessibility_tree_snapshot());
1039 let settled = loop {
1040 let update = frame(&mut tree);
1041 frames += 1;
1042 exposed.extend(arrivals.feed(update).into_iter().map(|(text, _)| text));
1043 let wait = wait_for_next_frame(&tree);
1044 if wait > NORMAL_FRAME {
1045 break wait;
1046 }
1047 assert!(frames < 16, "the announcer never went idle");
1048 };
1049 assert_eq!(
1050 exposed, messages,
1051 "every queued message must be exposed before the loop goes back to sleeping on the \
1052 clock; one left behind is heard only at the next key press"
1053 );
1054 assert!(
1055 settled >= CLOCK - NORMAL_FRAME,
1056 "once the queue is spoken the clock must pace alone again, not free-run at 60 Hz \
1057 (waited {settled:?})"
1058 );
1059 }
1060
1061 #[test]
1064 fn an_announcement_reaches_the_automation_ring() {
1065 let mut tree = tree_with_one_widget();
1066 let before = tree.announcements_since(0).len() as u64;
1067 tree.announce("Event added");
1068 let _ = tree.sync_accessibility();
1069 let got = tree.announcements_since(before);
1070 assert_eq!(got.len(), 1);
1071 assert_eq!(got[0].text, "Event added");
1072 assert!(!got[0].assertive);
1073 }
1074
1075 #[test]
1079 fn a_tree_snapshot_does_not_consume_a_pending_announcement() {
1080 let mut tree = tree_with_one_widget();
1081 tree.announce("Event added");
1082
1083 let snapshot = tree.accessibility_tree_snapshot();
1084 assert_eq!(
1085 announced_by_consumer(&snapshot),
1086 Vec::new(),
1087 "a snapshot must not advance the announcer"
1088 );
1089
1090 let delivered = tree.sync_accessibility();
1091 assert_eq!(
1092 announced_by_consumer(&delivered),
1093 vec![("Event added".to_string(), accesskit::Live::Polite)],
1094 "the message must still be there to deliver"
1095 );
1096 }
1097
1098 #[test]
1101 fn an_empty_announcement_is_not_exposed() {
1102 let mut tree = tree_with_one_widget();
1103 let _ = tree.sync_accessibility();
1104 tree.announce(" ");
1105 let update = tree.sync_accessibility();
1106 assert_eq!(announced_by_consumer(&update), Vec::new());
1107 }
1108
1109 #[derive(Debug)]
1110 struct ActionWidget;
1111
1112 impl Widget for ActionWidget {
1113 fn layout_response(
1114 &self,
1115 proposal: SizeProposal,
1116 _ctx: &LayoutContext,
1117 ) -> crate::widget::LayoutResponse {
1118 proposal.resolve(0.0, 0.0).into()
1119 }
1120
1121 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1122 builder.set_role(accesskit::Role::Button);
1123 builder.set_name("Save");
1124 builder.add_action(accesskit::Action::Click);
1125 builder.add_action(accesskit::Action::Focus);
1126 }
1127 }
1128
1129 #[derive(Debug)]
1130 struct ClickableWidget;
1131
1132 impl Widget for ClickableWidget {
1133 fn layout_response(
1134 &self,
1135 proposal: SizeProposal,
1136 _ctx: &LayoutContext,
1137 ) -> crate::widget::LayoutResponse {
1138 proposal.resolve(0.0, 0.0).into()
1139 }
1140
1141 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1142 builder.set_role(accesskit::Role::Button);
1143 builder.set_name("Click Me");
1144 builder.add_action(accesskit::Action::Click);
1145 }
1146 }
1147
1148 #[test]
1153 fn a_focusable_node_advertises_focus_without_the_widget_saying_so() {
1154 let mut tree = WidgetTree::new();
1155 let id = tree.add(ClickableWidget.focusable(true));
1157 tree.layout(SizeProposal::exact(100.0, 100.0));
1158
1159 let info = tree.accessibility_node(id);
1160 assert!(
1161 info.actions().contains(&accesskit::Action::Focus),
1162 "the arena knows the node is focusable; the AT node must say so"
1163 );
1164
1165 let mut ops = crate::window::NoopWindowOps;
1167 assert!(tree.dispatch_access_action(
1168 crate::accessibility::widget_id_to_node_id(id),
1169 accesskit::Action::Focus,
1170 None,
1171 &mut ops,
1172 ));
1173 assert_eq!(tree.focused(), Some(id));
1174 }
1175
1176 #[derive(Debug)]
1182 struct CompositeWidget {
1183 child_ids: Vec<WidgetId>,
1184 }
1185
1186 impl Widget for CompositeWidget {
1187 fn layout_response(
1188 &self,
1189 proposal: SizeProposal,
1190 _ctx: &LayoutContext,
1191 ) -> crate::widget::LayoutResponse {
1192 proposal.resolve(0.0, 0.0).into()
1193 }
1194
1195 fn place_children(
1196 &self,
1197 bounds: Rect,
1198 _proposal: SizeProposal,
1199 children: &mut [crate::widget::WidgetPlacement],
1200 _ctx: &LayoutContext,
1201 ) {
1202 for child in children.iter_mut() {
1203 child.origin = bounds.origin();
1204 child.size = bounds.size();
1205 }
1206 }
1207
1208 fn children(&self) -> Vec<WidgetId> {
1209 self.child_ids.clone()
1210 }
1211
1212 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1213 builder.set_role(accesskit::Role::SpinButton);
1214 builder.add_action(accesskit::Action::Focus);
1215 }
1216 }
1217
1218 #[test]
1229 fn an_at_focus_on_a_non_focusable_composite_lands_on_its_focusable_leaf() {
1230 let mut tree = WidgetTree::new();
1231 let leaf = tree.add(ClickableWidget.focusable(true));
1232 let root = tree.add(CompositeWidget {
1233 child_ids: vec![leaf],
1234 });
1235 tree.layout(SizeProposal::exact(100.0, 100.0));
1236
1237 let mut ops = crate::window::NoopWindowOps;
1238 assert!(tree.dispatch_access_action(
1239 crate::accessibility::widget_id_to_node_id(root),
1240 accesskit::Action::Focus,
1241 None,
1242 &mut ops,
1243 ));
1244 assert_eq!(
1245 tree.focused(),
1246 Some(leaf),
1247 "focus must reach the focusable leaf, not the composite root"
1248 );
1249 }
1250
1251 #[test]
1260 fn an_at_focus_on_a_plain_container_does_not_walk_into_it() {
1261 let mut tree = WidgetTree::new();
1262 let leaf = tree.add(ClickableWidget.focusable(true));
1263 let root = tree.add(StackWidget::new().child(leaf));
1264 tree.layout(SizeProposal::exact(100.0, 100.0));
1265
1266 let mut ops = crate::window::NoopWindowOps;
1267 assert!(
1268 !tree.dispatch_access_action(
1269 crate::accessibility::widget_id_to_node_id(root),
1270 accesskit::Action::Focus,
1271 None,
1272 &mut ops,
1273 ),
1274 "a node that never advertised `Focus` must report the action unhandled"
1275 );
1276 assert_eq!(
1277 tree.focused(),
1278 None,
1279 "focus must stay where it was, not fall into the container's first control"
1280 );
1281 }
1282
1283 #[test]
1286 fn an_at_focus_on_a_focusable_node_still_lands_on_that_node() {
1287 let mut tree = WidgetTree::new();
1288 let outer = tree.add(ClickableWidget.focusable(true));
1289 tree.layout(SizeProposal::exact(100.0, 100.0));
1290
1291 let mut ops = crate::window::NoopWindowOps;
1292 assert!(tree.dispatch_access_action(
1293 crate::accessibility::widget_id_to_node_id(outer),
1294 accesskit::Action::Focus,
1295 None,
1296 &mut ops,
1297 ));
1298 assert_eq!(tree.focused(), Some(outer));
1299 }
1300
1301 #[test]
1304 fn a_derived_focus_action_is_still_removable_by_an_override() {
1305 let mut tree = WidgetTree::new();
1306 let id = tree.add(
1307 ClickableWidget
1308 .focusable(true)
1309 .access_remove_action(accesskit::Action::Focus),
1310 );
1311 tree.layout(SizeProposal::exact(100.0, 100.0));
1312 assert!(
1313 !tree
1314 .accessibility_node(id)
1315 .actions()
1316 .contains(&accesskit::Action::Focus)
1317 );
1318 }
1319
1320 #[test]
1323 fn a_non_focusable_node_does_not_advertise_focus() {
1324 let mut tree = WidgetTree::new();
1325 let id = tree.add(ClickableWidget);
1326 tree.layout(SizeProposal::exact(100.0, 100.0));
1327 assert!(
1328 !tree
1329 .accessibility_node(id)
1330 .actions()
1331 .contains(&accesskit::Action::Focus)
1332 );
1333 }
1334
1335 #[test]
1336 fn labeled_widget_has_accessibility() {
1337 let mut tree = WidgetTree::new();
1338 let widget = tree.add(FillWidget::new().label("Hello"));
1339 tree.layout(SizeProposal::exact(100.0, 20.0));
1340 let info = tree.accessibility_node(widget);
1341 assert_eq!(info.role(), accesskit::Role::Label);
1342 assert_eq!(info.name(), Some("Hello"));
1343 }
1344
1345 #[test]
1346 fn find_by_label_works() {
1347 let mut tree = WidgetTree::new();
1348 let widget = tree.add(FillWidget::new().label("Save"));
1349 tree.layout(SizeProposal::exact(100.0, 20.0));
1350 assert_eq!(tree.find_by_label("Save"), Some(widget));
1351 }
1352
1353 #[test]
1354 fn label_role_emits_text_as_value_not_label() {
1355 let mut tree = WidgetTree::new();
1362 let widget = tree.add(FillWidget::new().label("Section")); tree.layout(SizeProposal::exact(100.0, 20.0));
1364
1365 assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1367
1368 let update = tree.sync_accessibility();
1370 let node = &update
1371 .nodes
1372 .iter()
1373 .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1374 .expect("label node must be present in the tree update")
1375 .1;
1376 assert_eq!(node.role(), accesskit::Role::Label);
1377 assert_eq!(
1378 node.value(),
1379 Some("Section"),
1380 "Role::Label text must be exposed via `value`"
1381 );
1382 assert_eq!(
1383 node.label(),
1384 None,
1385 "Role::Label must not leave text in the `label` property"
1386 );
1387 }
1388
1389 #[test]
1390 fn find_by_role_works() {
1391 let mut tree = WidgetTree::new();
1392 tree.add(FillWidget::new().label("Text"));
1393 tree.layout(SizeProposal::exact(100.0, 20.0));
1394 assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1395 }
1396
1397 #[test]
1398 fn access_hidden_bound_to_signal_toggles_reactively() {
1399 use crate::signal::Signal;
1400 use crate::widget_builder::WidgetBuilder;
1401 let hidden = Signal::new(false);
1402 let mut tree = WidgetTree::new();
1403 let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1404 tree.layout(SizeProposal::exact(100.0, 30.0));
1405 assert!(
1406 !tree.accessibility_node(w).is_hidden(),
1407 "node should be visible to AT while the signal is false"
1408 );
1409 hidden.set(true);
1410 assert!(
1412 tree.accessibility_node(w).is_hidden(),
1413 "node should hide from AT when the bound signal flips to true"
1414 );
1415 hidden.set(false);
1416 assert!(!tree.accessibility_node(w).is_hidden());
1417 }
1418
1419 #[test]
1423 fn a_widget_with_a_context_menu_advertises_the_action() {
1424 let mut tree = WidgetTree::new();
1425 let plain = tree.add(FillWidget::new().label("no menu"));
1426 let with_menu = tree.add(
1427 FillWidget::new()
1428 .label("has menu")
1429 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1430 );
1431 tree.layout(SizeProposal::exact(100.0, 20.0));
1432
1433 assert!(
1434 tree.accessibility_node(with_menu)
1435 .actions()
1436 .contains(&Action::ShowContextMenu),
1437 "a node owning a context-menu factory must announce it"
1438 );
1439 assert!(
1440 !tree
1441 .accessibility_node(plain)
1442 .actions()
1443 .contains(&Action::ShowContextMenu),
1444 "and a node without one must not"
1445 );
1446 }
1447
1448 #[test]
1451 fn a_removed_context_menu_action_stays_removed() {
1452 let mut tree = WidgetTree::new();
1453 let id = tree.add(
1454 FillWidget::new()
1455 .label("suppressed")
1456 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1457 .access_remove_action(Action::ShowContextMenu),
1458 );
1459 tree.layout(SizeProposal::exact(100.0, 20.0));
1460 assert!(
1461 !tree
1462 .accessibility_node(id)
1463 .actions()
1464 .contains(&Action::ShowContextMenu)
1465 );
1466 }
1467
1468 #[test]
1473 fn a_child_does_not_advertise_its_parents_context_menu() {
1474 let mut tree = WidgetTree::new();
1475 let child = tree.add(FillWidget::new().label("inner"));
1476 let parent = tree.add(
1477 StackWidget::new()
1478 .child(child)
1479 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1480 );
1481 tree.layout(SizeProposal::exact(100.0, 20.0));
1482
1483 assert!(
1484 tree.accessibility_node(parent)
1485 .actions()
1486 .contains(&Action::ShowContextMenu)
1487 );
1488 assert!(
1489 !tree
1490 .accessibility_node(child)
1491 .actions()
1492 .contains(&Action::ShowContextMenu),
1493 "the menu belongs to the widget that declared it"
1494 );
1495 }
1496
1497 #[test]
1498 fn accessibility_node_collects_actions() {
1499 let mut tree = WidgetTree::new();
1500 let widget = tree.add(ActionWidget);
1501 tree.layout(SizeProposal::exact(100.0, 40.0));
1502
1503 let info = tree.accessibility_node(widget);
1504 assert_eq!(info.role(), accesskit::Role::Button);
1505 assert_eq!(info.name(), Some("Save"));
1506 assert_eq!(info.actions().len(), 2);
1507 assert!(info.actions().contains(&accesskit::Action::Click));
1508 assert!(info.actions().contains(&accesskit::Action::Focus));
1509 }
1510
1511 #[test]
1512 fn sync_accessibility_produces_tree_update() {
1513 let mut tree = WidgetTree::new();
1514 tree.add(FillWidget::new().label("First"));
1515 tree.add(FillWidget::new().label("Second"));
1516 tree.layout(SizeProposal::exact(200.0, 100.0));
1517
1518 let update = tree.sync_accessibility();
1519 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1522 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1523 assert!(update.tree.is_some());
1524 assert_a11y_tree_valid(&update);
1525 }
1526
1527 #[test]
1528 fn root_node_carries_locale_as_language() {
1529 let mut tree = WidgetTree::new();
1530 tree.add(FillWidget::new().label("Bonjour"));
1531
1532 tree.layout(SizeProposal::exact(200.0, 100.0));
1534 let update = tree.sync_accessibility();
1535 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1536 assert_eq!(
1537 update.nodes[0].1.language(),
1538 None,
1539 "no language before a locale is set"
1540 );
1541
1542 tree.set_locale("fr-FR".to_string());
1545 let update = tree.sync_accessibility();
1546 assert_eq!(
1547 update.nodes[0].1.language(),
1548 Some("fr-FR"),
1549 "root node must advertise the active locale as its language"
1550 );
1551 }
1552
1553 #[test]
1554 fn sync_accessibility_excludes_dormant_widgets() {
1555 let mut tree = WidgetTree::new();
1556 tree.add(FillWidget::new().label("Active"));
1557 let dormant = tree.add(FillWidget::new().label("Dormant"));
1558 tree.layout(SizeProposal::exact(200.0, 100.0));
1559
1560 tree.set_dormant(dormant);
1561
1562 let update = tree.sync_accessibility();
1563 assert_eq!(update.nodes.len(), 2 + ANNOUNCER_NODES);
1564 assert_a11y_tree_valid(&update);
1565 }
1566
1567 #[test]
1574 fn relayout_without_activation_change_does_not_dirty_a11y() {
1575 let mut tree = WidgetTree::new();
1576 let id = tree.add(FillWidget::new().label("Static"));
1577 tree.layout(SizeProposal::exact(200.0, 100.0));
1578
1579 let _ = tree.sync_accessibility();
1581 assert!(
1582 !tree.a11y_dirty,
1583 "sync_accessibility must clear the dirty flag"
1584 );
1585
1586 tree.arena.mark_needs_layout(id);
1590 tree.layout(SizeProposal::exact(200.0, 100.0));
1591
1592 assert!(
1593 !tree.a11y_dirty,
1594 "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1595 );
1596 }
1597
1598 #[derive(Debug)]
1601 struct Mover {
1602 offset: crate::signal::Signal<f32>,
1603 child: WidgetId,
1604 }
1605
1606 impl crate::widget::Widget for Mover {
1607 fn children(&self) -> Vec<WidgetId> {
1608 vec![self.child]
1609 }
1610 fn layout_response(
1611 &self,
1612 proposal: SizeProposal,
1613 _ctx: &crate::widget::LayoutContext,
1614 ) -> crate::widget::LayoutResponse {
1615 proposal.resolve(200.0, 100.0).into()
1616 }
1617 fn place_children(
1618 &self,
1619 bounds: teksilo_canvas::Rect,
1620 _proposal: SizeProposal,
1621 children: &mut [crate::widget::WidgetPlacement],
1622 _ctx: &crate::widget::LayoutContext,
1623 ) {
1624 if let Some(placement) = children.first_mut() {
1625 placement.origin =
1626 teksilo_canvas::Point::new(bounds.x + self.offset.get(), bounds.y);
1627 placement.size = teksilo_canvas::Size::new(50.0, 20.0);
1628 }
1629 }
1630 }
1631
1632 #[test]
1639 fn translating_a_widget_patches_the_cache_without_a_walk() {
1640 let mut tree = WidgetTree::new();
1641 let inner = tree.add(FillWidget::new().label("Row"));
1642 let offset = crate::signal::Signal::new(0.0f32);
1643 let root = tree.add(Mover {
1644 offset: offset.clone(),
1645 child: inner,
1646 });
1647 tree.layout(SizeProposal::exact(200.0, 100.0));
1648 let update = tree.sync_accessibility();
1649 let walks = tree.a11y_walk_generation();
1650 let before = find_node(&update, inner)
1651 .unwrap()
1652 .bounds()
1653 .expect("the node carries bounds");
1654
1655 offset.set(20.0);
1657 tree.arena.mark_needs_layout(root);
1658 tree.layout(SizeProposal::exact(200.0, 100.0));
1659
1660 let update = tree.sync_accessibility();
1661 assert_eq!(
1662 tree.a11y_walk_generation(),
1663 walks,
1664 "a pure translation must not cost a walk"
1665 );
1666 let after = find_node(&update, inner)
1667 .unwrap()
1668 .bounds()
1669 .expect("the node still carries bounds");
1670 assert!(
1671 (after.x0 - before.x0 - 20.0).abs() < 0.01,
1672 "the cached node must follow the move: {before:?} -> {after:?}"
1673 );
1674 }
1675
1676 #[test]
1677 fn at_version_does_not_bump_for_a_move() {
1678 let mut tree = WidgetTree::new();
1682 let inner = tree.add(FillWidget::new().label("Row"));
1683 let offset = crate::signal::Signal::new(0.0f32);
1684 let root = tree.add(Mover {
1685 offset: offset.clone(),
1686 child: inner,
1687 });
1688 tree.layout(SizeProposal::exact(200.0, 100.0));
1689 let _ = tree.sync_accessibility();
1690 let version = tree.at_version().get();
1691
1692 offset.set(20.0);
1693 tree.arena.mark_needs_layout(root);
1694 tree.layout(SizeProposal::exact(200.0, 100.0));
1695 let _ = tree.sync_accessibility();
1696
1697 assert_eq!(tree.at_version().get(), version);
1698 }
1699
1700 #[test]
1705 fn a_delivery_is_owed_only_for_what_the_adapter_does_not_hold() {
1706 let mut tree = WidgetTree::new();
1707 let inner = tree.add(FillWidget::new().label("Row"));
1708 let offset = crate::signal::Signal::new(0.0f32);
1709 let root = tree.add(Mover {
1710 offset: offset.clone(),
1711 child: inner,
1712 });
1713 tree.layout(SizeProposal::exact(200.0, 100.0));
1714 let _ = tree.sync_accessibility();
1715 assert!(
1716 tree.accessibility_delivery_owed(true),
1717 "nothing delivered yet"
1718 );
1719 let _ = tree.deliver_accessibility(true);
1720 assert!(!tree.accessibility_delivery_owed(true));
1721 assert!(
1722 tree.accessibility_delivery_owed(false),
1723 "the window's focus changes what the adapter keeps"
1724 );
1725
1726 let _ = tree.sync_accessibility();
1727 assert!(
1728 !tree.accessibility_delivery_owed(true),
1729 "an idle frame owes nothing"
1730 );
1731
1732 offset.set(20.0);
1733 tree.arena.mark_needs_layout(root);
1734 tree.layout(SizeProposal::exact(200.0, 100.0));
1735 let _ = tree.sync_accessibility();
1736 assert!(tree.accessibility_delivery_owed(true), "a move is owed");
1737 let _ = tree.deliver_accessibility(true);
1738 assert!(!tree.accessibility_delivery_owed(true));
1739
1740 let walks = tree.a11y_walk_generation();
1742 tree.set_theme(crate::presets::intui::dark());
1743 let _ = tree.sync_accessibility();
1744 assert!(
1745 tree.a11y_walk_generation() > walks,
1746 "precondition: it walked"
1747 );
1748 assert!(!tree.accessibility_delivery_owed(true));
1749
1750 tree.add(FillWidget::new().label("New"));
1751 tree.layout(SizeProposal::exact(200.0, 100.0));
1752 let _ = tree.sync_accessibility();
1753 assert!(
1754 tree.accessibility_delivery_owed(true),
1755 "new content is owed"
1756 );
1757 let _ = tree.deliver_accessibility(true);
1758 assert!(!tree.accessibility_delivery_owed(true));
1759 }
1760
1761 #[test]
1762 fn resizing_a_widget_dirties_the_accessibility_tree() {
1763 let mut tree = WidgetTree::new();
1767 let id = tree.add(FillWidget::new().label("Static"));
1768 tree.layout(SizeProposal::exact(200.0, 100.0));
1769 let _ = tree.sync_accessibility();
1770 let walks = tree.a11y_walk_generation();
1771
1772 tree.layout(SizeProposal::exact(300.0, 100.0));
1773 let _ = tree.sync_accessibility();
1774
1775 assert!(
1776 tree.a11y_walk_generation() > walks,
1777 "a resize must be walked, not patched"
1778 );
1779 let _ = id;
1780 }
1781
1782 #[test]
1783 fn changing_the_theme_dirties_the_accessibility_tree() {
1784 let mut tree = WidgetTree::new();
1789 tree.add(FillWidget::new().label("Static"));
1790 tree.layout(SizeProposal::exact(200.0, 100.0));
1791 let _ = tree.sync_accessibility();
1792 let walks = tree.a11y_walk_generation();
1793
1794 tree.set_theme(crate::presets::intui::dark());
1795 let _ = tree.sync_accessibility();
1796
1797 assert!(tree.a11y_walk_generation() > walks);
1798 }
1799
1800 #[test]
1801 fn changing_the_text_scale_dirties_the_accessibility_tree() {
1802 let mut tree = WidgetTree::new();
1803 tree.add(FillWidget::new().label("Static"));
1804 tree.layout(SizeProposal::exact(200.0, 100.0));
1805 let _ = tree.sync_accessibility();
1806 let walks = tree.a11y_walk_generation();
1807
1808 tree.set_user_text_scale(1.5);
1809 let _ = tree.sync_accessibility();
1810
1811 assert!(tree.a11y_walk_generation() > walks);
1812 }
1813
1814 #[test]
1815 fn a_moved_node_absent_from_the_cache_is_skipped() {
1816 use crate::widget_builder::WidgetBuilder;
1821 let mut tree = WidgetTree::new();
1822 let hidden = tree.add(FillWidget::new().label("Inner"));
1823 let offset = crate::signal::Signal::new(0.0f32);
1824 let root = tree.add(
1825 Mover {
1826 offset: offset.clone(),
1827 child: hidden,
1828 }
1829 .access_exclude_subtree(),
1830 );
1831 tree.layout(SizeProposal::exact(200.0, 100.0));
1832 let update = tree.sync_accessibility();
1833 assert!(
1834 find_node(&update, hidden).is_none(),
1835 "the excluded descendant must be absent for this test to mean anything"
1836 );
1837
1838 offset.set(20.0);
1839 tree.arena.mark_needs_layout(root);
1840 tree.layout(SizeProposal::exact(200.0, 100.0));
1841 let update = tree.sync_accessibility();
1842
1843 assert!(find_node(&update, hidden).is_none());
1844 assert_no_dangling_relationships(&update);
1845 }
1846
1847 #[test]
1848 fn focus_on_a_node_the_walk_did_not_emit_resolves_to_an_emitted_ancestor() {
1849 use crate::widget_builder::WidgetBuilder;
1860 let mut tree = WidgetTree::new();
1861 let inner = tree.add(ClickableWidget);
1862 let outer = tree.add(
1863 Mover {
1864 offset: crate::signal::Signal::new(0.0f32),
1865 child: inner,
1866 }
1867 .access_exclude_subtree(),
1868 );
1869 tree.layout(SizeProposal::exact(200.0, 100.0));
1870 tree.focus(inner);
1871
1872 let update = tree.sync_accessibility();
1873 assert!(
1874 find_node(&update, inner).is_none(),
1875 "precondition: the excluded descendant emits nothing"
1876 );
1877 let emitted: std::collections::HashSet<_> =
1878 update.nodes.iter().map(|(id, _)| *id).collect();
1879 assert!(
1880 emitted.contains(&update.focus),
1881 "the published focus must be a node in the published tree"
1882 );
1883 assert_eq!(
1884 update.focus,
1885 crate::accessibility::widget_id_to_node_id(outer),
1886 "…and specifically the nearest ancestor that did emit"
1887 );
1888 accesskit_consumer::Tree::new(update, false);
1890 }
1891
1892 #[test]
1896 fn activation_transition_does_dirty_a11y() {
1897 let mut tree = WidgetTree::new();
1898 let id = tree.add(FillWidget::new().label("Toggle"));
1899 tree.layout(SizeProposal::exact(200.0, 100.0));
1900 let _ = tree.sync_accessibility();
1901 assert!(!tree.a11y_dirty);
1902
1903 tree.set_dormant(id);
1904 tree.layout(SizeProposal::exact(200.0, 100.0));
1905 assert!(
1906 tree.a11y_dirty,
1907 "active→dormant transition must dirty the AT cache so the dormant node is removed"
1908 );
1909 }
1910
1911 #[test]
1912 fn sync_accessibility_includes_focus() {
1913 let mut tree = WidgetTree::new();
1914 let widget = tree.add(FillWidget::new().focusable().label("Focused"));
1915 tree.layout(SizeProposal::exact(100.0, 50.0));
1916 tree.focus(widget);
1917
1918 let update = tree.sync_accessibility();
1919 let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
1920 assert_eq!(update.focus, expected_focus);
1921 assert_a11y_tree_valid(&update);
1922 }
1923
1924 #[test]
1925 fn sync_accessibility_parent_child_relationship() {
1926 let mut tree = WidgetTree::new();
1927 let child = tree.add(FillWidget::new().label("Child"));
1928 let parent = tree.add(
1931 StackWidget::new()
1932 .child(child)
1933 .access_label_literal("Parent"),
1934 );
1935 tree.layout(SizeProposal::exact(100.0, 50.0));
1936
1937 let update = tree.sync_accessibility();
1938 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1939
1940 let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
1941 let parent_node = update
1942 .nodes
1943 .iter()
1944 .find(|(id, _)| *id == parent_node_id)
1945 .map(|(_, node)| node)
1946 .unwrap();
1947
1948 let child_node_id = crate::accessibility::widget_id_to_node_id(child);
1949 assert!(parent_node.children().contains(&child_node_id));
1950 assert_a11y_tree_valid(&update);
1951 }
1952
1953 #[test]
1954 fn presentational_containers_collapse_and_promote_children() {
1955 let mut tree = WidgetTree::new();
1961 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(
1965 StackWidget::new()
1966 .child(outer)
1967 .access_label_literal("Group"),
1968 );
1969 tree.layout(SizeProposal::exact(100.0, 50.0));
1970 let update = tree.sync_accessibility();
1971
1972 assert!(find_node(&update, inner).is_none(), "bare inner pruned");
1973 assert!(find_node(&update, outer).is_none(), "bare outer pruned");
1974
1975 let labeled_node = find_node(&update, labeled).expect("labeled group survives");
1976 let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
1977 assert!(
1978 labeled_node.children().contains(&leaf_nid),
1979 "leaf promoted past both bare containers to the labeled ancestor"
1980 );
1981 assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
1982 assert_a11y_tree_valid(&update);
1983 }
1984
1985 #[test]
1986 fn find_by_action_finds_clickable() {
1987 let mut tree = WidgetTree::new();
1988 let widget = tree.add(ClickableWidget);
1989 tree.layout(SizeProposal::exact(100.0, 40.0));
1990
1991 assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
1992 assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
1993 }
1994
1995 #[test]
1996 fn text_content_returns_accessibility_name() {
1997 let mut tree = WidgetTree::new();
1998 let widget = tree.add(FillWidget::new().label("Hello World"));
1999 tree.layout(SizeProposal::exact(100.0, 50.0));
2000
2001 assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
2002 }
2003
2004 #[test]
2005 fn text_content_returns_none_without_label() {
2006 let mut tree = WidgetTree::new();
2007 let widget = tree.add(FillWidget::new());
2008 tree.layout(SizeProposal::exact(100.0, 50.0));
2009
2010 assert_eq!(tree.text_content(widget), None);
2011 }
2012
2013 #[test]
2014 fn descendant_of_disabled_ancestor_reports_disabled() {
2015 use crate::signal::Signal;
2016
2017 let mut tree = WidgetTree::new();
2018 let child = tree.add(FillWidget::new().label("Child"));
2019 let parent = tree.add(StackWidget::new().child(child));
2020 tree.enabled_when(parent, Signal::new(false));
2021 tree.layout(SizeProposal::exact(100.0, 50.0));
2022
2023 assert!(
2024 tree.accessibility_node(child).is_disabled(),
2025 "descendant should report disabled when ancestor is disabled"
2026 );
2027 }
2028
2029 #[test]
2030 fn text_value_returns_accessibility_value() {
2031 #[derive(Debug)]
2032 struct ValueWidget;
2033
2034 impl Widget for ValueWidget {
2035 fn layout_response(
2036 &self,
2037 proposal: SizeProposal,
2038 _ctx: &LayoutContext,
2039 ) -> crate::widget::LayoutResponse {
2040 proposal.resolve(0.0, 0.0).into()
2041 }
2042
2043 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
2044 builder.set_role(accesskit::Role::Slider);
2045 builder.set_name("Volume");
2046 builder.set_value("75%");
2047 }
2048 }
2049
2050 let mut tree = WidgetTree::new();
2051 let widget = tree.add(ValueWidget);
2052 tree.layout(SizeProposal::exact(100.0, 40.0));
2053
2054 assert_eq!(tree.text_value(widget), Some("75%".to_string()));
2055 assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
2056 }
2057
2058 #[test]
2059 fn sync_accessibility_has_no_duplicate_children() {
2060 let mut tree = WidgetTree::new();
2064 let grandchild = tree.add(FillWidget::new().label("Grandchild"));
2065 let child_a = tree.add(StackWidget::new().child(grandchild));
2066 let child_b = tree.add(FillWidget::new().label("Sibling"));
2067 let _root = tree.add(StackWidget::new().child(child_a).child(child_b));
2068 tree.layout(SizeProposal::exact(200.0, 100.0));
2069
2070 let update = tree.sync_accessibility();
2071
2072 let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
2073 std::collections::HashMap::new();
2074 for (parent_id, node) in &update.nodes {
2075 for &child_id in node.children() {
2076 let prev = all_children.insert(child_id, *parent_id);
2077 assert!(
2078 prev.is_none(),
2079 "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
2080 );
2081 }
2082 }
2083 assert_a11y_tree_valid(&update);
2084 }
2085
2086 #[test]
2087 fn no_dangling_relationships_in_basic_tree() {
2088 let mut tree = WidgetTree::new();
2089 let child = tree.add(FillWidget::new().label("Child"));
2090 let _parent = tree.add(StackWidget::new().child(child));
2091 tree.layout(SizeProposal::exact(100.0, 50.0));
2092
2093 let update = tree.sync_accessibility();
2094 assert_no_dangling_relationships(&update);
2095 assert_a11y_tree_valid(&update);
2096 }
2097
2098 use crate::widget_builder::WidgetBuilder;
2103 use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
2104
2105 fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
2107 let nid = crate::accessibility::widget_id_to_node_id(id);
2108 update
2109 .nodes
2110 .iter()
2111 .find(|(node_id, _)| *node_id == nid)
2112 .map(|(_, n)| n)
2113 }
2114
2115 #[derive(Debug)]
2118 struct AlwaysHiddenWidget;
2119 impl Widget for AlwaysHiddenWidget {
2120 fn layout_response(
2121 &self,
2122 proposal: SizeProposal,
2123 _ctx: &LayoutContext,
2124 ) -> crate::widget::LayoutResponse {
2125 proposal.resolve(0.0, 0.0).into()
2126 }
2127 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2128 builder.set_role(Role::GenericContainer);
2129 builder.set_hidden();
2130 }
2131 }
2132
2133 #[test]
2135 fn access_label_replaces_widget_label() {
2136 let mut tree = WidgetTree::new();
2137 let id = tree.add(ClickableWidget.access_label_literal("Publish"));
2138 tree.layout(SizeProposal::exact(100.0, 40.0));
2139 assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
2140 }
2141
2142 #[test]
2144 fn access_description_appears_on_bare_widget() {
2145 let mut tree = WidgetTree::new();
2146 let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
2147 tree.layout(SizeProposal::exact(50.0, 50.0));
2148 let update = tree.sync_accessibility();
2149 let node = find_node(&update, id).expect("node present");
2150 assert_eq!(node.description(), Some("Decorative"));
2151 }
2152
2153 #[test]
2155 fn access_value_replaces_widget_value() {
2156 #[derive(Debug)]
2157 struct SliderWidget;
2158 impl Widget for SliderWidget {
2159 fn layout_response(
2160 &self,
2161 proposal: SizeProposal,
2162 _ctx: &LayoutContext,
2163 ) -> crate::widget::LayoutResponse {
2164 proposal.resolve(0.0, 0.0).into()
2165 }
2166 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2167 builder.set_role(Role::Slider);
2168 builder.set_value("50");
2169 }
2170 }
2171 let mut tree = WidgetTree::new();
2172 let id = tree.add(SliderWidget.access_value_literal("Custom"));
2173 tree.layout(SizeProposal::exact(100.0, 40.0));
2174 assert_eq!(tree.text_value(id), Some("Custom".to_string()));
2175 }
2176
2177 #[test]
2179 fn access_role_overrides_widget_role() {
2180 let mut tree = WidgetTree::new();
2181 let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
2182 tree.layout(SizeProposal::exact(100.0, 40.0));
2183 assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
2184 }
2185
2186 #[test]
2187 fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
2188 let mut tree = WidgetTree::new();
2195 let anchor = tree.add(FillWidget::new().label("Save"));
2196 let tip = tree.add(FillWidget::new().label("Save the file"));
2197 tree.layout(SizeProposal::exact(200.0, 100.0));
2198 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
2199
2200 assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
2201 let update = tree.sync_accessibility();
2202 let node = find_node(&update, anchor).expect("anchor node present");
2203 assert_eq!(
2204 node.description(),
2205 Some("Save the file"),
2206 "the anchor must describe itself with its tooltip text while unshown"
2207 );
2208 }
2209
2210 #[test]
2211 fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
2212 let mut tree = WidgetTree::new();
2218 let anchor = tree.add(FillWidget::new().label("Save"));
2219 let tip = tree.add(FillWidget::new().label("Save the file"));
2220 tree.layout(SizeProposal::exact(200.0, 100.0));
2221 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2222
2223 tree.pointer_move(tree.bounds(anchor).center());
2224 tree.advance_time(std::time::Duration::from_millis(150));
2225 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
2226
2227 let update = tree.sync_accessibility();
2228 let node = find_node(&update, anchor).expect("anchor node present");
2229 assert!(
2230 !node.described_by().is_empty(),
2231 "a shown tooltip is wired as a described_by relation"
2232 );
2233 assert_eq!(
2234 node.description(),
2235 Some("Save the file"),
2236 "and the anchor still describes itself with the tooltip's text"
2237 );
2238 }
2239
2240 #[test]
2242 fn access_hint_alias_writes_description_field() {
2243 let mut tree = WidgetTree::new();
2244 let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
2245 tree.layout(SizeProposal::exact(50.0, 50.0));
2246 let update = tree.sync_accessibility();
2247 let node = find_node(&update, id).unwrap();
2248 assert_eq!(node.description(), Some("Tip"));
2249 }
2250
2251 #[test]
2253 fn access_identifier_writes_author_id() {
2254 let mut tree = WidgetTree::new();
2255 let id = tree.add(FillWidget::new().access_identifier("save-button"));
2256 tree.layout(SizeProposal::exact(50.0, 50.0));
2257 let update = tree.sync_accessibility();
2258 let node = find_node(&update, id).unwrap();
2259 assert_eq!(node.author_id(), Some("save-button"));
2260 }
2261
2262 #[test]
2264 fn access_shortcut_literal_set() {
2265 let mut tree = WidgetTree::new();
2266 let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
2267 tree.layout(SizeProposal::exact(50.0, 50.0));
2268 let update = tree.sync_accessibility();
2269 let node = find_node(&update, id).unwrap();
2270 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
2271 }
2272
2273 #[test]
2276 fn access_shortcut_id_resolves_and_tracks_rebinds() {
2277 use crate::event::Key;
2278 use crate::shortcut::{KeyStroke, Shortcut};
2279
2280 let mut tree = WidgetTree::new();
2281 tree.shortcut_registry_mut().register(
2282 Shortcut::new("app.save")
2283 .name("Save")
2284 .primary(KeyStroke::command(Key::S))
2285 .build(),
2286 );
2287 let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
2288 tree.layout(SizeProposal::exact(50.0, 50.0));
2289
2290 let accel = if cfg!(target_os = "macos") {
2294 "Cmd"
2295 } else {
2296 "Ctrl"
2297 };
2298
2299 let update = tree.sync_accessibility();
2301 let node = find_node(&update, id).unwrap();
2302 assert_eq!(
2303 node.keyboard_shortcut(),
2304 Some(format!("{accel}+S").as_str())
2305 );
2306
2307 tree.shortcut_registry_mut()
2309 .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
2310 let update = tree.sync_accessibility();
2311 let node = find_node(&update, id).unwrap();
2312 assert_eq!(
2313 node.keyboard_shortcut(),
2314 Some(format!("{accel}+Q").as_str())
2315 );
2316 }
2317
2318 #[test]
2321 fn access_shortcut_id_unknown_id_omits_announcement() {
2322 let mut tree = WidgetTree::new();
2323 let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
2324 tree.layout(SizeProposal::exact(50.0, 50.0));
2325 let update = tree.sync_accessibility();
2326 let node = find_node(&update, id).unwrap();
2327 assert_eq!(node.keyboard_shortcut(), None);
2328 }
2329
2330 #[test]
2332 fn access_hidden_true_hides_widget() {
2333 let mut tree = WidgetTree::new();
2334 let id = tree.add(ClickableWidget.access_hidden(true));
2335 tree.layout(SizeProposal::exact(50.0, 50.0));
2336 assert!(tree.accessibility_node(id).is_hidden());
2337 }
2338
2339 #[test]
2341 fn access_hidden_false_clears_widget_set_hidden() {
2342 let mut tree = WidgetTree::new();
2343 let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
2344 tree.layout(SizeProposal::exact(50.0, 50.0));
2345 assert!(
2346 !tree.accessibility_node(id).is_hidden(),
2347 "access_hidden(false) should clear widget-emitted hidden"
2348 );
2349 }
2350
2351 #[test]
2353 fn access_disabled_true_marks_disabled() {
2354 let mut tree = WidgetTree::new();
2355 let id = tree.add(FillWidget::new().access_disabled(true));
2356 tree.layout(SizeProposal::exact(50.0, 50.0));
2357 assert!(tree.accessibility_node(id).is_disabled());
2358 }
2359
2360 #[test]
2362 fn access_disabled_false_clears_arena_driven_disabled() {
2363 use crate::signal::Signal;
2364 let mut tree = WidgetTree::new();
2365 let id = tree.add(FillWidget::new().label("X").access_disabled(false));
2366 tree.enabled_when(id, Signal::new(false));
2367 tree.layout(SizeProposal::exact(50.0, 50.0));
2368 assert!(
2369 !tree.accessibility_node(id).is_disabled(),
2370 "access_disabled(false) should clear even arena-driven disabled"
2371 );
2372 }
2373
2374 #[test]
2376 fn access_controls_appends() {
2377 let mut tree = WidgetTree::new();
2378 let target = tree.add(FillWidget::new().label("Target"));
2379 let controller = tree.add(
2380 FillWidget::new()
2381 .label("Controller")
2382 .access_controls(target),
2383 );
2384 tree.layout(SizeProposal::exact(100.0, 50.0));
2385 let update = tree.sync_accessibility();
2386 let node = find_node(&update, controller).unwrap();
2387 let target_nid = crate::accessibility::widget_id_to_node_id(target);
2388 assert!(
2389 node.controls().contains(&target_nid),
2390 "controls list should contain the target NodeId"
2391 );
2392 }
2393
2394 #[test]
2396 fn access_described_by_appends() {
2397 let mut tree = WidgetTree::new();
2398 let other = tree.add(FillWidget::new().label("Desc"));
2399 let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
2400 tree.layout(SizeProposal::exact(100.0, 50.0));
2401 let update = tree.sync_accessibility();
2402 let node = find_node(&update, id).unwrap();
2403 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2404 assert!(node.described_by().contains(&other_nid));
2405 }
2406
2407 #[test]
2409 fn access_labelled_by_appends() {
2410 let mut tree = WidgetTree::new();
2411 let other = tree.add(FillWidget::new().label("Lbl"));
2412 let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
2413 tree.layout(SizeProposal::exact(100.0, 50.0));
2414 let update = tree.sync_accessibility();
2415 let node = find_node(&update, id).unwrap();
2416 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2417 assert!(node.labelled_by().contains(&other_nid));
2418 }
2419
2420 #[test]
2432 fn a_child_attached_to_a_node_that_never_reached_the_tree_is_stripped() {
2433 use crate::accessibility::{AccessNodeBuilder, SyntheticKind, widget_id_to_node_id};
2434
2435 #[derive(Debug)]
2438 struct Grafter {
2439 child: std::cell::Cell<Option<WidgetId>>,
2440 }
2441
2442 impl Widget for Grafter {
2443 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2444 let child = ctx.add(FillWidget::new().label("Card"));
2445 self.child.set(Some(child));
2446 vec![child]
2447 }
2448 fn layout_response(
2449 &self,
2450 _p: SizeProposal,
2451 _c: &crate::widget::LayoutContext,
2452 ) -> crate::widget::LayoutResponse {
2453 teksilo_canvas::Size::new(100.0, 100.0).into()
2454 }
2455 fn children(&self) -> Vec<WidgetId> {
2456 self.child.get().into_iter().collect()
2457 }
2458 fn wants_descendant_redirects(&self) -> bool {
2459 true
2460 }
2461 fn a11y_redirect_descendant(
2462 &self,
2463 _self_id: WidgetId,
2464 descendant: WidgetId,
2465 ) -> Option<accesskit::NodeId> {
2466 (self.child.get() == Some(descendant)).then(|| widget_id_to_node_id(descendant))
2469 }
2470 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2471 builder.set_role(accesskit::Role::Pane);
2472 let group =
2473 builder.push_scene_child_under(None, 7, SyntheticKind::SceneGroup, |c| {
2474 c.set_role(accesskit::Role::GenericContainer);
2475 c.set_name("Group".to_string());
2476 });
2477 if let Some(child) = self.child.get() {
2478 builder.attach_scene_child_under(group, widget_id_to_node_id(child));
2479 }
2480 }
2481 }
2482
2483 let mut tree = WidgetTree::new();
2484 let grafter = tree.add(Grafter {
2485 child: std::cell::Cell::new(None),
2486 });
2487 tree.layout(SizeProposal::exact(200.0, 200.0));
2488 let child = tree.children(grafter)[0];
2489
2490 let update = tree.sync_accessibility();
2492 let child_nid = widget_id_to_node_id(child);
2493 assert!(
2494 update.nodes.iter().any(|(id, _)| *id == child_nid),
2495 "precondition: the grafted child is in the tree"
2496 );
2497 assert!(
2498 update
2499 .nodes
2500 .iter()
2501 .any(|(_, n)| n.children().contains(&child_nid)),
2502 "precondition: something names it as a child"
2503 );
2504 assert_a11y_tree_valid(&update);
2505
2506 tree.set_dormant(child);
2510 let update = tree.sync_accessibility();
2511 assert!(
2512 !update.nodes.iter().any(|(id, _)| *id == child_nid),
2513 "a dormant widget emits no node"
2514 );
2515 let emitted: std::collections::HashSet<accesskit::NodeId> =
2516 update.nodes.iter().map(|(id, _)| *id).collect();
2517 for (parent, node) in &update.nodes {
2518 for c in node.children() {
2519 assert!(
2520 emitted.contains(c),
2521 "node {parent:?} names child {c:?}, absent from the tree"
2522 );
2523 }
2524 }
2525 assert_a11y_tree_valid(&update);
2528 }
2529
2530 #[test]
2531 fn a_labelled_by_target_that_went_dormant_is_stripped() {
2532 let mut tree = WidgetTree::new();
2538 let title = tree.add(FillWidget::new().label("Preferences"));
2539 let dialog = tree.add(FillWidget::new().access_labelled_by(title));
2540 tree.layout(SizeProposal::exact(200.0, 100.0));
2541
2542 let update = tree.sync_accessibility();
2543 assert!(
2544 !find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2545 "the relation is live while the target is in the tree"
2546 );
2547
2548 tree.set_dormant(title);
2549 let update = tree.sync_accessibility();
2550 assert!(
2551 find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2552 "a target absent from the tree must not survive in the relation"
2553 );
2554 assert_no_dangling_relationships(&update);
2555 }
2556
2557 #[test]
2558 fn re_registering_a_labelled_by_relation_does_not_duplicate_it() {
2559 let mut tree = WidgetTree::new();
2563 let title = tree.add(FillWidget::new().label("Name"));
2564 let field = tree.add(FillWidget::new());
2565 tree.layout(SizeProposal::exact(200.0, 100.0));
2566
2567 for _ in 0..3 {
2568 tree.push_access_labelled_by(field, title);
2569 }
2570 let update = tree.sync_accessibility();
2571 assert_eq!(
2572 find_node(&update, field).unwrap().labelled_by().len(),
2573 1,
2574 "the same target registered repeatedly is one relation"
2575 );
2576 }
2577
2578 #[test]
2579 fn re_registering_a_described_by_relation_does_not_duplicate_it() {
2580 let mut tree = WidgetTree::new();
2581 let hint = tree.add(FillWidget::new().label("Must be unique"));
2582 let field = tree.add(FillWidget::new());
2583 tree.layout(SizeProposal::exact(200.0, 100.0));
2584
2585 for _ in 0..3 {
2586 tree.push_access_described_by(field, hint);
2587 }
2588 let update = tree.sync_accessibility();
2589 assert_eq!(
2590 find_node(&update, field).unwrap().described_by().len(),
2591 1,
2592 "the same target registered repeatedly is one relation"
2593 );
2594 }
2595
2596 #[test]
2598 fn access_live_assertive_set() {
2599 let mut tree = WidgetTree::new();
2600 let id = tree.add(FillWidget::new().access_live(Live::Assertive));
2601 tree.layout(SizeProposal::exact(50.0, 50.0));
2602 let update = tree.sync_accessibility();
2603 let node = find_node(&update, id).unwrap();
2604 assert_eq!(node.live(), Some(Live::Assertive));
2605 }
2606
2607 #[test]
2609 fn access_aria_current_set() {
2610 let mut tree = WidgetTree::new();
2611 let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
2612 tree.layout(SizeProposal::exact(50.0, 50.0));
2613 let update = tree.sync_accessibility();
2614 let node = find_node(&update, id).unwrap();
2615 assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
2616 }
2617
2618 #[test]
2620 fn access_has_popup_set() {
2621 let mut tree = WidgetTree::new();
2622 let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
2623 tree.layout(SizeProposal::exact(50.0, 50.0));
2624 let update = tree.sync_accessibility();
2625 let node = find_node(&update, id).unwrap();
2626 assert_eq!(node.has_popup(), Some(HasPopup::Menu));
2627 }
2628
2629 #[test]
2631 fn access_orientation_set() {
2632 let mut tree = WidgetTree::new();
2633 let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
2634 tree.layout(SizeProposal::exact(50.0, 50.0));
2635 let update = tree.sync_accessibility();
2636 let node = find_node(&update, id).unwrap();
2637 assert_eq!(node.orientation(), Some(Orientation::Vertical));
2638 }
2639
2640 #[test]
2642 fn access_numeric_value_and_range() {
2643 let mut tree = WidgetTree::new();
2644 let id = tree.add(
2645 FillWidget::new()
2646 .access_role(Role::Slider)
2647 .access_numeric_value(50.0)
2648 .access_numeric_range(0.0, 100.0)
2649 .access_numeric_step(5.0),
2650 );
2651 tree.layout(SizeProposal::exact(50.0, 50.0));
2652 let update = tree.sync_accessibility();
2653 let node = find_node(&update, id).unwrap();
2654 assert_eq!(node.numeric_value(), Some(50.0));
2655 assert_eq!(node.min_numeric_value(), Some(0.0));
2656 assert_eq!(node.max_numeric_value(), Some(100.0));
2657 assert_eq!(node.numeric_value_step(), Some(5.0));
2658 }
2659
2660 #[test]
2662 fn access_action_advertises_and_routes() {
2663 use crate::signal::Signal;
2664 let flag = Signal::new(false);
2665 let flag_for_cb = flag.clone();
2666 let mut tree = WidgetTree::new();
2667 let id = tree.add(
2668 FillWidget::new()
2669 .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
2670 );
2671 tree.layout(SizeProposal::exact(50.0, 50.0));
2672 let info = tree.accessibility_node(id);
2673 assert!(info.actions().contains(&Action::ShowContextMenu));
2674 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2675 action: Action::ShowContextMenu,
2676 target: Some(id),
2677 target_node: crate::accessibility::widget_id_to_node_id(id),
2678 data: None,
2679 });
2680 assert!(flag.get(), "callback should have been invoked");
2681 }
2682
2683 #[test]
2685 fn access_two_actions_both_route() {
2686 use crate::signal::Signal;
2687 let click = Signal::new(false);
2688 let increment = Signal::new(false);
2689 let click_cb = click.clone();
2690 let inc_cb = increment.clone();
2691 let mut tree = WidgetTree::new();
2692 let id = tree.add(
2693 FillWidget::new()
2694 .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
2695 .access_action(Action::Increment, move |_| inc_cb.set(true)),
2696 );
2697 tree.layout(SizeProposal::exact(50.0, 50.0));
2698 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2699 action: Action::ShowContextMenu,
2700 target: Some(id),
2701 target_node: crate::accessibility::widget_id_to_node_id(id),
2702 data: None,
2703 });
2704 assert!(click.get() && !increment.get(), "only first action fired");
2705 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2706 action: Action::Increment,
2707 target: Some(id),
2708 target_node: crate::accessibility::widget_id_to_node_id(id),
2709 data: None,
2710 });
2711 assert!(
2712 click.get() && increment.get(),
2713 "both actions fired exactly once each"
2714 );
2715 }
2716
2717 #[test]
2719 fn access_remove_action_suppresses_widget_action() {
2720 let mut tree = WidgetTree::new();
2721 let id = tree.add(ActionWidget.access_remove_action(Action::Click));
2722 tree.layout(SizeProposal::exact(50.0, 50.0));
2723 let info = tree.accessibility_node(id);
2724 assert!(!info.actions().contains(&Action::Click));
2725 assert!(info.actions().contains(&Action::Focus));
2726 }
2727
2728 #[test]
2730 fn access_custom_action_uses_localized_label() {
2731 let mut tree = WidgetTree::new();
2732 let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
2733 tree.layout(SizeProposal::exact(50.0, 50.0));
2734 let update = tree.sync_accessibility();
2735 let node = find_node(&update, id).unwrap();
2736 let actions = node.custom_actions();
2737 assert_eq!(actions.len(), 1);
2738 assert_eq!(actions[0].id, 0);
2739 assert_eq!(actions[0].description.as_str(), "Reply");
2740 }
2741
2742 #[test]
2744 fn access_custom_action_routes_by_index() {
2745 use crate::signal::Signal;
2746 let first = Signal::new(false);
2747 let second = Signal::new(false);
2748 let f = first.clone();
2749 let s = second.clone();
2750 let mut tree = WidgetTree::new();
2751 let id = tree.add(
2752 FillWidget::new()
2753 .access_custom_action_literal("First", move |_| f.set(true))
2754 .access_custom_action_literal("Second", move |_| s.set(true)),
2755 );
2756 tree.layout(SizeProposal::exact(50.0, 50.0));
2757 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2758 action: Action::CustomAction,
2759 target: Some(id),
2760 target_node: crate::accessibility::widget_id_to_node_id(id),
2761 data: Some(accesskit::ActionData::CustomAction(1)),
2762 });
2763 assert!(!first.get(), "first should not fire");
2764 assert!(second.get(), "second should fire (index 1)");
2765 }
2766
2767 #[derive(Debug)]
2770 struct OwnCustomActionWidget;
2771
2772 const OWN_CUSTOM_ACTION_ID: i32 = 1000;
2773
2774 impl Widget for OwnCustomActionWidget {
2775 fn layout_response(
2776 &self,
2777 proposal: SizeProposal,
2778 _ctx: &LayoutContext,
2779 ) -> crate::widget::LayoutResponse {
2780 proposal.resolve(0.0, 0.0).into()
2781 }
2782
2783 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2784 builder.set_role(accesskit::Role::Slider);
2785 builder.set_custom_actions(vec![accesskit::CustomAction {
2786 id: OWN_CUSTOM_ACTION_ID,
2787 description: "Reset to default".to_owned(),
2788 }]);
2789 }
2790 }
2791
2792 #[test]
2794 fn access_custom_action_appends_to_the_widgets_own() {
2795 use crate::signal::Signal;
2796 let own = Signal::new(0_u32);
2797 let added = Signal::new(0_u32);
2798 let own_sink = own.clone();
2799 let added_sink = added.clone();
2800 let mut tree = WidgetTree::new();
2801 let id = tree.add(
2802 OwnCustomActionWidget
2803 .access_custom_action_literal("Pin", move |_| added_sink.set(added_sink.get() + 1))
2804 .on_access_action_request(move |action, _node, data, _ctx| {
2805 if action == Action::CustomAction
2806 && data == Some(accesskit::ActionData::CustomAction(OWN_CUSTOM_ACTION_ID))
2807 {
2808 own_sink.set(own_sink.get() + 1);
2809 return crate::event::EventResponse::Handled;
2810 }
2811 crate::event::EventResponse::Ignored
2812 }),
2813 );
2814 tree.layout(SizeProposal::exact(50.0, 50.0));
2815 let update = tree.sync_accessibility();
2816 let node = find_node(&update, id).unwrap();
2817 let offered: Vec<(i32, &str)> = node
2818 .custom_actions()
2819 .iter()
2820 .map(|a| (a.id, a.description.as_str()))
2821 .collect();
2822 assert_eq!(
2823 offered,
2824 vec![(OWN_CUSTOM_ACTION_ID, "Reset to default"), (0, "Pin")],
2825 "the application's action joins the widget's rather than replacing it",
2826 );
2827 assert!(node.supports_action(Action::CustomAction));
2828
2829 let invoke = |tree: &mut WidgetTree, custom: i32| {
2830 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2831 action: Action::CustomAction,
2832 target: Some(id),
2833 target_node: crate::accessibility::widget_id_to_node_id(id),
2834 data: Some(accesskit::ActionData::CustomAction(custom)),
2835 });
2836 };
2837 invoke(&mut tree, 0);
2838 assert_eq!(
2839 (own.get(), added.get()),
2840 (0, 1),
2841 "index 0 is the override's"
2842 );
2843 invoke(&mut tree, OWN_CUSTOM_ACTION_ID);
2844 assert_eq!(
2845 (own.get(), added.get()),
2846 (1, 1),
2847 "and the widget's id is still the widget's"
2848 );
2849 }
2850
2851 #[test]
2853 fn access_action_layered_with_on_access_action() {
2854 use crate::signal::Signal;
2855 let from_override = Signal::new(false);
2856 let from_user = Signal::new(false);
2857 let ov_cb = from_override.clone();
2858 let user_cb = from_user.clone();
2859 let mut tree = WidgetTree::new();
2860 let id = tree.add(
2861 FillWidget::new()
2862 .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
2863 .on_access_action(move |_action, _ctx| {
2864 user_cb.set(true);
2865 crate::event::EventResponse::Handled
2866 }),
2867 );
2868 tree.layout(SizeProposal::exact(50.0, 50.0));
2869 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2870 action: Action::ShowContextMenu,
2871 target: Some(id),
2872 target_node: crate::accessibility::widget_id_to_node_id(id),
2873 data: None,
2874 });
2875 assert!(from_override.get(), "override callback fired");
2876 assert!(from_user.get(), "user catch-all fired");
2877 }
2878
2879 #[test]
2885 fn access_label_accepts_resolved_string_via_into() {
2886 struct ResolvedAtCall(String);
2890 impl From<ResolvedAtCall> for crate::signal::Prop<String> {
2891 fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
2892 crate::signal::Prop::Static(v.0)
2893 }
2894 }
2895 let mut tree = WidgetTree::new();
2896 let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
2897 tree.layout(SizeProposal::exact(50.0, 50.0));
2898 assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
2899 }
2900
2901 #[test]
2903 fn access_exclude_subtree_prunes_children_from_at_tree() {
2904 let mut tree = WidgetTree::new();
2905 let inner1 = tree.add(FillWidget::new().label("A"));
2906 let inner2 = tree.add(FillWidget::new().label("B"));
2907 let outer = tree.add(
2908 StackWidget::new()
2909 .child(inner1)
2910 .child(inner2)
2911 .access_label_literal("Section")
2915 .access_exclude_subtree(),
2916 );
2917 tree.layout(SizeProposal::exact(100.0, 50.0));
2918 let update = tree.sync_accessibility();
2919 assert!(find_node(&update, outer).is_some());
2921 assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
2922 assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
2923 let outer_node = find_node(&update, outer).unwrap();
2924 assert!(
2925 outer_node.children().is_empty(),
2926 "outer should have no AT children when excluded"
2927 );
2928 }
2929
2930 #[test]
2932 fn access_merge_subtree_concatenates_descendant_labels() {
2933 let mut tree = WidgetTree::new();
2934 let title = tree.add(FillWidget::new().label("Title"));
2935 let subtitle = tree.add(FillWidget::new().label("Subtitle"));
2936 let card = tree.add(
2937 StackWidget::new()
2938 .child(title)
2939 .child(subtitle)
2940 .access_merge_subtree(),
2941 );
2942 tree.layout(SizeProposal::exact(100.0, 50.0));
2943 assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
2945 let update = tree.sync_accessibility();
2946 assert!(find_node(&update, title).is_none());
2947 assert!(find_node(&update, subtitle).is_none());
2948 }
2949
2950 #[test]
2951 fn access_merge_subtree_takes_nothing_from_under_a_hidden_descendant() {
2952 let mut tree = WidgetTree::new();
2957 let title = tree.add(FillWidget::new().label("Title"));
2958 let secret = tree.add(FillWidget::new().label("Secret"));
2959 let hidden = tree.add(StackWidget::new().child(secret).access_hidden(true));
2960 let card = tree.add(
2961 StackWidget::new()
2962 .child(title)
2963 .child(hidden)
2964 .access_merge_subtree(),
2965 );
2966 tree.layout(SizeProposal::exact(100.0, 50.0));
2967 assert_eq!(tree.text_content(card), Some("Title".to_string()));
2968 }
2969
2970 #[test]
2972 fn access_merge_subtree_unions_actions() {
2973 let mut tree = WidgetTree::new();
2974 let click_a = tree.add(ClickableWidget);
2975 let click_b = tree.add(ClickableWidget);
2976 let card = tree.add(
2977 StackWidget::new()
2978 .child(click_a)
2979 .child(click_b)
2980 .access_merge_subtree(),
2981 );
2982 tree.layout(SizeProposal::exact(100.0, 50.0));
2983 let actions = tree.accessibility_node(card).actions().to_vec();
2984 let click_count = actions.iter().filter(|a| **a == Action::Click).count();
2985 assert_eq!(
2986 click_count, 1,
2987 "Click should be present exactly once after merge (deduplicated)"
2988 );
2989 }
2990
2991 #[test]
3001 fn access_merge_subtree_first_nonempty_value_wins() {
3002 #[derive(Debug)]
3003 struct ValueWidget(&'static str);
3004 impl Widget for ValueWidget {
3005 fn layout_response(
3006 &self,
3007 proposal: SizeProposal,
3008 _ctx: &LayoutContext,
3009 ) -> crate::widget::LayoutResponse {
3010 proposal.resolve(0.0, 0.0).into()
3011 }
3012 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3013 builder.set_role(Role::Slider);
3014 builder.set_value(self.0);
3015 }
3016 }
3017 let mut tree = WidgetTree::new();
3018 let v1 = tree.add(ValueWidget("first"));
3019 let v2 = tree.add(ValueWidget("second"));
3020 let card = tree.add(
3021 StackWidget::new()
3022 .child(v1)
3023 .child(v2)
3024 .access_merge_subtree(),
3025 );
3026 tree.layout(SizeProposal::exact(100.0, 50.0));
3027 assert_eq!(tree.text_value(card), Some("first".to_string()));
3028 }
3029
3030 #[test]
3032 fn access_exclude_inside_merge() {
3033 let mut tree = WidgetTree::new();
3034 let visible = tree.add(FillWidget::new().label("VISIBLE"));
3035 let pruned = tree.add(FillWidget::new().label("PRUNED"));
3036 let inner_excluded = tree.add(StackWidget::new().child(pruned).access_exclude_subtree());
3037 let card = tree.add(
3038 StackWidget::new()
3039 .child(visible)
3040 .child(inner_excluded)
3041 .access_merge_subtree(),
3042 );
3043 tree.layout(SizeProposal::exact(100.0, 50.0));
3044 let merged = tree.text_content(card).unwrap_or_default();
3045 assert!(merged.contains("VISIBLE"));
3048 assert!(
3049 !merged.contains("PRUNED"),
3050 "excluded subtree should not contribute to merge"
3051 );
3052 }
3053
3054 #[test]
3056 fn access_merge_inside_merge() {
3057 let mut tree = WidgetTree::new();
3058 let inner_a = tree.add(FillWidget::new().label("a"));
3059 let inner_b = tree.add(FillWidget::new().label("b"));
3060 let inner_card = tree.add(
3061 StackWidget::new()
3062 .child(inner_a)
3063 .child(inner_b)
3064 .access_merge_subtree(),
3065 );
3066 let outer_extra = tree.add(FillWidget::new().label("X"));
3067 let outer = tree.add(
3068 StackWidget::new()
3069 .child(inner_card)
3070 .child(outer_extra)
3071 .access_merge_subtree(),
3072 );
3073 tree.layout(SizeProposal::exact(200.0, 100.0));
3074 let merged = tree.text_content(outer).unwrap_or_default();
3079 assert!(merged.contains("a"));
3080 assert!(merged.contains("b"));
3081 assert!(merged.contains("X"));
3082 let update = tree.sync_accessibility();
3084 assert!(find_node(&update, inner_a).is_none());
3085 assert!(find_node(&update, inner_b).is_none());
3086 assert!(find_node(&update, inner_card).is_none());
3087 assert!(find_node(&update, outer_extra).is_none());
3088 }
3089
3090 #[test]
3092 fn access_customize_runs_last() {
3093 let mut tree = WidgetTree::new();
3094 let id = tree.add(
3095 FillWidget::new()
3096 .access_label_literal("A")
3097 .access_customize(|b| b.set_name("B")),
3098 );
3099 tree.layout(SizeProposal::exact(50.0, 50.0));
3100 assert_eq!(tree.accessibility_node(id).name(), Some("B"));
3101 }
3102
3103 #[test]
3105 fn access_customize_can_reach_inner_mut() {
3106 let mut tree = WidgetTree::new();
3107 let id = tree.add(FillWidget::new().access_customize(|b| {
3108 b.inner_mut().set_author_id("from-customize");
3109 }));
3110 tree.layout(SizeProposal::exact(50.0, 50.0));
3111 let update = tree.sync_accessibility();
3112 let node = find_node(&update, id).unwrap();
3113 assert_eq!(node.author_id(), Some("from-customize"));
3114 }
3115
3116 #[test]
3118 fn access_overrides_zero_cost_when_unused() {
3119 use std::mem::size_of;
3126 assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
3127 assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
3128 }
3129
3130 #[test]
3131 fn accessibility_children_overrides_at_reading_order() {
3132 #[derive(Debug)]
3137 struct NamedLeaf(&'static str);
3138 impl Widget for NamedLeaf {
3139 fn layout_response(
3140 &self,
3141 proposal: SizeProposal,
3142 _ctx: &LayoutContext,
3143 ) -> crate::widget::LayoutResponse {
3144 proposal.resolve(10.0, 10.0).into()
3145 }
3146 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3147 builder.set_role(accesskit::Role::Button);
3151 builder.set_name(self.0);
3152 }
3153 }
3154
3155 #[derive(Debug, Default)]
3156 struct ReorderContainer {
3157 a: Option<WidgetId>,
3158 b: Option<WidgetId>,
3159 }
3160 impl Widget for ReorderContainer {
3161 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
3162 let a = ctx.add(NamedLeaf("A"));
3163 let b = ctx.add(NamedLeaf("B"));
3164 self.a = Some(a);
3165 self.b = Some(b);
3166 vec![a, b]
3167 }
3168 fn layout_response(
3169 &self,
3170 proposal: SizeProposal,
3171 _ctx: &LayoutContext,
3172 ) -> crate::widget::LayoutResponse {
3173 proposal.resolve(100.0, 100.0).into()
3174 }
3175 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3176 builder.set_role(accesskit::Role::Group);
3179 builder.set_name("Container");
3180 }
3181 fn children(&self) -> Vec<WidgetId> {
3182 [self.a, self.b].into_iter().flatten().collect()
3183 }
3184 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
3185 Some([self.b, self.a].into_iter().flatten().collect())
3187 }
3188 }
3189
3190 let mut tree = WidgetTree::new();
3191 let container = tree.add(ReorderContainer::default());
3192 tree.layout(SizeProposal::exact(100.0, 100.0));
3193 let update = tree.sync_accessibility();
3194
3195 let a_nid = update
3196 .nodes
3197 .iter()
3198 .find(|(_, n)| n.label() == Some("A"))
3199 .map(|(id, _)| *id)
3200 .expect("A node present");
3201 let b_nid = update
3202 .nodes
3203 .iter()
3204 .find(|(_, n)| n.label() == Some("B"))
3205 .map(|(id, _)| *id)
3206 .expect("B node present");
3207 let cnid = crate::accessibility::widget_id_to_node_id(container);
3208 let cnode = update
3209 .nodes
3210 .iter()
3211 .find(|(id, _)| *id == cnid)
3212 .map(|(_, n)| n)
3213 .expect("container node present");
3214 let kids = cnode.children();
3215 let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
3216 let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
3217 assert!(
3218 pb < pa,
3219 "accessibility_children() must set AT order B-before-A; got {kids:?}"
3220 );
3221 }
3222
3223 fn consumer_bounding_box(update: &accesskit::TreeUpdate, label: &str) -> accesskit::Rect {
3237 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
3238 let state = consumer.state();
3239 let mut stack = vec![state.root()];
3240 while let Some(node) = stack.pop() {
3241 let text = node.value().or_else(|| node.label());
3244 if text.as_deref() == Some(label) {
3245 return node.bounding_box().expect("node has bounds");
3246 }
3247 for child in node.children() {
3248 stack.push(child);
3249 }
3250 }
3251 panic!("no node labelled {label:?} in the consumer tree");
3252 }
3253
3254 fn tree_at_scale(scale: f32) -> (WidgetTree, WidgetId) {
3255 let mut tree = WidgetTree::new();
3256 let id = tree.add(FillWidget::new().label("content"));
3257 tree.set_device_scale_factor(scale);
3258 tree.layout(SizeProposal::exact(200.0, 100.0));
3259 (tree, id)
3260 }
3261
3262 #[test]
3263 fn the_root_carries_the_device_scale_as_its_transform() {
3264 let (mut tree, _) = tree_at_scale(2.0);
3265 let update = tree.sync_accessibility();
3266 let root = update
3267 .nodes
3268 .iter()
3269 .find(|(id, _)| *id == crate::accessibility::root_node_id())
3270 .map(|(_, n)| n)
3271 .expect("root node present");
3272 assert_eq!(
3273 root.transform().copied(),
3274 Some(accesskit::Affine::scale(2.0)),
3275 "AccessKit wants physical coordinates; the tree emits logical ones"
3276 );
3277 assert_a11y_tree_valid(&update);
3278 }
3279
3280 #[test]
3281 fn emitted_bounds_stay_logical_at_scale_two() {
3282 let (mut tree, id) = tree_at_scale(2.0);
3291 let update = tree.sync_accessibility();
3292 let layout = tree.bounds(id);
3293 let nid = crate::accessibility::widget_id_to_node_id(id);
3294 let raw = update
3295 .nodes
3296 .iter()
3297 .find(|(n, _)| *n == nid)
3298 .and_then(|(_, n)| n.bounds())
3299 .expect("widget node has bounds");
3300 assert_eq!(
3301 (raw.x0, raw.y0, raw.x1, raw.y1),
3302 (
3303 layout.x as f64,
3304 layout.y as f64,
3305 (layout.x + layout.width) as f64,
3306 (layout.y + layout.height) as f64,
3307 ),
3308 "an emitted rectangle must be the logical layout rectangle, unscaled"
3309 );
3310 }
3311
3312 #[test]
3331 fn an_interactive_node_emits_its_exact_layout_rectangle() {
3332 let mut tree = WidgetTree::new();
3333 let id = tree.add(ActionWidget);
3334 tree.layout(SizeProposal::exact(200.0, 100.0));
3335 let update = tree.sync_accessibility();
3336 let nid = crate::accessibility::widget_id_to_node_id(id);
3337 let node = update
3338 .nodes
3339 .iter()
3340 .find(|(n, _)| *n == nid)
3341 .map(|(_, n)| n)
3342 .expect("the interactive node is in the update");
3343 assert!(
3344 node.supports_action(accesskit::Action::Click),
3345 "the fixture has to be the clickable shape the rule is about, \
3346 or this test guards nothing"
3347 );
3348 let layout = tree.bounds(id);
3349 assert!(
3350 layout.width > 0.0 && layout.height > 0.0,
3351 "a real, non-degenerate rectangle to compare against: {layout:?}"
3352 );
3353 let raw = node.bounds().expect("the interactive node has bounds");
3354 assert_eq!(
3355 (raw.x0, raw.y0, raw.x1, raw.y1),
3356 (
3357 layout.x as f64,
3358 layout.y as f64,
3359 (layout.x + layout.width) as f64,
3360 (layout.y + layout.height) as f64,
3361 ),
3362 "a clickable node's emitted rectangle is its layout rectangle, \
3363 not an outset one"
3364 );
3365 }
3366
3367 #[test]
3368 fn what_an_assistive_technology_reads_is_the_physical_rectangle() {
3369 let (mut one, _) = tree_at_scale(1.0);
3373 let at_one = consumer_bounding_box(&one.sync_accessibility(), "content");
3374 let (mut two, _) = tree_at_scale(2.0);
3375 let at_two = consumer_bounding_box(&two.sync_accessibility(), "content");
3376 assert_eq!(at_two.x0, at_one.x0 * 2.0);
3377 assert_eq!(at_two.y0, at_one.y0 * 2.0);
3378 assert_eq!(at_two.x1, at_one.x1 * 2.0);
3379 assert_eq!(at_two.y1, at_one.y1 * 2.0);
3380 assert!(at_two.x1 > at_two.x0, "a real, non-degenerate rectangle");
3381 }
3382
3383 #[test]
3384 fn a_scale_change_invalidates_the_accessibility_cache() {
3385 let (mut tree, _) = tree_at_scale(1.0);
3391 let _ = tree.sync_accessibility();
3392 tree.set_device_scale_factor(2.0);
3393 let update = tree.sync_accessibility();
3394 let root = update
3395 .nodes
3396 .iter()
3397 .find(|(id, _)| *id == crate::accessibility::root_node_id())
3398 .map(|(_, n)| n)
3399 .expect("root node present");
3400 assert_eq!(
3401 root.transform().copied(),
3402 Some(accesskit::Affine::scale(2.0))
3403 );
3404 }
3405
3406 #[test]
3407 fn setting_the_same_scale_does_not_dirty_the_tree() {
3408 let (mut tree, _) = tree_at_scale(2.0);
3412 let first = tree.sync_accessibility();
3413 tree.set_device_scale_factor(2.0);
3414 let second = tree.sync_accessibility();
3415 assert_eq!(first, second);
3416 }
3417
3418 #[test]
3421 fn explore_by_touch_is_off_by_default() {
3422 let tree = WidgetTree::new();
3423 assert_eq!(tree.explore_by_touch(), crate::ExploreByTouch::Off);
3424 assert_eq!(
3425 tree.screen_reader_state(),
3426 crate::ScreenReaderState::Unknown
3427 );
3428 assert!(!tree.explore_by_touch_active());
3429 }
3430
3431 #[test]
3432 fn auto_follows_the_operating_systems_screen_reader_flag() {
3433 let mut tree = WidgetTree::new();
3434 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3435 assert!(!tree.explore_by_touch_active(), "Unknown is not a yes");
3436 tree.set_screen_reader_state(crate::ScreenReaderState::Inactive);
3437 assert!(!tree.explore_by_touch_active());
3438 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3439 assert!(tree.explore_by_touch_active());
3440 }
3441
3442 #[test]
3443 fn on_ignores_the_screen_reader_flag_and_off_ignores_everything() {
3444 let mut tree = WidgetTree::new();
3445 tree.set_explore_by_touch(crate::ExploreByTouch::On);
3446 assert!(tree.explore_by_touch_active(), "On is the app's own choice");
3447 tree.set_explore_by_touch(crate::ExploreByTouch::Off);
3448 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3449 assert!(!tree.explore_by_touch_active());
3450 }
3451
3452 #[test]
3453 fn an_attached_accesskit_client_does_not_switch_exploring_on() {
3454 let mut tree = WidgetTree::new();
3460 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3461 tree.set_at_client_attached(true);
3462 assert!(tree.at_client_attached());
3463 assert_eq!(
3464 tree.screen_reader_state(),
3465 crate::ScreenReaderState::Unknown,
3466 "attaching says nothing about screen readers"
3467 );
3468 assert!(!tree.explore_by_touch_active());
3469 }
3470
3471 #[test]
3472 fn touch_still_reaches_a_handler_with_an_accesskit_client_attached() {
3473 use crate::Modifiers;
3479 use crate::pointer::{
3480 BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase,
3481 PointerSample,
3482 };
3483 use crate::widget_builder::WidgetBuilder;
3484 use std::cell::Cell;
3485 use std::rc::Rc;
3486
3487 let taps = Rc::new(Cell::new(0u32));
3488 let seen = Rc::clone(&taps);
3489 let mut tree = WidgetTree::new();
3490 let id = tree.add(
3491 FillWidget::new()
3492 .label("target")
3493 .on_tap(move |_e, _ctx| seen.set(seen.get() + 1)),
3494 );
3495 tree.layout(SizeProposal::exact(200.0, 100.0));
3496 tree.set_at_client_attached(true);
3497 assert!(!tree.explore_by_touch_active());
3498
3499 let at = Point::new(50.0, 50.0);
3500 let pid = PointerIdAllocator::global().begin(BackendDeviceKey::new(0x0A11), 1);
3501 let contact = |phase| PointerSample {
3502 pointer: PointerInfo::touch(pid, EventTime::ZERO),
3503 phase,
3504 position: at,
3505 button: None,
3506 modifiers: Modifiers::NONE,
3507 coalesced: Vec::new(),
3508 };
3509 tree.dispatch_pointer(contact(PointerPhase::Down));
3510 tree.dispatch_pointer(contact(PointerPhase::Up));
3511 assert!(tree.bounds(id).width > 0.0);
3512 assert_eq!(taps.get(), 1, "a touch tap must still activate its handler");
3513 }
3514
3515 #[test]
3516 fn a_detaching_client_is_evidence_that_no_screen_reader_is_reading() {
3517 let mut tree = WidgetTree::new();
3521 tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
3522 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3523 tree.set_at_client_attached(true);
3524 assert!(tree.explore_by_touch_active());
3525 tree.set_at_client_attached(false);
3526 assert_eq!(
3527 tree.screen_reader_state(),
3528 crate::ScreenReaderState::Inactive
3529 );
3530 assert!(!tree.explore_by_touch_active());
3531 }
3532
3533 #[test]
3534 fn a_repeated_detach_notice_does_not_clobber_a_fresh_os_reading() {
3535 let mut tree = WidgetTree::new();
3539 tree.set_at_client_attached(false);
3540 tree.set_screen_reader_state(crate::ScreenReaderState::Active);
3541 tree.set_at_client_attached(false);
3542 assert_eq!(tree.screen_reader_state(), crate::ScreenReaderState::Active);
3543 }
3544
3545 #[test]
3548 fn a_density_switch_announces_once_in_the_applications_own_words() {
3549 use teksilo_tokens::TargetDensity;
3550 let mut tree = tree_with_one_widget();
3551 tree.set_density_announcement(Some(std::rc::Rc::new(|d: TargetDensity| {
3552 format!("layout {d:?}")
3553 })));
3554 let _ = tree.sync_accessibility();
3555
3556 let mut arrivals = Arrivals::attach(tree.accessibility_tree_snapshot());
3557
3558 tree.set_input_density(TargetDensity::Comfortable);
3559 let update = tree.sync_accessibility();
3560 assert_eq!(
3561 arrivals.feed(update),
3562 vec![("layout Comfortable".to_string(), accesskit::Live::Polite)]
3563 );
3564
3565 let _ = arrivals.feed(tree.sync_accessibility());
3567 tree.set_input_density(TargetDensity::Comfortable);
3568 let update = tree.sync_accessibility();
3569 assert_eq!(arrivals.feed(update), Vec::new());
3570 }
3571
3572 #[test]
3573 fn a_density_switch_is_silent_without_a_registered_wording() {
3574 use teksilo_tokens::TargetDensity;
3579 let mut tree = tree_with_one_widget();
3580 let _ = tree.sync_accessibility();
3581 tree.set_input_density(TargetDensity::Comfortable);
3582 let update = tree.sync_accessibility();
3583 assert_eq!(announced_by_consumer(&update), Vec::new());
3584 }
3585
3586 #[test]
3587 fn an_announcement_stays_quiet_when_the_widget_already_speaks() {
3588 let mut tree = WidgetTree::new();
3589 use crate::widget_builder::WidgetBuilder;
3590 let widget = tree.add(
3591 FillWidget::new()
3592 .label("Saved")
3593 .access_live(accesskit::Live::Polite),
3594 );
3595 tree.layout(SizeProposal::exact(200.0, 100.0));
3596 let _ = tree.sync_accessibility();
3598
3599 assert!(
3600 !tree.announce_unless_widget_speaks(widget, "Saved"),
3601 "a widget with its own live region must not be doubled"
3602 );
3603 let update = tree.sync_accessibility();
3604 let live = live_nodes_in_filtered_tree(&update);
3608 assert_eq!(live.len(), 1, "exactly one voice, not two: {live:?}");
3609 }
3610
3611 #[test]
3612 fn an_announcement_goes_ahead_when_the_widget_is_silent() {
3613 let mut tree = WidgetTree::new();
3614 let widget = tree.add(FillWidget::new().label("Row"));
3615 tree.layout(SizeProposal::exact(200.0, 100.0));
3616 let _ = tree.sync_accessibility();
3617
3618 assert!(tree.announce_unless_widget_speaks(widget, "Moved to 3 of 12"));
3619 let update = tree.sync_accessibility();
3620 assert_eq!(
3621 announced_by_consumer(&update),
3622 vec![("Moved to 3 of 12".to_string(), accesskit::Live::Polite)]
3623 );
3624 }
3625
3626 #[test]
3627 fn a_tree_that_records_no_announcements_still_hears_the_widget_speak() {
3628 let mut tree = WidgetTree::new();
3633 tree.set_records_announcements(false);
3634 use crate::widget_builder::WidgetBuilder;
3635 let widget = tree.add(
3636 FillWidget::new()
3637 .label("Saved")
3638 .access_live(accesskit::Live::Polite),
3639 );
3640 tree.layout(SizeProposal::exact(200.0, 100.0));
3641 let _ = tree.sync_accessibility();
3642 assert!(!tree.announce_unless_widget_speaks(widget, "Saved"));
3643 }
3644
3645 #[test]
3646 fn a_live_region_no_platform_hears_does_not_count_as_speaking() {
3647 let mut tree = WidgetTree::new();
3652 use crate::widget_builder::WidgetBuilder;
3653 let widget = tree.add(
3654 FillWidget::new()
3655 .access_role(accesskit::Role::Status)
3656 .access_value("Saved")
3657 .access_live(accesskit::Live::Polite),
3658 );
3659 tree.layout(SizeProposal::exact(200.0, 100.0));
3660 let _ = tree.sync_accessibility();
3661 assert!(tree.announce_unless_widget_speaks(widget, "Saved"));
3662 }
3663
3664 #[test]
3665 fn a_hidden_live_region_does_not_count_as_speaking() {
3666 let mut tree = WidgetTree::new();
3668 use crate::widget_builder::WidgetBuilder;
3669 let widget = tree.add(
3670 FillWidget::new()
3671 .label("Saved")
3672 .access_live(accesskit::Live::Polite)
3673 .access_hidden(true),
3674 );
3675 tree.layout(SizeProposal::exact(200.0, 100.0));
3676 let _ = tree.sync_accessibility();
3677 assert!(tree.announce_unless_widget_speaks(widget, "Saved"));
3678 }
3679
3680 #[test]
3681 fn a_live_region_around_the_widget_does_not_count_as_the_widget_speaking() {
3682 let mut tree = WidgetTree::new();
3688 use crate::widget_builder::WidgetBuilder;
3689 let region = tree.add(
3690 StackWidget::new()
3691 .access_role(accesskit::Role::Group)
3692 .access_label("Notification")
3693 .access_live(accesskit::Live::Polite),
3694 );
3695 let button = tree.add_child(
3696 region,
3697 FillWidget::new()
3698 .label("Annuler")
3699 .access_role(accesskit::Role::Button),
3700 );
3701 tree.layout(SizeProposal::exact(200.0, 100.0));
3702 let _ = tree.sync_accessibility();
3703 assert!(tree.announce_unless_widget_speaks(button, "Menu des actions ouvert"));
3704 }
3705
3706 #[test]
3707 fn a_live_region_with_no_text_does_not_count_as_speaking() {
3708 let mut tree = WidgetTree::new();
3711 use crate::widget_builder::WidgetBuilder;
3712 let widget = tree.add(FillWidget::new().access_live(accesskit::Live::Polite));
3713 tree.layout(SizeProposal::exact(200.0, 100.0));
3714 let _ = tree.sync_accessibility();
3715 assert!(tree.announce_unless_widget_speaks(widget, "Something happened"));
3716 }
3717
3718 #[derive(Debug)]
3726 struct SpeakingSynthetic;
3727
3728 impl crate::widget::Widget for SpeakingSynthetic {
3729 fn layout_response(
3730 &self,
3731 proposal: SizeProposal,
3732 _ctx: &crate::widget::LayoutContext,
3733 ) -> crate::widget::LayoutResponse {
3734 proposal.resolve(0.0, 0.0).into()
3735 }
3736
3737 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3738 builder.set_role(accesskit::Role::Group);
3739 let child = builder.push_scene_child(
3740 1,
3741 crate::accessibility::SyntheticKind::SceneItem,
3742 |item| {
3743 item.set_role(accesskit::Role::Status);
3744 item.set_name("2 items selected");
3745 },
3746 );
3747 builder.with_collected_node(child, |node| node.set_live(accesskit::Live::Polite));
3748 }
3749 }
3750
3751 #[test]
3752 fn a_live_synthetic_child_counts_as_the_widget_speaking() {
3753 let mut tree = WidgetTree::new();
3758 let widget = tree.add(SpeakingSynthetic);
3759 tree.layout(SizeProposal::exact(200.0, 100.0));
3760 let update = tree.sync_accessibility();
3761 assert!(
3762 update
3763 .nodes
3764 .iter()
3765 .any(|(id, _)| crate::accessibility::is_synthetic(*id)),
3766 "the fixture must actually emit a synthetic child"
3767 );
3768 assert!(!tree.announce_unless_widget_speaks(widget, "2 items selected"));
3769 }
3770
3771 #[test]
3772 fn a_descendants_live_region_silences_its_ancestor_too() {
3773 let mut tree = WidgetTree::new();
3776 let container = tree.add(StackWidget::new());
3777 use crate::widget_builder::WidgetBuilder;
3778 let inner = FillWidget::new()
3779 .label("3 results")
3780 .access_live(accesskit::Live::Polite);
3781 tree.add_child(container, inner);
3782 tree.layout(SizeProposal::exact(200.0, 100.0));
3783 let _ = tree.sync_accessibility();
3784 assert!(!tree.announce_unless_widget_speaks(container, "3 results"));
3785 }
3786}