1use super::*;
5
6use crate::accessibility::{AccessNodeBuilder, AccessibilityInfo};
7
8impl WidgetTree {
9 pub fn sync_accessibility(&mut self) -> accesskit::TreeUpdate {
20 if self.a11y_update_requested.replace(false) {
25 self.a11y_dirty = true;
26 }
27 let current_shortcut_version = self.shortcut_registry.version().get();
35 if current_shortcut_version != self.last_synced_shortcut_version {
36 self.a11y_dirty = true;
37 self.last_synced_shortcut_version = current_shortcut_version;
38 }
39
40 let current_locale = self.locale_signal.get();
49 if current_locale != self.last_synced_locale {
50 self.a11y_dirty = true;
51 self.last_synced_locale = current_locale;
52 }
53
54 let announcers_busy = self.step_announcers();
62
63 if !self.a11y_dirty
64 && let Some(cached) = &self.cached_a11y
65 {
66 return cached.clone();
67 }
68
69 let (update, parents) = self.build_accessibility_tree();
70 self.collect_announcements(&update);
76 let content_changed = self.cached_a11y.as_ref() != Some(&update);
83 self.cached_a11y = Some(update.clone());
84 self.synthetic_parent_map = parents;
85 self.a11y_dirty = false;
86 if content_changed {
87 self.at_version.set(self.at_version.get().saturating_add(1));
90 }
91 if announcers_busy {
92 self.request_accessibility_update();
93 self.request_frame();
94 }
95 update
96 }
97
98 pub fn accessibility_tree_snapshot(&self) -> accesskit::TreeUpdate {
112 self.build_accessibility_tree().0
113 }
114
115 fn step_announcers(&mut self) -> bool {
118 let polite = self.announcer_polite.step();
122 let assertive = self.announcer_assertive.step();
123 if polite || assertive {
124 self.a11y_dirty = true;
125 }
126 polite || assertive
127 }
128
129 fn collect_announcements(&mut self, update: &accesskit::TreeUpdate) {
135 use accesskit::Live;
136 let mut seen: std::collections::HashSet<accesskit::NodeId> =
137 std::collections::HashSet::with_capacity(self.automation_last_text.len());
138 for (node_id, node) in &update.nodes {
139 let assertive = match node.live() {
140 Some(Live::Polite) => false,
141 Some(Live::Assertive) => true,
142 _ => continue,
144 };
145 let text = node
148 .value()
149 .or_else(|| node.label())
150 .unwrap_or("")
151 .trim()
152 .to_string();
153 if text.is_empty() {
154 self.automation_last_text.remove(node_id);
159 continue;
160 }
161 seen.insert(*node_id);
162 let changed = self
163 .automation_last_text
164 .get(node_id)
165 .map(|prev| prev != &text)
166 .unwrap_or(true);
167 if !changed {
168 continue;
169 }
170 self.automation_last_text.insert(*node_id, text.clone());
171 self.automation_announce_seq = self.automation_announce_seq.saturating_add(1);
172 if self.automation_announcements.len() >= super::AUTOMATION_ANNOUNCE_CAP {
173 self.automation_announcements.pop_front();
174 }
175 self.automation_announcements
176 .push_back(crate::accessibility::Announcement {
177 seq: self.automation_announce_seq,
178 text,
179 assertive,
180 });
181 }
182 self.automation_last_text.retain(|k, _| seen.contains(k));
185 }
186
187 pub fn dispatch_access_action(
210 &mut self,
211 node_id: accesskit::NodeId,
212 action: accesskit::Action,
213 data: Option<accesskit::ActionData>,
214 ops: &mut dyn crate::window::WindowOps,
215 ) -> bool {
216 let target = crate::accessibility::node_id_to_widget_id_maybe(node_id)
217 .or_else(|| self.widget_for_synthetic(node_id));
218 let event = crate::event::WidgetEvent::AccessAction {
219 action,
220 target,
221 target_node: node_id,
222 data,
223 };
224 self.access_action_handled = false;
225 self.dispatch_event_with_ops(event, ops);
226 self.access_action_handled
227 }
228
229 fn build_accessibility_tree(
230 &self,
231 ) -> (
232 accesskit::TreeUpdate,
233 std::collections::HashMap<accesskit::NodeId, WidgetId>,
234 ) {
235 use crate::accessibility::{root_node_id, widget_id_to_node_id};
236
237 let roots = self.arena.roots();
238 let mut nodes: Vec<(accesskit::NodeId, accesskit::Node)> = Vec::new();
239 let mut synthetic_parents: std::collections::HashMap<accesskit::NodeId, WidgetId> =
240 std::collections::HashMap::new();
241 let mut seen_children: std::collections::HashMap<accesskit::NodeId, WidgetId> =
246 std::collections::HashMap::new();
247
248 let mut root = accesskit::Node::new(accesskit::Role::Window);
249 if let Some(locale) = self.locale_signal.get() {
256 root.set_language(locale);
257 }
258 for &root_id in &roots {
259 if self.arena.is_active(root_id) {
260 let child_nid = widget_id_to_node_id(root_id);
261 if seen_children.insert(child_nid, root_id).is_none() {
262 root.push_child(child_nid);
263 } else {
264 eprintln!(
265 "Teksilo bug: duplicate accessibility child {:?} in Window root — \
266 already claimed by another parent. Please file a bug report.",
267 root_id
268 );
269 }
270 }
271 }
272 let announcer_nodes = [
278 self.announcer_polite.node(),
279 self.announcer_assertive.node(),
280 ];
281 for (id, _) in &announcer_nodes {
282 root.push_child(*id);
283 }
284
285 nodes.push((root_node_id(), root));
286 nodes.extend(announcer_nodes);
287
288 let mut handled_tooltips: std::collections::HashSet<WidgetId> =
292 std::collections::HashSet::new();
293 for &root_id in &roots {
294 self.build_accessibility_recursive(
295 root_id,
296 &mut nodes,
297 &mut synthetic_parents,
298 &mut seen_children,
299 &mut handled_tooltips,
300 );
301 }
302
303 let focus = self
304 .focused
305 .filter(|id| self.arena.is_active(*id))
306 .map(widget_id_to_node_id)
307 .unwrap_or_else(root_node_id);
308
309 {
325 use accesskit::Role;
326 use std::collections::HashMap;
327
328 fn names_from_content(role: Role) -> bool {
332 matches!(role, Role::TreeItem | Role::ListBoxOption)
333 }
334
335 let index: HashMap<accesskit::NodeId, usize> = nodes
336 .iter()
337 .enumerate()
338 .map(|(i, (nid, _))| (*nid, i))
339 .collect();
340
341 let mut hoisted: Vec<(usize, String)> = Vec::new();
342 for (i, (_, node)) in nodes.iter().enumerate() {
343 if !names_from_content(node.role()) || node.label().is_some() {
344 continue;
345 }
346 let mut stack: Vec<accesskit::NodeId> =
348 node.children().iter().rev().copied().collect();
349 while let Some(nid) = stack.pop() {
350 let Some(&j) = index.get(&nid) else { continue };
351 let descendant = &nodes[j].1;
352 if names_from_content(descendant.role()) {
353 continue;
354 }
355 match descendant.label() {
356 Some(label) if !label.trim().is_empty() => {
357 hoisted.push((i, label.to_string()));
358 break;
359 }
360 _ => stack.extend(descendant.children().iter().rev().copied()),
361 }
362 }
363 }
364 for (i, label) in hoisted {
365 nodes[i].1.set_label(label);
366 }
367 }
368
369 {
385 use std::collections::{HashMap, HashSet};
386
387 let mut relation_targets: HashSet<accesskit::NodeId> = HashSet::new();
390 for (_, node) in &nodes {
391 relation_targets.extend(node.controls());
392 relation_targets.extend(node.described_by());
393 relation_targets.extend(node.labelled_by());
394 }
395
396 let prunable: HashSet<accesskit::NodeId> = nodes
397 .iter()
398 .filter(|(nid, node)| {
399 *nid != root_node_id()
400 && *nid != focus
401 && !relation_targets.contains(nid)
402 && is_presentational_container(node)
403 })
404 .map(|(nid, _)| *nid)
405 .collect();
406
407 if !prunable.is_empty() {
408 let children_map: HashMap<accesskit::NodeId, Vec<accesskit::NodeId>> = nodes
410 .iter()
411 .map(|(nid, node)| (*nid, node.children().to_vec()))
412 .collect();
413
414 fn resolve(
419 nid: accesskit::NodeId,
420 children_map: &HashMap<accesskit::NodeId, Vec<accesskit::NodeId>>,
421 prunable: &HashSet<accesskit::NodeId>,
422 memo: &mut HashMap<accesskit::NodeId, Vec<accesskit::NodeId>>,
423 ) -> Vec<accesskit::NodeId> {
424 if let Some(cached) = memo.get(&nid) {
425 return cached.clone();
426 }
427 let mut out = Vec::new();
428 if let Some(kids) = children_map.get(&nid) {
429 for &c in kids {
430 if prunable.contains(&c) {
431 out.extend(resolve(c, children_map, prunable, memo));
432 } else {
433 out.push(c);
434 }
435 }
436 }
437 memo.insert(nid, out.clone());
438 out
439 }
440
441 let mut memo: HashMap<accesskit::NodeId, Vec<accesskit::NodeId>> = HashMap::new();
442 for (nid, node) in &mut nodes {
443 if prunable.contains(nid) {
444 continue;
445 }
446 let resolved = resolve(*nid, &children_map, &prunable, &mut memo);
447 if children_map.get(nid) != Some(&resolved) {
448 node.set_children(resolved);
449 }
450 }
451 nodes.retain(|(nid, _)| !prunable.contains(nid));
452 }
453 }
454
455 let emitted: std::collections::HashSet<accesskit::NodeId> =
462 nodes.iter().map(|(id, _)| *id).collect();
463 for (_, node) in &mut nodes {
464 let controlled: Vec<_> = node
465 .controls()
466 .iter()
467 .filter(|id| emitted.contains(*id))
468 .copied()
469 .collect();
470 if controlled.len() != node.controls().len() {
471 node.set_controls(controlled);
472 }
473 let described: Vec<_> = node
474 .described_by()
475 .iter()
476 .filter(|id| emitted.contains(*id))
477 .copied()
478 .collect();
479 if described.len() != node.described_by().len() {
480 node.set_described_by(described);
481 }
482 }
483
484 (
485 accesskit::TreeUpdate {
486 nodes,
487 tree: Some(accesskit::TreeInfo::new(root_node_id())),
488 tree_id: accesskit::TreeId::ROOT,
489 focus,
490 },
491 synthetic_parents,
492 )
493 }
494
495 pub fn widget_for_synthetic(&self, node_id: accesskit::NodeId) -> Option<WidgetId> {
503 self.synthetic_parent_map.get(&node_id).copied()
504 }
505
506 fn build_accessibility_recursive(
507 &self,
508 id: WidgetId,
509 nodes: &mut Vec<(accesskit::NodeId, accesskit::Node)>,
510 synthetic_parents: &mut std::collections::HashMap<accesskit::NodeId, WidgetId>,
511 seen_children: &mut std::collections::HashMap<accesskit::NodeId, WidgetId>,
512 handled_tooltips: &mut std::collections::HashSet<WidgetId>,
518 ) {
519 use crate::accessibility::widget_id_to_node_id;
520 use crate::widget_builder::AccessSubtreeMode;
521
522 if !self.arena.is_active(id) {
523 return;
524 }
525
526 let node = self.arena.get(id).expect("widget id is active in arena");
527 let mut builder = AccessNodeBuilder::for_widget(id);
528 node.widget.accessibility(&mut builder);
529 self.announce_context_menu(id, &mut builder);
533
534 if let Some(ov) = node.access_overrides.as_deref() {
538 ov.apply(&mut builder);
539 if let Some(ref id) = ov.shortcut_id
547 && let Some(eff) = self.shortcut_registry.effective(id)
548 && let Some(ks) = eff.primary
549 {
550 builder.set_keyboard_shortcut(ks.to_string());
551 }
552 }
553
554 let subtree_mode = node.access_subtree;
555 let at_children_owned;
563 let children: &[WidgetId] = match node.widget.accessibility_children() {
564 Some(v) => {
565 at_children_owned = v;
566 &at_children_owned
567 }
568 None => self.arena.children(id),
569 };
570
571 match subtree_mode {
577 AccessSubtreeMode::Inherit => {
578 for &child_id in children {
579 if self.arena.is_active(child_id) {
580 let child_nid = widget_id_to_node_id(child_id);
581 if self.ancestor_chain_redirects(id, child_id) {
598 seen_children.insert(child_nid, id);
601 continue;
602 }
603 if let Some(&prior_parent) = seen_children.get(&child_nid) {
604 eprintln!(
605 "Teksilo bug: duplicate accessibility child {:?}: \
606 first claimed by parent {:?}, now also claimed by {:?}. \
607 Please file a bug report.",
608 child_id, prior_parent, id
609 );
610 continue;
611 }
612 seen_children.insert(child_nid, id);
613 builder.inner_mut().push_child(child_nid);
614 }
615 }
616 }
617 AccessSubtreeMode::Exclude => {
618 }
620 AccessSubtreeMode::Merge => {
621 merge_descendants_into(&mut builder, id, &self.arena);
622 }
623 }
624
625 let bounds = self.arena.bounds(id);
626 builder.inner_mut().set_bounds(accesskit::Rect {
627 x0: bounds.x as f64,
628 y0: bounds.y as f64,
629 x1: (bounds.x + bounds.width) as f64,
630 y1: (bounds.y + bounds.height) as f64,
631 });
632
633 let force_clear_disabled =
638 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
639 if !self.arena.is_enabled(id) && !force_clear_disabled {
640 builder.set_disabled();
641 }
642
643 if let Some(content_id) =
663 self.tooltip_description_target(id, handled_tooltips, &mut builder)
664 {
665 if self
666 .tooltips
667 .iter()
668 .any(|t| t.content_id == content_id && t.overlay_id.is_some())
669 {
670 builder
673 .inner_mut()
674 .push_described_by(widget_id_to_node_id(self.tooltip_content_node(content_id)));
675 handled_tooltips.insert(content_id);
676 } else if let Some(text) = self.tooltip_access_description(content_id) {
677 builder.set_description(text);
685 handled_tooltips.insert(content_id);
686 }
687 }
688
689 let (node_id, ak_node, synthetic_children) = builder.build(id);
690 nodes.push((node_id, ak_node));
691 for (syn_id, syn_node) in synthetic_children {
697 nodes.push((syn_id, syn_node));
698 synthetic_parents.insert(syn_id, id);
699 }
700
701 if matches!(subtree_mode, AccessSubtreeMode::Inherit) {
704 for &child_id in children {
705 self.build_accessibility_recursive(
706 child_id,
707 nodes,
708 synthetic_parents,
709 seen_children,
710 handled_tooltips,
711 );
712 }
713 }
714 }
715
716 fn ancestor_chain_redirects(&self, parent_id: WidgetId, descendant: WidgetId) -> bool {
733 let mut current = Some(parent_id);
734 while let Some(curr) = current {
735 let Some(curr_node) = self.arena.get(curr) else {
736 break;
737 };
738 if curr_node.widget.wants_descendant_redirects()
739 && curr_node
740 .widget
741 .a11y_redirect_descendant(curr, descendant)
742 .is_some()
743 {
744 return true;
745 }
746 current = self.arena.parent(curr);
747 }
748 false
749 }
750
751 fn tooltip_description_target(
783 &self,
784 id: WidgetId,
785 handled: &std::collections::HashSet<WidgetId>,
786 builder: &mut AccessNodeBuilder,
787 ) -> Option<WidgetId> {
788 let mut claims = self
789 .tooltips
790 .iter()
791 .filter(|t| t.description_owner_id == id && !handled.contains(&t.content_id));
792 if let (Some(only), None) = (claims.next(), claims.next())
795 && only.anchor_id != id
796 && !is_presentational_container(builder.inner_mut())
802 && builder.inner_mut().description().is_none()
807 {
808 return Some(only.content_id);
809 }
810 self.tooltips
811 .iter()
812 .find(|t| t.anchor_id == id && !handled.contains(&t.content_id))
813 .map(|t| t.content_id)
814 }
815
816 fn announce_context_menu(&self, id: WidgetId, builder: &mut AccessNodeBuilder) {
837 if self
838 .arena
839 .get(id)
840 .is_some_and(|node| node.context_menu_factory.is_some())
841 && !builder
842 .actions()
843 .contains(&accesskit::Action::ShowContextMenu)
844 {
845 builder.add_action(accesskit::Action::ShowContextMenu);
846 }
847 }
848
849 fn build_overridden_builder(&self, id: WidgetId) -> AccessNodeBuilder {
850 use crate::widget_builder::AccessSubtreeMode;
851 let node = self.arena.get(id).expect("widget id is active in arena");
852 let mut builder = AccessNodeBuilder::for_widget(id);
853 node.widget.accessibility(&mut builder);
854 self.announce_context_menu(id, &mut builder);
855 if let Some(ov) = node.access_overrides.as_deref() {
856 ov.apply(&mut builder);
857 if let Some(ref sid) = ov.shortcut_id
860 && let Some(eff) = self.shortcut_registry.effective(sid)
861 && let Some(ks) = eff.primary
862 {
863 builder.set_keyboard_shortcut(ks.to_string());
864 }
865 }
866 if node.access_subtree == AccessSubtreeMode::Merge {
867 merge_descendants_into(&mut builder, id, &self.arena);
868 }
869 builder
870 }
871
872 pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
890 self.arena
891 .get(content_id)
892 .and_then(|node| node.widget.as_any())
893 .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
894 .and_then(|d| d.materialized_child())
895 .unwrap_or(content_id)
896 }
897
898 pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
899 let content_id = self.tooltip_content_node(content_id);
900 let node = self.arena.get(content_id)?;
901 let mut probe = AccessNodeBuilder::for_widget(content_id);
902 node.widget.accessibility(&mut probe);
903 if let Some(ov) = node.access_overrides.as_deref() {
904 ov.apply(&mut probe);
905 }
906 probe
907 .name()
908 .map(str::trim)
909 .filter(|name| !name.is_empty())
910 .map(str::to_owned)
911 }
912
913 pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
914 let node = self.arena.get(id).expect("widget id is active in arena");
915 let builder = self.build_overridden_builder(id);
916 let role = builder.role();
917 let name = builder.name().map(|s| s.to_string());
918 let actions = builder.actions().to_vec();
919 let mut info = AccessibilityInfo::new(role, name, actions);
920 if let Some(toggled) = builder.toggled() {
921 info = info.with_toggled(toggled);
922 }
923 if let Some(expanded) = builder.expanded() {
924 info = info.with_expanded(expanded);
925 }
926 if let Some(selected) = builder.selected() {
927 info = info.with_selected(selected);
928 }
929 let force_clear_disabled =
933 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
934 let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
935 let disabled_override =
940 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(true);
941 if disabled_arena || disabled_override {
942 info = info.with_disabled(true);
943 }
944 if builder.is_hidden() {
945 info = info.with_hidden(true);
946 }
947 info
948 }
949
950 pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
951 self.arena
952 .active_ids_iter()
953 .find(|&id| self.build_overridden_builder(id).role() == role)
954 }
955
956 pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
957 self.arena
958 .active_ids_iter()
959 .find(|&id| self.build_overridden_builder(id).name() == Some(label))
960 }
961
962 pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
963 self.arena.active_ids_iter().find(|&id| {
964 self.build_overridden_builder(id)
965 .actions()
966 .contains(&action)
967 })
968 }
969
970 pub fn text_content(&self, id: WidgetId) -> Option<String> {
974 self.arena.get(id)?;
975 self.build_overridden_builder(id)
976 .name()
977 .map(|s| s.to_string())
978 }
979
980 pub fn text_value(&self, id: WidgetId) -> Option<String> {
984 self.arena.get(id)?;
985 self.build_overridden_builder(id)
986 .value()
987 .map(|s| s.to_string())
988 }
989}
990
991fn is_presentational_container(node: &accesskit::Node) -> bool {
1005 use accesskit::Role;
1006 if !matches!(node.role(), Role::GenericContainer | Role::Unknown) {
1007 return false;
1008 }
1009 let mut probe = node.clone();
1019 probe.clear_children();
1020 probe.clear_bounds();
1021 probe.clear_disabled();
1022 format!("{probe:?}") == format!("{:?}", accesskit::Node::new(node.role()))
1023}
1024
1025fn merge_descendants_into(
1047 parent_builder: &mut AccessNodeBuilder,
1048 parent_id: WidgetId,
1049 arena: &crate::arena::WidgetArena,
1050) {
1051 let mut acc = MergeAccumulator::default();
1052 for &child in arena.children(parent_id) {
1053 merge_collect_recursive(child, arena, &mut acc);
1054 }
1055 acc.flush_into(parent_builder);
1056}
1057
1058fn merge_collect_recursive(
1059 id: WidgetId,
1060 arena: &crate::arena::WidgetArena,
1061 acc: &mut MergeAccumulator,
1062) {
1063 use crate::widget_builder::AccessSubtreeMode;
1064
1065 if !arena.is_active(id) {
1066 return;
1067 }
1068 let Some(node) = arena.get(id) else {
1069 return;
1070 };
1071
1072 let mut tmp = AccessNodeBuilder::for_widget(id);
1077 node.widget.accessibility(&mut tmp);
1078 if let Some(ov) = node.access_overrides.as_deref() {
1079 ov.apply(&mut tmp);
1080 }
1081 if matches!(node.access_subtree, AccessSubtreeMode::Merge) {
1086 merge_descendants_into(&mut tmp, id, arena);
1087 }
1088 if !tmp.is_hidden() {
1092 acc.absorb(&tmp);
1093 }
1094
1095 match node.access_subtree {
1096 AccessSubtreeMode::Exclude => {
1097 }
1099 AccessSubtreeMode::Merge => {
1100 }
1105 AccessSubtreeMode::Inherit => {
1106 for &grandchild in arena.children(id) {
1107 merge_collect_recursive(grandchild, arena, acc);
1108 }
1109 }
1110 }
1111}
1112
1113#[derive(Default)]
1127struct MergeAccumulator {
1128 label_parts: Vec<String>,
1129 value: Option<String>,
1131 actions: Vec<accesskit::Action>,
1132}
1133
1134impl MergeAccumulator {
1135 fn absorb(&mut self, src: &AccessNodeBuilder) {
1136 if let Some(name) = src.name()
1137 && !name.is_empty()
1138 {
1139 self.label_parts.push(name.to_string());
1140 }
1141 if let Some(value) = src.value()
1142 && self.value.is_none()
1143 && !value.is_empty()
1144 {
1145 self.value = Some(value.to_string());
1146 }
1147 for &action in src.actions() {
1148 if !self.actions.contains(&action) {
1149 self.actions.push(action);
1150 }
1151 }
1152 }
1153
1154 fn flush_into(self, dst: &mut AccessNodeBuilder) {
1155 if !self.label_parts.is_empty() {
1158 let existing = dst.name().map(|s| s.to_string());
1159 let merged = match existing {
1160 Some(e) if !e.is_empty() => {
1161 let mut s = e;
1162 for part in self.label_parts {
1163 s.push(' ');
1164 s.push_str(&part);
1165 }
1166 s
1167 }
1168 _ => self.label_parts.join(" "),
1169 };
1170 dst.set_name(merged);
1171 }
1172 if let Some(v) = self.value {
1173 if dst.value().is_none() {
1177 dst.set_value(v);
1178 }
1179 }
1180 for action in self.actions {
1181 if !dst.actions().contains(&action) {
1184 dst.add_action(action);
1185 }
1186 }
1187 }
1188}
1189
1190#[cfg(test)]
1191pub(crate) mod test_helpers {
1192 pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
1198 accesskit_consumer::Tree::new(update.clone(), false);
1199 }
1200
1201 pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
1206 let emitted: std::collections::HashSet<accesskit::NodeId> =
1207 update.nodes.iter().map(|(id, _)| *id).collect();
1208 for (parent_id, node) in &update.nodes {
1209 for &target in node.controls() {
1210 assert!(
1211 emitted.contains(&target),
1212 "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
1213 );
1214 }
1215 for &target in node.described_by() {
1216 assert!(
1217 emitted.contains(&target),
1218 "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
1219 );
1220 }
1221 }
1222 }
1223
1224 #[allow(dead_code)]
1226 pub(crate) fn nodes_with_role(
1227 update: &accesskit::TreeUpdate,
1228 role: accesskit::Role,
1229 ) -> Vec<accesskit::NodeId> {
1230 update
1231 .nodes
1232 .iter()
1233 .filter(|(_, node)| node.role() == role)
1234 .map(|(id, _)| *id)
1235 .collect()
1236 }
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241 use super::test_helpers::*;
1242 use super::*;
1243 use crate::test_widgets::{FillWidget, StackWidget};
1244
1245 const ANNOUNCER_NODES: usize = 2;
1249
1250 fn announced_by_consumer(update: &accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
1266 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
1267 let state = consumer.state();
1268 let mut found = Vec::new();
1269 let mut stack = vec![state.root()];
1270 while let Some(node) = stack.pop() {
1271 if node.live() != accesskit::Live::Off
1272 && let Some(label) = node.label()
1273 {
1274 found.push((label, node.live()));
1275 }
1276 for child in node.filtered_children(&accesskit_consumer::common_filter) {
1277 stack.push(child);
1278 }
1279 }
1280 found
1281 }
1282
1283 fn live_nodes_in_filtered_tree(update: &accesskit::TreeUpdate) -> Vec<String> {
1292 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
1293 let state = consumer.state();
1294 let mut found = Vec::new();
1295 let mut stack = vec![state.root()];
1296 while let Some(node) = stack.pop() {
1297 if node.live() != accesskit::Live::Off {
1298 found.push(format!("{:?}", node.id()));
1299 }
1300 for child in node.filtered_children(&accesskit_consumer::common_filter) {
1301 stack.push(child);
1302 }
1303 }
1304 found.sort();
1305 found
1306 }
1307
1308 fn tree_with_one_widget() -> WidgetTree {
1309 let mut tree = WidgetTree::new();
1310 tree.add(FillWidget::new().label("content"));
1311 tree.layout(SizeProposal::exact(200.0, 100.0));
1312 tree
1313 }
1314
1315 #[test]
1318 fn an_announcement_enters_the_filtered_tree_then_leaves_it() {
1319 let mut tree = tree_with_one_widget();
1320 let update = tree.sync_accessibility();
1323 assert_eq!(announced_by_consumer(&update), Vec::new());
1324 assert_eq!(
1325 live_nodes_in_filtered_tree(&update),
1326 Vec::<String>::new(),
1327 "an idle announcer must be outside the filtered tree"
1328 );
1329 assert_a11y_tree_valid(&update);
1330
1331 tree.announce("Event added");
1332
1333 let exposed = tree.sync_accessibility();
1334 assert_eq!(
1335 announced_by_consumer(&exposed),
1336 vec![("Event added".to_string(), accesskit::Live::Polite)],
1337 "the message must be a label on a live node inside the filtered tree"
1338 );
1339 assert_eq!(
1340 live_nodes_in_filtered_tree(&exposed).len(),
1341 1,
1342 "exactly the polite announcer must have entered the filtered tree"
1343 );
1344 assert_a11y_tree_valid(&exposed);
1345
1346 let retracted = tree.sync_accessibility();
1347 assert_eq!(
1348 announced_by_consumer(&retracted),
1349 Vec::new(),
1350 "the node must stop carrying the message"
1351 );
1352 assert_eq!(
1358 live_nodes_in_filtered_tree(&retracted),
1359 Vec::<String>::new(),
1360 "the live node must leave the filtered tree entirely, not merely \
1361 lose its label"
1362 );
1363 assert_a11y_tree_valid(&retracted);
1364 }
1365
1366 #[test]
1371 fn the_same_message_announced_twice_is_exposed_twice() {
1372 let mut tree = tree_with_one_widget();
1373 tree.announce("Saved");
1374 tree.announce("Saved");
1375
1376 let mut exposures = 0;
1377 for _ in 0..6 {
1378 let update = tree.sync_accessibility();
1379 if announced_by_consumer(&update)
1380 .iter()
1381 .any(|(text, _)| text == "Saved")
1382 {
1383 exposures += 1;
1384 }
1385 }
1386 assert_eq!(exposures, 2, "each announcement needs its own exposure");
1387 }
1388
1389 #[test]
1390 fn an_assertive_announcement_uses_the_assertive_live_setting() {
1391 let mut tree = tree_with_one_widget();
1392 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
1393 let update = tree.sync_accessibility();
1394 assert_eq!(
1395 announced_by_consumer(&update),
1396 vec![("Could not save".to_string(), accesskit::Live::Assertive)]
1397 );
1398 }
1399
1400 #[test]
1403 fn the_two_politeness_levels_announce_independently() {
1404 let mut tree = tree_with_one_widget();
1405 tree.announce("Event added");
1406 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
1407
1408 let mut spoken = announced_by_consumer(&tree.sync_accessibility());
1409 spoken.sort_by(|a, b| a.0.cmp(&b.0));
1410 assert_eq!(
1411 spoken,
1412 vec![
1413 ("Could not save".to_string(), accesskit::Live::Assertive),
1414 ("Event added".to_string(), accesskit::Live::Polite),
1415 ],
1416 "both levels must be exposed on the same update"
1417 );
1418 }
1419
1420 #[test]
1424 fn announcing_requests_the_syncs_it_needs() {
1425 let mut tree = tree_with_one_widget();
1426 let _ = tree.sync_accessibility();
1427 tree.a11y_update_requested.set(false);
1428 tree.frame_tick_requested.set(false);
1429
1430 tree.announce("Event added");
1431 assert!(
1432 tree.a11y_update_requested.get(),
1433 "an announcement must dirty the accessibility tree"
1434 );
1435 assert!(
1436 tree.frame_requested(),
1437 "an announcement must ask for the frame that carries it"
1438 );
1439
1440 tree.a11y_update_requested.set(false);
1442 tree.frame_tick_requested.set(false);
1443 let _ = tree.sync_accessibility();
1444 assert!(tree.a11y_update_requested.get());
1445 assert!(tree.frame_requested());
1446
1447 tree.a11y_update_requested.set(false);
1449 tree.frame_tick_requested.set(false);
1450 let _ = tree.sync_accessibility();
1451 assert!(!tree.a11y_update_requested.get());
1452 assert!(!tree.frame_requested());
1453 }
1454
1455 #[test]
1458 fn an_announcement_reaches_the_automation_ring() {
1459 let mut tree = tree_with_one_widget();
1460 let before = tree.announcements_since(0).len() as u64;
1461 tree.announce("Event added");
1462 let _ = tree.sync_accessibility();
1463 let got = tree.announcements_since(before);
1464 assert_eq!(got.len(), 1);
1465 assert_eq!(got[0].text, "Event added");
1466 assert!(!got[0].assertive);
1467 }
1468
1469 #[test]
1473 fn a_tree_snapshot_does_not_consume_a_pending_announcement() {
1474 let mut tree = tree_with_one_widget();
1475 tree.announce("Event added");
1476
1477 let snapshot = tree.accessibility_tree_snapshot();
1478 assert_eq!(
1479 announced_by_consumer(&snapshot),
1480 Vec::new(),
1481 "a snapshot must not advance the announcer"
1482 );
1483
1484 let delivered = tree.sync_accessibility();
1485 assert_eq!(
1486 announced_by_consumer(&delivered),
1487 vec![("Event added".to_string(), accesskit::Live::Polite)],
1488 "the message must still be there to deliver"
1489 );
1490 }
1491
1492 #[test]
1495 fn an_empty_announcement_is_not_exposed() {
1496 let mut tree = tree_with_one_widget();
1497 let _ = tree.sync_accessibility();
1498 tree.announce(" ");
1499 let update = tree.sync_accessibility();
1500 assert_eq!(announced_by_consumer(&update), Vec::new());
1501 }
1502
1503 #[derive(Debug)]
1504 struct ActionWidget;
1505
1506 impl Widget for ActionWidget {
1507 fn layout_response(
1508 &self,
1509 proposal: SizeProposal,
1510 _ctx: &LayoutContext,
1511 ) -> crate::widget::LayoutResponse {
1512 proposal.resolve(0.0, 0.0).into()
1513 }
1514
1515 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1516 builder.set_role(accesskit::Role::Button);
1517 builder.set_name("Save");
1518 builder.add_action(accesskit::Action::Click);
1519 builder.add_action(accesskit::Action::Focus);
1520 }
1521 }
1522
1523 #[derive(Debug)]
1524 struct ClickableWidget;
1525
1526 impl Widget for ClickableWidget {
1527 fn layout_response(
1528 &self,
1529 proposal: SizeProposal,
1530 _ctx: &LayoutContext,
1531 ) -> crate::widget::LayoutResponse {
1532 proposal.resolve(0.0, 0.0).into()
1533 }
1534
1535 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1536 builder.set_role(accesskit::Role::Button);
1537 builder.set_name("Click Me");
1538 builder.add_action(accesskit::Action::Click);
1539 }
1540 }
1541
1542 #[test]
1543 fn labeled_widget_has_accessibility() {
1544 let mut tree = WidgetTree::new();
1545 let widget = tree.add(FillWidget::new().label("Hello"));
1546 tree.layout(SizeProposal::exact(100.0, 20.0));
1547 let info = tree.accessibility_node(widget);
1548 assert_eq!(info.role(), accesskit::Role::Label);
1549 assert_eq!(info.name(), Some("Hello"));
1550 }
1551
1552 #[test]
1553 fn find_by_label_works() {
1554 let mut tree = WidgetTree::new();
1555 let widget = tree.add(FillWidget::new().label("Save"));
1556 tree.layout(SizeProposal::exact(100.0, 20.0));
1557 assert_eq!(tree.find_by_label("Save"), Some(widget));
1558 }
1559
1560 #[test]
1561 fn label_role_emits_text_as_value_not_label() {
1562 let mut tree = WidgetTree::new();
1569 let widget = tree.add(FillWidget::new().label("Section")); tree.layout(SizeProposal::exact(100.0, 20.0));
1571
1572 assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1574
1575 let update = tree.sync_accessibility();
1577 let node = &update
1578 .nodes
1579 .iter()
1580 .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1581 .expect("label node must be present in the tree update")
1582 .1;
1583 assert_eq!(node.role(), accesskit::Role::Label);
1584 assert_eq!(
1585 node.value(),
1586 Some("Section"),
1587 "Role::Label text must be exposed via `value`"
1588 );
1589 assert_eq!(
1590 node.label(),
1591 None,
1592 "Role::Label must not leave text in the `label` property"
1593 );
1594 }
1595
1596 #[test]
1597 fn find_by_role_works() {
1598 let mut tree = WidgetTree::new();
1599 tree.add(FillWidget::new().label("Text"));
1600 tree.layout(SizeProposal::exact(100.0, 20.0));
1601 assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1602 }
1603
1604 #[test]
1605 fn access_hidden_bound_to_signal_toggles_reactively() {
1606 use crate::signal::Signal;
1607 use crate::widget_builder::WidgetBuilder;
1608 let hidden = Signal::new(false);
1609 let mut tree = WidgetTree::new();
1610 let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1611 tree.layout(SizeProposal::exact(100.0, 30.0));
1612 assert!(
1613 !tree.accessibility_node(w).is_hidden(),
1614 "node should be visible to AT while the signal is false"
1615 );
1616 hidden.set(true);
1617 assert!(
1619 tree.accessibility_node(w).is_hidden(),
1620 "node should hide from AT when the bound signal flips to true"
1621 );
1622 hidden.set(false);
1623 assert!(!tree.accessibility_node(w).is_hidden());
1624 }
1625
1626 #[test]
1630 fn a_widget_with_a_context_menu_advertises_the_action() {
1631 let mut tree = WidgetTree::new();
1632 let plain = tree.add(FillWidget::new().label("no menu"));
1633 let with_menu = tree.add(
1634 FillWidget::new()
1635 .label("has menu")
1636 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1637 );
1638 tree.layout(SizeProposal::exact(100.0, 20.0));
1639
1640 assert!(
1641 tree.accessibility_node(with_menu)
1642 .actions()
1643 .contains(&Action::ShowContextMenu),
1644 "a node owning a context-menu factory must announce it"
1645 );
1646 assert!(
1647 !tree
1648 .accessibility_node(plain)
1649 .actions()
1650 .contains(&Action::ShowContextMenu),
1651 "and a node without one must not"
1652 );
1653 }
1654
1655 #[test]
1658 fn a_removed_context_menu_action_stays_removed() {
1659 let mut tree = WidgetTree::new();
1660 let id = tree.add(
1661 FillWidget::new()
1662 .label("suppressed")
1663 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1664 .access_remove_action(Action::ShowContextMenu),
1665 );
1666 tree.layout(SizeProposal::exact(100.0, 20.0));
1667 assert!(
1668 !tree
1669 .accessibility_node(id)
1670 .actions()
1671 .contains(&Action::ShowContextMenu)
1672 );
1673 }
1674
1675 #[test]
1680 fn a_child_does_not_advertise_its_parents_context_menu() {
1681 let mut tree = WidgetTree::new();
1682 let child = tree.add(FillWidget::new().label("inner"));
1683 let parent = tree.add(
1684 StackWidget::new()
1685 .add_child(child)
1686 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1687 );
1688 tree.layout(SizeProposal::exact(100.0, 20.0));
1689
1690 assert!(
1691 tree.accessibility_node(parent)
1692 .actions()
1693 .contains(&Action::ShowContextMenu)
1694 );
1695 assert!(
1696 !tree
1697 .accessibility_node(child)
1698 .actions()
1699 .contains(&Action::ShowContextMenu),
1700 "the menu belongs to the widget that declared it"
1701 );
1702 }
1703
1704 #[test]
1705 fn accessibility_node_collects_actions() {
1706 let mut tree = WidgetTree::new();
1707 let widget = tree.add(ActionWidget);
1708 tree.layout(SizeProposal::exact(100.0, 40.0));
1709
1710 let info = tree.accessibility_node(widget);
1711 assert_eq!(info.role(), accesskit::Role::Button);
1712 assert_eq!(info.name(), Some("Save"));
1713 assert_eq!(info.actions().len(), 2);
1714 assert!(info.actions().contains(&accesskit::Action::Click));
1715 assert!(info.actions().contains(&accesskit::Action::Focus));
1716 }
1717
1718 #[test]
1719 fn sync_accessibility_produces_tree_update() {
1720 let mut tree = WidgetTree::new();
1721 tree.add(FillWidget::new().label("First"));
1722 tree.add(FillWidget::new().label("Second"));
1723 tree.layout(SizeProposal::exact(200.0, 100.0));
1724
1725 let update = tree.sync_accessibility();
1726 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1729 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1730 assert!(update.tree.is_some());
1731 assert_a11y_tree_valid(&update);
1732 }
1733
1734 #[test]
1735 fn root_node_carries_locale_as_language() {
1736 let mut tree = WidgetTree::new();
1737 tree.add(FillWidget::new().label("Bonjour"));
1738
1739 tree.layout(SizeProposal::exact(200.0, 100.0));
1741 let update = tree.sync_accessibility();
1742 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1743 assert_eq!(
1744 update.nodes[0].1.language(),
1745 None,
1746 "no language before a locale is set"
1747 );
1748
1749 tree.set_locale("fr-FR".to_string());
1752 let update = tree.sync_accessibility();
1753 assert_eq!(
1754 update.nodes[0].1.language(),
1755 Some("fr-FR"),
1756 "root node must advertise the active locale as its language"
1757 );
1758 }
1759
1760 #[test]
1761 fn sync_accessibility_excludes_dormant_widgets() {
1762 let mut tree = WidgetTree::new();
1763 tree.add(FillWidget::new().label("Active"));
1764 let dormant = tree.add(FillWidget::new().label("Dormant"));
1765 tree.layout(SizeProposal::exact(200.0, 100.0));
1766
1767 tree.set_dormant(dormant);
1768
1769 let update = tree.sync_accessibility();
1770 assert_eq!(update.nodes.len(), 2 + ANNOUNCER_NODES);
1771 assert_a11y_tree_valid(&update);
1772 }
1773
1774 #[test]
1781 fn relayout_without_activation_change_does_not_dirty_a11y() {
1782 let mut tree = WidgetTree::new();
1783 let id = tree.add(FillWidget::new().label("Static"));
1784 tree.layout(SizeProposal::exact(200.0, 100.0));
1785
1786 let _ = tree.sync_accessibility();
1788 assert!(
1789 !tree.a11y_dirty,
1790 "sync_accessibility must clear the dirty flag"
1791 );
1792
1793 tree.arena.mark_needs_layout(id);
1797 tree.layout(SizeProposal::exact(200.0, 100.0));
1798
1799 assert!(
1800 !tree.a11y_dirty,
1801 "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1802 );
1803 }
1804
1805 #[test]
1809 fn activation_transition_does_dirty_a11y() {
1810 let mut tree = WidgetTree::new();
1811 let id = tree.add(FillWidget::new().label("Toggle"));
1812 tree.layout(SizeProposal::exact(200.0, 100.0));
1813 let _ = tree.sync_accessibility();
1814 assert!(!tree.a11y_dirty);
1815
1816 tree.set_dormant(id);
1817 tree.layout(SizeProposal::exact(200.0, 100.0));
1818 assert!(
1819 tree.a11y_dirty,
1820 "active→dormant transition must dirty the AT cache so the dormant node is removed"
1821 );
1822 }
1823
1824 #[test]
1825 fn sync_accessibility_includes_focus() {
1826 let mut tree = WidgetTree::new();
1827 let widget = tree.add(FillWidget::new().focusable().label("Focused"));
1828 tree.layout(SizeProposal::exact(100.0, 50.0));
1829 tree.focus(widget);
1830
1831 let update = tree.sync_accessibility();
1832 let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
1833 assert_eq!(update.focus, expected_focus);
1834 assert_a11y_tree_valid(&update);
1835 }
1836
1837 #[test]
1838 fn sync_accessibility_parent_child_relationship() {
1839 let mut tree = WidgetTree::new();
1840 let child = tree.add(FillWidget::new().label("Child"));
1841 let parent = tree.add(
1844 StackWidget::new()
1845 .add_child(child)
1846 .access_label_literal("Parent"),
1847 );
1848 tree.layout(SizeProposal::exact(100.0, 50.0));
1849
1850 let update = tree.sync_accessibility();
1851 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1852
1853 let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
1854 let parent_node = update
1855 .nodes
1856 .iter()
1857 .find(|(id, _)| *id == parent_node_id)
1858 .map(|(_, node)| node)
1859 .unwrap();
1860
1861 let child_node_id = crate::accessibility::widget_id_to_node_id(child);
1862 assert!(parent_node.children().contains(&child_node_id));
1863 assert_a11y_tree_valid(&update);
1864 }
1865
1866 #[test]
1867 fn presentational_containers_collapse_and_promote_children() {
1868 let mut tree = WidgetTree::new();
1874 let leaf = tree.add(FillWidget::new().label("Leaf")); let inner = tree.add(StackWidget::new().add_child(leaf)); let outer = tree.add(StackWidget::new().add_child(inner)); let labeled = tree.add(
1878 StackWidget::new()
1879 .add_child(outer)
1880 .access_label_literal("Group"),
1881 );
1882 tree.layout(SizeProposal::exact(100.0, 50.0));
1883 let update = tree.sync_accessibility();
1884
1885 assert!(find_node(&update, inner).is_none(), "bare inner pruned");
1886 assert!(find_node(&update, outer).is_none(), "bare outer pruned");
1887
1888 let labeled_node = find_node(&update, labeled).expect("labeled group survives");
1889 let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
1890 assert!(
1891 labeled_node.children().contains(&leaf_nid),
1892 "leaf promoted past both bare containers to the labeled ancestor"
1893 );
1894 assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
1895 assert_a11y_tree_valid(&update);
1896 }
1897
1898 #[test]
1899 fn find_by_action_finds_clickable() {
1900 let mut tree = WidgetTree::new();
1901 let widget = tree.add(ClickableWidget);
1902 tree.layout(SizeProposal::exact(100.0, 40.0));
1903
1904 assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
1905 assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
1906 }
1907
1908 #[test]
1909 fn text_content_returns_accessibility_name() {
1910 let mut tree = WidgetTree::new();
1911 let widget = tree.add(FillWidget::new().label("Hello World"));
1912 tree.layout(SizeProposal::exact(100.0, 50.0));
1913
1914 assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
1915 }
1916
1917 #[test]
1918 fn text_content_returns_none_without_label() {
1919 let mut tree = WidgetTree::new();
1920 let widget = tree.add(FillWidget::new());
1921 tree.layout(SizeProposal::exact(100.0, 50.0));
1922
1923 assert_eq!(tree.text_content(widget), None);
1924 }
1925
1926 #[test]
1927 fn descendant_of_disabled_ancestor_reports_disabled() {
1928 use crate::signal::Signal;
1929
1930 let mut tree = WidgetTree::new();
1931 let child = tree.add(FillWidget::new().label("Child"));
1932 let parent = tree.add(StackWidget::new().add_child(child));
1933 tree.enabled_when(parent, Signal::new(false));
1934 tree.layout(SizeProposal::exact(100.0, 50.0));
1935
1936 assert!(
1937 tree.accessibility_node(child).is_disabled(),
1938 "descendant should report disabled when ancestor is disabled"
1939 );
1940 }
1941
1942 #[test]
1943 fn text_value_returns_accessibility_value() {
1944 #[derive(Debug)]
1945 struct ValueWidget;
1946
1947 impl Widget for ValueWidget {
1948 fn layout_response(
1949 &self,
1950 proposal: SizeProposal,
1951 _ctx: &LayoutContext,
1952 ) -> crate::widget::LayoutResponse {
1953 proposal.resolve(0.0, 0.0).into()
1954 }
1955
1956 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1957 builder.set_role(accesskit::Role::Slider);
1958 builder.set_name("Volume");
1959 builder.set_value("75%");
1960 }
1961 }
1962
1963 let mut tree = WidgetTree::new();
1964 let widget = tree.add(ValueWidget);
1965 tree.layout(SizeProposal::exact(100.0, 40.0));
1966
1967 assert_eq!(tree.text_value(widget), Some("75%".to_string()));
1968 assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
1969 }
1970
1971 #[test]
1972 fn sync_accessibility_has_no_duplicate_children() {
1973 let mut tree = WidgetTree::new();
1977 let grandchild = tree.add(FillWidget::new().label("Grandchild"));
1978 let child_a = tree.add(StackWidget::new().add_child(grandchild));
1979 let child_b = tree.add(FillWidget::new().label("Sibling"));
1980 let _root = tree.add(StackWidget::new().add_child(child_a).add_child(child_b));
1981 tree.layout(SizeProposal::exact(200.0, 100.0));
1982
1983 let update = tree.sync_accessibility();
1984
1985 let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
1986 std::collections::HashMap::new();
1987 for (parent_id, node) in &update.nodes {
1988 for &child_id in node.children() {
1989 let prev = all_children.insert(child_id, *parent_id);
1990 assert!(
1991 prev.is_none(),
1992 "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
1993 );
1994 }
1995 }
1996 assert_a11y_tree_valid(&update);
1997 }
1998
1999 #[test]
2000 fn no_dangling_relationships_in_basic_tree() {
2001 let mut tree = WidgetTree::new();
2002 let child = tree.add(FillWidget::new().label("Child"));
2003 let _parent = tree.add(StackWidget::new().add_child(child));
2004 tree.layout(SizeProposal::exact(100.0, 50.0));
2005
2006 let update = tree.sync_accessibility();
2007 assert_no_dangling_relationships(&update);
2008 assert_a11y_tree_valid(&update);
2009 }
2010
2011 use crate::widget_builder::WidgetBuilder;
2016 use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
2017
2018 fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
2020 let nid = crate::accessibility::widget_id_to_node_id(id);
2021 update
2022 .nodes
2023 .iter()
2024 .find(|(node_id, _)| *node_id == nid)
2025 .map(|(_, n)| n)
2026 }
2027
2028 #[derive(Debug)]
2031 struct AlwaysHiddenWidget;
2032 impl Widget for AlwaysHiddenWidget {
2033 fn layout_response(
2034 &self,
2035 proposal: SizeProposal,
2036 _ctx: &LayoutContext,
2037 ) -> crate::widget::LayoutResponse {
2038 proposal.resolve(0.0, 0.0).into()
2039 }
2040 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2041 builder.set_role(Role::GenericContainer);
2042 builder.set_hidden();
2043 }
2044 }
2045
2046 #[test]
2048 fn access_label_replaces_widget_label() {
2049 let mut tree = WidgetTree::new();
2050 let id = tree.add(ClickableWidget.access_label_literal("Publish"));
2051 tree.layout(SizeProposal::exact(100.0, 40.0));
2052 assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
2053 }
2054
2055 #[test]
2057 fn access_description_appears_on_bare_widget() {
2058 let mut tree = WidgetTree::new();
2059 let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
2060 tree.layout(SizeProposal::exact(50.0, 50.0));
2061 let update = tree.sync_accessibility();
2062 let node = find_node(&update, id).expect("node present");
2063 assert_eq!(node.description(), Some("Decorative"));
2064 }
2065
2066 #[test]
2068 fn access_value_replaces_widget_value() {
2069 #[derive(Debug)]
2070 struct SliderWidget;
2071 impl Widget for SliderWidget {
2072 fn layout_response(
2073 &self,
2074 proposal: SizeProposal,
2075 _ctx: &LayoutContext,
2076 ) -> crate::widget::LayoutResponse {
2077 proposal.resolve(0.0, 0.0).into()
2078 }
2079 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2080 builder.set_role(Role::Slider);
2081 builder.set_value("50");
2082 }
2083 }
2084 let mut tree = WidgetTree::new();
2085 let id = tree.add(SliderWidget.access_value_literal("Custom"));
2086 tree.layout(SizeProposal::exact(100.0, 40.0));
2087 assert_eq!(tree.text_value(id), Some("Custom".to_string()));
2088 }
2089
2090 #[test]
2092 fn access_role_overrides_widget_role() {
2093 let mut tree = WidgetTree::new();
2094 let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
2095 tree.layout(SizeProposal::exact(100.0, 40.0));
2096 assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
2097 }
2098
2099 #[test]
2100 fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
2101 let mut tree = WidgetTree::new();
2108 let anchor = tree.add(FillWidget::new().label("Save"));
2109 let tip = tree.add(FillWidget::new().label("Save the file"));
2110 tree.layout(SizeProposal::exact(200.0, 100.0));
2111 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
2112
2113 assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
2114 let update = tree.sync_accessibility();
2115 let node = find_node(&update, anchor).expect("anchor node present");
2116 assert_eq!(
2117 node.description(),
2118 Some("Save the file"),
2119 "the anchor must describe itself with its tooltip text while unshown"
2120 );
2121 }
2122
2123 #[test]
2124 fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
2125 let mut tree = WidgetTree::new();
2128 let anchor = tree.add(FillWidget::new().label("Save"));
2129 let tip = tree.add(FillWidget::new().label("Save the file"));
2130 tree.layout(SizeProposal::exact(200.0, 100.0));
2131 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2132
2133 tree.pointer_move(tree.bounds(anchor).center());
2134 tree.advance_time(std::time::Duration::from_millis(150));
2135 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
2136
2137 let update = tree.sync_accessibility();
2138 let node = find_node(&update, anchor).expect("anchor node present");
2139 assert!(
2140 !node.described_by().is_empty(),
2141 "a shown tooltip is wired as a described_by relation"
2142 );
2143 }
2144
2145 #[test]
2147 fn access_hint_alias_writes_description_field() {
2148 let mut tree = WidgetTree::new();
2149 let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
2150 tree.layout(SizeProposal::exact(50.0, 50.0));
2151 let update = tree.sync_accessibility();
2152 let node = find_node(&update, id).unwrap();
2153 assert_eq!(node.description(), Some("Tip"));
2154 }
2155
2156 #[test]
2158 fn access_identifier_writes_author_id() {
2159 let mut tree = WidgetTree::new();
2160 let id = tree.add(FillWidget::new().access_identifier("save-button"));
2161 tree.layout(SizeProposal::exact(50.0, 50.0));
2162 let update = tree.sync_accessibility();
2163 let node = find_node(&update, id).unwrap();
2164 assert_eq!(node.author_id(), Some("save-button"));
2165 }
2166
2167 #[test]
2169 fn access_shortcut_literal_set() {
2170 let mut tree = WidgetTree::new();
2171 let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
2172 tree.layout(SizeProposal::exact(50.0, 50.0));
2173 let update = tree.sync_accessibility();
2174 let node = find_node(&update, id).unwrap();
2175 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
2176 }
2177
2178 #[test]
2181 fn access_shortcut_id_resolves_and_tracks_rebinds() {
2182 use crate::event::Key;
2183 use crate::shortcut::{KeyStroke, Shortcut};
2184
2185 let mut tree = WidgetTree::new();
2186 tree.shortcut_registry_mut().register(
2187 Shortcut::new("app.save")
2188 .name("Save")
2189 .primary(KeyStroke::command(Key::S))
2190 .build(),
2191 );
2192 let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
2193 tree.layout(SizeProposal::exact(50.0, 50.0));
2194
2195 let accel = if cfg!(target_os = "macos") {
2199 "Cmd"
2200 } else {
2201 "Ctrl"
2202 };
2203
2204 let update = tree.sync_accessibility();
2206 let node = find_node(&update, id).unwrap();
2207 assert_eq!(
2208 node.keyboard_shortcut(),
2209 Some(format!("{accel}+S").as_str())
2210 );
2211
2212 tree.shortcut_registry_mut()
2214 .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
2215 let update = tree.sync_accessibility();
2216 let node = find_node(&update, id).unwrap();
2217 assert_eq!(
2218 node.keyboard_shortcut(),
2219 Some(format!("{accel}+Q").as_str())
2220 );
2221 }
2222
2223 #[test]
2226 fn access_shortcut_id_unknown_id_omits_announcement() {
2227 let mut tree = WidgetTree::new();
2228 let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
2229 tree.layout(SizeProposal::exact(50.0, 50.0));
2230 let update = tree.sync_accessibility();
2231 let node = find_node(&update, id).unwrap();
2232 assert_eq!(node.keyboard_shortcut(), None);
2233 }
2234
2235 #[test]
2237 fn access_hidden_true_hides_widget() {
2238 let mut tree = WidgetTree::new();
2239 let id = tree.add(ClickableWidget.access_hidden(true));
2240 tree.layout(SizeProposal::exact(50.0, 50.0));
2241 assert!(tree.accessibility_node(id).is_hidden());
2242 }
2243
2244 #[test]
2246 fn access_hidden_false_clears_widget_set_hidden() {
2247 let mut tree = WidgetTree::new();
2248 let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
2249 tree.layout(SizeProposal::exact(50.0, 50.0));
2250 assert!(
2251 !tree.accessibility_node(id).is_hidden(),
2252 "access_hidden(false) should clear widget-emitted hidden"
2253 );
2254 }
2255
2256 #[test]
2258 fn access_disabled_true_marks_disabled() {
2259 let mut tree = WidgetTree::new();
2260 let id = tree.add(FillWidget::new().access_disabled(true));
2261 tree.layout(SizeProposal::exact(50.0, 50.0));
2262 assert!(tree.accessibility_node(id).is_disabled());
2263 }
2264
2265 #[test]
2267 fn access_disabled_false_clears_arena_driven_disabled() {
2268 use crate::signal::Signal;
2269 let mut tree = WidgetTree::new();
2270 let id = tree.add(FillWidget::new().label("X").access_disabled(false));
2271 tree.enabled_when(id, Signal::new(false));
2272 tree.layout(SizeProposal::exact(50.0, 50.0));
2273 assert!(
2274 !tree.accessibility_node(id).is_disabled(),
2275 "access_disabled(false) should clear even arena-driven disabled"
2276 );
2277 }
2278
2279 #[test]
2281 fn access_controls_appends() {
2282 let mut tree = WidgetTree::new();
2283 let target = tree.add(FillWidget::new().label("Target"));
2284 let controller = tree.add(
2285 FillWidget::new()
2286 .label("Controller")
2287 .access_controls(target),
2288 );
2289 tree.layout(SizeProposal::exact(100.0, 50.0));
2290 let update = tree.sync_accessibility();
2291 let node = find_node(&update, controller).unwrap();
2292 let target_nid = crate::accessibility::widget_id_to_node_id(target);
2293 assert!(
2294 node.controls().contains(&target_nid),
2295 "controls list should contain the target NodeId"
2296 );
2297 }
2298
2299 #[test]
2301 fn access_described_by_appends() {
2302 let mut tree = WidgetTree::new();
2303 let other = tree.add(FillWidget::new().label("Desc"));
2304 let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
2305 tree.layout(SizeProposal::exact(100.0, 50.0));
2306 let update = tree.sync_accessibility();
2307 let node = find_node(&update, id).unwrap();
2308 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2309 assert!(node.described_by().contains(&other_nid));
2310 }
2311
2312 #[test]
2314 fn access_labelled_by_appends() {
2315 let mut tree = WidgetTree::new();
2316 let other = tree.add(FillWidget::new().label("Lbl"));
2317 let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
2318 tree.layout(SizeProposal::exact(100.0, 50.0));
2319 let update = tree.sync_accessibility();
2320 let node = find_node(&update, id).unwrap();
2321 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2322 assert!(node.labelled_by().contains(&other_nid));
2323 }
2324
2325 #[test]
2327 fn access_live_assertive_set() {
2328 let mut tree = WidgetTree::new();
2329 let id = tree.add(FillWidget::new().access_live(Live::Assertive));
2330 tree.layout(SizeProposal::exact(50.0, 50.0));
2331 let update = tree.sync_accessibility();
2332 let node = find_node(&update, id).unwrap();
2333 assert_eq!(node.live(), Some(Live::Assertive));
2334 }
2335
2336 #[test]
2338 fn access_aria_current_set() {
2339 let mut tree = WidgetTree::new();
2340 let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
2341 tree.layout(SizeProposal::exact(50.0, 50.0));
2342 let update = tree.sync_accessibility();
2343 let node = find_node(&update, id).unwrap();
2344 assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
2345 }
2346
2347 #[test]
2349 fn access_has_popup_set() {
2350 let mut tree = WidgetTree::new();
2351 let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
2352 tree.layout(SizeProposal::exact(50.0, 50.0));
2353 let update = tree.sync_accessibility();
2354 let node = find_node(&update, id).unwrap();
2355 assert_eq!(node.has_popup(), Some(HasPopup::Menu));
2356 }
2357
2358 #[test]
2360 fn access_orientation_set() {
2361 let mut tree = WidgetTree::new();
2362 let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
2363 tree.layout(SizeProposal::exact(50.0, 50.0));
2364 let update = tree.sync_accessibility();
2365 let node = find_node(&update, id).unwrap();
2366 assert_eq!(node.orientation(), Some(Orientation::Vertical));
2367 }
2368
2369 #[test]
2371 fn access_numeric_value_and_range() {
2372 let mut tree = WidgetTree::new();
2373 let id = tree.add(
2374 FillWidget::new()
2375 .access_role(Role::Slider)
2376 .access_numeric_value(50.0)
2377 .access_numeric_range(0.0, 100.0)
2378 .access_numeric_step(5.0),
2379 );
2380 tree.layout(SizeProposal::exact(50.0, 50.0));
2381 let update = tree.sync_accessibility();
2382 let node = find_node(&update, id).unwrap();
2383 assert_eq!(node.numeric_value(), Some(50.0));
2384 assert_eq!(node.min_numeric_value(), Some(0.0));
2385 assert_eq!(node.max_numeric_value(), Some(100.0));
2386 assert_eq!(node.numeric_value_step(), Some(5.0));
2387 }
2388
2389 #[test]
2391 fn access_action_advertises_and_routes() {
2392 use crate::signal::Signal;
2393 let flag = Signal::new(false);
2394 let flag_for_cb = flag.clone();
2395 let mut tree = WidgetTree::new();
2396 let id = tree.add(
2397 FillWidget::new()
2398 .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
2399 );
2400 tree.layout(SizeProposal::exact(50.0, 50.0));
2401 let info = tree.accessibility_node(id);
2402 assert!(info.actions().contains(&Action::ShowContextMenu));
2403 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2404 action: Action::ShowContextMenu,
2405 target: Some(id),
2406 target_node: crate::accessibility::widget_id_to_node_id(id),
2407 data: None,
2408 });
2409 assert!(flag.get(), "callback should have been invoked");
2410 }
2411
2412 #[test]
2414 fn access_two_actions_both_route() {
2415 use crate::signal::Signal;
2416 let click = Signal::new(false);
2417 let increment = Signal::new(false);
2418 let click_cb = click.clone();
2419 let inc_cb = increment.clone();
2420 let mut tree = WidgetTree::new();
2421 let id = tree.add(
2422 FillWidget::new()
2423 .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
2424 .access_action(Action::Increment, move |_| inc_cb.set(true)),
2425 );
2426 tree.layout(SizeProposal::exact(50.0, 50.0));
2427 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2428 action: Action::ShowContextMenu,
2429 target: Some(id),
2430 target_node: crate::accessibility::widget_id_to_node_id(id),
2431 data: None,
2432 });
2433 assert!(click.get() && !increment.get(), "only first action fired");
2434 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2435 action: Action::Increment,
2436 target: Some(id),
2437 target_node: crate::accessibility::widget_id_to_node_id(id),
2438 data: None,
2439 });
2440 assert!(
2441 click.get() && increment.get(),
2442 "both actions fired exactly once each"
2443 );
2444 }
2445
2446 #[test]
2448 fn access_remove_action_suppresses_widget_action() {
2449 let mut tree = WidgetTree::new();
2450 let id = tree.add(ActionWidget.access_remove_action(Action::Click));
2451 tree.layout(SizeProposal::exact(50.0, 50.0));
2452 let info = tree.accessibility_node(id);
2453 assert!(!info.actions().contains(&Action::Click));
2454 assert!(info.actions().contains(&Action::Focus));
2455 }
2456
2457 #[test]
2459 fn access_custom_action_uses_localized_label() {
2460 let mut tree = WidgetTree::new();
2461 let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
2462 tree.layout(SizeProposal::exact(50.0, 50.0));
2463 let update = tree.sync_accessibility();
2464 let node = find_node(&update, id).unwrap();
2465 let actions = node.custom_actions();
2466 assert_eq!(actions.len(), 1);
2467 assert_eq!(actions[0].id, 0);
2468 assert_eq!(actions[0].description.as_str(), "Reply");
2469 }
2470
2471 #[test]
2473 fn access_custom_action_routes_by_index() {
2474 use crate::signal::Signal;
2475 let first = Signal::new(false);
2476 let second = Signal::new(false);
2477 let f = first.clone();
2478 let s = second.clone();
2479 let mut tree = WidgetTree::new();
2480 let id = tree.add(
2481 FillWidget::new()
2482 .access_custom_action_literal("First", move |_| f.set(true))
2483 .access_custom_action_literal("Second", move |_| s.set(true)),
2484 );
2485 tree.layout(SizeProposal::exact(50.0, 50.0));
2486 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2487 action: Action::CustomAction,
2488 target: Some(id),
2489 target_node: crate::accessibility::widget_id_to_node_id(id),
2490 data: Some(accesskit::ActionData::CustomAction(1)),
2491 });
2492 assert!(!first.get(), "first should not fire");
2493 assert!(second.get(), "second should fire (index 1)");
2494 }
2495
2496 #[test]
2498 fn access_action_layered_with_on_access_action() {
2499 use crate::signal::Signal;
2500 let from_override = Signal::new(false);
2501 let from_user = Signal::new(false);
2502 let ov_cb = from_override.clone();
2503 let user_cb = from_user.clone();
2504 let mut tree = WidgetTree::new();
2505 let id = tree.add(
2506 FillWidget::new()
2507 .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
2508 .on_access_action(move |_action, _ctx| {
2509 user_cb.set(true);
2510 crate::event::EventResponse::Handled
2511 }),
2512 );
2513 tree.layout(SizeProposal::exact(50.0, 50.0));
2514 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2515 action: Action::ShowContextMenu,
2516 target: Some(id),
2517 target_node: crate::accessibility::widget_id_to_node_id(id),
2518 data: None,
2519 });
2520 assert!(from_override.get(), "override callback fired");
2521 assert!(from_user.get(), "user catch-all fired");
2522 }
2523
2524 #[test]
2530 fn access_label_accepts_resolved_string_via_into() {
2531 struct ResolvedAtCall(String);
2535 impl From<ResolvedAtCall> for crate::signal::Prop<String> {
2536 fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
2537 crate::signal::Prop::Static(v.0)
2538 }
2539 }
2540 let mut tree = WidgetTree::new();
2541 let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
2542 tree.layout(SizeProposal::exact(50.0, 50.0));
2543 assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
2544 }
2545
2546 #[test]
2548 fn access_exclude_subtree_prunes_children_from_at_tree() {
2549 let mut tree = WidgetTree::new();
2550 let inner1 = tree.add(FillWidget::new().label("A"));
2551 let inner2 = tree.add(FillWidget::new().label("B"));
2552 let outer = tree.add(
2553 StackWidget::new()
2554 .add_child(inner1)
2555 .add_child(inner2)
2556 .access_label_literal("Section")
2560 .access_exclude_subtree(),
2561 );
2562 tree.layout(SizeProposal::exact(100.0, 50.0));
2563 let update = tree.sync_accessibility();
2564 assert!(find_node(&update, outer).is_some());
2566 assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
2567 assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
2568 let outer_node = find_node(&update, outer).unwrap();
2569 assert!(
2570 outer_node.children().is_empty(),
2571 "outer should have no AT children when excluded"
2572 );
2573 }
2574
2575 #[test]
2577 fn access_merge_subtree_concatenates_descendant_labels() {
2578 let mut tree = WidgetTree::new();
2579 let title = tree.add(FillWidget::new().label("Title"));
2580 let subtitle = tree.add(FillWidget::new().label("Subtitle"));
2581 let card = tree.add(
2582 StackWidget::new()
2583 .add_child(title)
2584 .add_child(subtitle)
2585 .access_merge_subtree(),
2586 );
2587 tree.layout(SizeProposal::exact(100.0, 50.0));
2588 assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
2590 let update = tree.sync_accessibility();
2591 assert!(find_node(&update, title).is_none());
2592 assert!(find_node(&update, subtitle).is_none());
2593 }
2594
2595 #[test]
2597 fn access_merge_subtree_unions_actions() {
2598 let mut tree = WidgetTree::new();
2599 let click_a = tree.add(ClickableWidget);
2600 let click_b = tree.add(ClickableWidget);
2601 let card = tree.add(
2602 StackWidget::new()
2603 .add_child(click_a)
2604 .add_child(click_b)
2605 .access_merge_subtree(),
2606 );
2607 tree.layout(SizeProposal::exact(100.0, 50.0));
2608 let actions = tree.accessibility_node(card).actions().to_vec();
2609 let click_count = actions.iter().filter(|a| **a == Action::Click).count();
2610 assert_eq!(
2611 click_count, 1,
2612 "Click should be present exactly once after merge (deduplicated)"
2613 );
2614 }
2615
2616 #[test]
2626 fn access_merge_subtree_first_nonempty_value_wins() {
2627 #[derive(Debug)]
2628 struct ValueWidget(&'static str);
2629 impl Widget for ValueWidget {
2630 fn layout_response(
2631 &self,
2632 proposal: SizeProposal,
2633 _ctx: &LayoutContext,
2634 ) -> crate::widget::LayoutResponse {
2635 proposal.resolve(0.0, 0.0).into()
2636 }
2637 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2638 builder.set_role(Role::Slider);
2639 builder.set_value(self.0);
2640 }
2641 }
2642 let mut tree = WidgetTree::new();
2643 let v1 = tree.add(ValueWidget("first"));
2644 let v2 = tree.add(ValueWidget("second"));
2645 let card = tree.add(
2646 StackWidget::new()
2647 .add_child(v1)
2648 .add_child(v2)
2649 .access_merge_subtree(),
2650 );
2651 tree.layout(SizeProposal::exact(100.0, 50.0));
2652 assert_eq!(tree.text_value(card), Some("first".to_string()));
2653 }
2654
2655 #[test]
2657 fn access_exclude_inside_merge() {
2658 let mut tree = WidgetTree::new();
2659 let visible = tree.add(FillWidget::new().label("VISIBLE"));
2660 let pruned = tree.add(FillWidget::new().label("PRUNED"));
2661 let inner_excluded = tree.add(
2662 StackWidget::new()
2663 .add_child(pruned)
2664 .access_exclude_subtree(),
2665 );
2666 let card = tree.add(
2667 StackWidget::new()
2668 .add_child(visible)
2669 .add_child(inner_excluded)
2670 .access_merge_subtree(),
2671 );
2672 tree.layout(SizeProposal::exact(100.0, 50.0));
2673 let merged = tree.text_content(card).unwrap_or_default();
2674 assert!(merged.contains("VISIBLE"));
2677 assert!(
2678 !merged.contains("PRUNED"),
2679 "excluded subtree should not contribute to merge"
2680 );
2681 }
2682
2683 #[test]
2685 fn access_merge_inside_merge() {
2686 let mut tree = WidgetTree::new();
2687 let inner_a = tree.add(FillWidget::new().label("a"));
2688 let inner_b = tree.add(FillWidget::new().label("b"));
2689 let inner_card = tree.add(
2690 StackWidget::new()
2691 .add_child(inner_a)
2692 .add_child(inner_b)
2693 .access_merge_subtree(),
2694 );
2695 let outer_extra = tree.add(FillWidget::new().label("X"));
2696 let outer = tree.add(
2697 StackWidget::new()
2698 .add_child(inner_card)
2699 .add_child(outer_extra)
2700 .access_merge_subtree(),
2701 );
2702 tree.layout(SizeProposal::exact(200.0, 100.0));
2703 let merged = tree.text_content(outer).unwrap_or_default();
2708 assert!(merged.contains("a"));
2709 assert!(merged.contains("b"));
2710 assert!(merged.contains("X"));
2711 let update = tree.sync_accessibility();
2713 assert!(find_node(&update, inner_a).is_none());
2714 assert!(find_node(&update, inner_b).is_none());
2715 assert!(find_node(&update, inner_card).is_none());
2716 assert!(find_node(&update, outer_extra).is_none());
2717 }
2718
2719 #[test]
2721 fn access_customize_runs_last() {
2722 let mut tree = WidgetTree::new();
2723 let id = tree.add(
2724 FillWidget::new()
2725 .access_label_literal("A")
2726 .access_customize(|b| b.set_name("B")),
2727 );
2728 tree.layout(SizeProposal::exact(50.0, 50.0));
2729 assert_eq!(tree.accessibility_node(id).name(), Some("B"));
2730 }
2731
2732 #[test]
2734 fn access_customize_can_reach_inner_mut() {
2735 let mut tree = WidgetTree::new();
2736 let id = tree.add(FillWidget::new().access_customize(|b| {
2737 b.inner_mut().set_author_id("from-customize");
2738 }));
2739 tree.layout(SizeProposal::exact(50.0, 50.0));
2740 let update = tree.sync_accessibility();
2741 let node = find_node(&update, id).unwrap();
2742 assert_eq!(node.author_id(), Some("from-customize"));
2743 }
2744
2745 #[test]
2747 fn access_overrides_zero_cost_when_unused() {
2748 use std::mem::size_of;
2755 assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
2756 assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
2757 }
2758
2759 #[test]
2760 fn accessibility_children_overrides_at_reading_order() {
2761 #[derive(Debug)]
2766 struct NamedLeaf(&'static str);
2767 impl Widget for NamedLeaf {
2768 fn layout_response(
2769 &self,
2770 proposal: SizeProposal,
2771 _ctx: &LayoutContext,
2772 ) -> crate::widget::LayoutResponse {
2773 proposal.resolve(10.0, 10.0).into()
2774 }
2775 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2776 builder.set_role(accesskit::Role::Button);
2780 builder.set_name(self.0);
2781 }
2782 }
2783
2784 #[derive(Debug, Default)]
2785 struct ReorderContainer {
2786 a: Option<WidgetId>,
2787 b: Option<WidgetId>,
2788 }
2789 impl Widget for ReorderContainer {
2790 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2791 let a = ctx.add(NamedLeaf("A"));
2792 let b = ctx.add(NamedLeaf("B"));
2793 self.a = Some(a);
2794 self.b = Some(b);
2795 vec![a, b]
2796 }
2797 fn layout_response(
2798 &self,
2799 proposal: SizeProposal,
2800 _ctx: &LayoutContext,
2801 ) -> crate::widget::LayoutResponse {
2802 proposal.resolve(100.0, 100.0).into()
2803 }
2804 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2805 builder.set_role(accesskit::Role::Group);
2808 builder.set_name("Container");
2809 }
2810 fn children(&self) -> Vec<WidgetId> {
2811 [self.a, self.b].into_iter().flatten().collect()
2812 }
2813 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2814 Some([self.b, self.a].into_iter().flatten().collect())
2816 }
2817 }
2818
2819 let mut tree = WidgetTree::new();
2820 let container = tree.add(ReorderContainer::default());
2821 tree.layout(SizeProposal::exact(100.0, 100.0));
2822 let update = tree.sync_accessibility();
2823
2824 let a_nid = update
2825 .nodes
2826 .iter()
2827 .find(|(_, n)| n.label() == Some("A"))
2828 .map(|(id, _)| *id)
2829 .expect("A node present");
2830 let b_nid = update
2831 .nodes
2832 .iter()
2833 .find(|(_, n)| n.label() == Some("B"))
2834 .map(|(id, _)| *id)
2835 .expect("B node present");
2836 let cnid = crate::accessibility::widget_id_to_node_id(container);
2837 let cnode = update
2838 .nodes
2839 .iter()
2840 .find(|(id, _)| *id == cnid)
2841 .map(|(_, n)| n)
2842 .expect("container node present");
2843 let kids = cnode.children();
2844 let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
2845 let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
2846 assert!(
2847 pb < pa,
2848 "accessibility_children() must set AT order B-before-A; got {kids:?}"
2849 );
2850 }
2851}