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 self.announce_focusable(id, &mut builder);
534
535 if let Some(ov) = node.access_overrides.as_deref() {
539 ov.apply(&mut builder);
540 if let Some(ref id) = ov.shortcut_id
548 && let Some(eff) = self.shortcut_registry.effective(id)
549 && let Some(ks) = eff.primary
550 {
551 builder.set_keyboard_shortcut(ks.to_string());
552 }
553 }
554
555 let subtree_mode = node.access_subtree;
556 let at_children_owned;
564 let children: &[WidgetId] = match node.widget.accessibility_children() {
565 Some(v) => {
566 at_children_owned = v;
567 &at_children_owned
568 }
569 None => self.arena.children(id),
570 };
571
572 match subtree_mode {
578 AccessSubtreeMode::Inherit => {
579 for &child_id in children {
580 if self.arena.is_active(child_id) {
581 let child_nid = widget_id_to_node_id(child_id);
582 if self.ancestor_chain_redirects(id, child_id) {
599 seen_children.insert(child_nid, id);
602 continue;
603 }
604 if let Some(&prior_parent) = seen_children.get(&child_nid) {
605 eprintln!(
606 "Teksilo bug: duplicate accessibility child {:?}: \
607 first claimed by parent {:?}, now also claimed by {:?}. \
608 Please file a bug report.",
609 child_id, prior_parent, id
610 );
611 continue;
612 }
613 seen_children.insert(child_nid, id);
614 builder.inner_mut().push_child(child_nid);
615 }
616 }
617 }
618 AccessSubtreeMode::Exclude => {
619 }
621 AccessSubtreeMode::Merge => {
622 merge_descendants_into(&mut builder, id, &self.arena);
623 }
624 }
625
626 let bounds = self.arena.bounds(id);
627 builder.inner_mut().set_bounds(accesskit::Rect {
628 x0: bounds.x as f64,
629 y0: bounds.y as f64,
630 x1: (bounds.x + bounds.width) as f64,
631 y1: (bounds.y + bounds.height) as f64,
632 });
633
634 let force_clear_disabled =
639 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
640 if !self.arena.is_enabled(id) && !force_clear_disabled {
641 builder.set_disabled();
642 }
643
644 if let Some(content_id) =
664 self.tooltip_description_target(id, handled_tooltips, &mut builder)
665 {
666 if self
667 .tooltips
668 .iter()
669 .any(|t| t.content_id == content_id && t.overlay_id.is_some())
670 {
671 builder
674 .inner_mut()
675 .push_described_by(widget_id_to_node_id(self.tooltip_content_node(content_id)));
676 handled_tooltips.insert(content_id);
677 } else if let Some(text) = self.tooltip_access_description(content_id) {
678 builder.set_description(text);
686 handled_tooltips.insert(content_id);
687 }
688 }
689
690 let (node_id, ak_node, synthetic_children) = builder.build(id);
691 nodes.push((node_id, ak_node));
692 for (syn_id, syn_node) in synthetic_children {
698 nodes.push((syn_id, syn_node));
699 synthetic_parents.insert(syn_id, id);
700 }
701
702 if matches!(subtree_mode, AccessSubtreeMode::Inherit) {
705 for &child_id in children {
706 self.build_accessibility_recursive(
707 child_id,
708 nodes,
709 synthetic_parents,
710 seen_children,
711 handled_tooltips,
712 );
713 }
714 }
715 }
716
717 fn ancestor_chain_redirects(&self, parent_id: WidgetId, descendant: WidgetId) -> bool {
734 let mut current = Some(parent_id);
735 while let Some(curr) = current {
736 let Some(curr_node) = self.arena.get(curr) else {
737 break;
738 };
739 if curr_node.widget.wants_descendant_redirects()
740 && curr_node
741 .widget
742 .a11y_redirect_descendant(curr, descendant)
743 .is_some()
744 {
745 return true;
746 }
747 current = self.arena.parent(curr);
748 }
749 false
750 }
751
752 fn tooltip_description_target(
784 &self,
785 id: WidgetId,
786 handled: &std::collections::HashSet<WidgetId>,
787 builder: &mut AccessNodeBuilder,
788 ) -> Option<WidgetId> {
789 let mut claims = self
790 .tooltips
791 .iter()
792 .filter(|t| t.description_owner_id == id && !handled.contains(&t.content_id));
793 if let (Some(only), None) = (claims.next(), claims.next())
796 && only.anchor_id != id
797 && !is_presentational_container(builder.inner_mut())
803 && builder.inner_mut().description().is_none()
808 {
809 return Some(only.content_id);
810 }
811 self.tooltips
812 .iter()
813 .find(|t| t.anchor_id == id && !handled.contains(&t.content_id))
814 .map(|t| t.content_id)
815 }
816
817 fn announce_focusable(&self, id: WidgetId, builder: &mut AccessNodeBuilder) {
861 if self
862 .arena
863 .get(id)
864 .is_some_and(|node| self.is_node_focusable(node))
865 && !builder.actions().contains(&accesskit::Action::Focus)
866 {
867 builder.add_action(accesskit::Action::Focus);
868 }
869 }
870
871 fn announce_context_menu(&self, id: WidgetId, builder: &mut AccessNodeBuilder) {
872 if self
873 .arena
874 .get(id)
875 .is_some_and(|node| node.context_menu_factory.is_some())
876 && !builder
877 .actions()
878 .contains(&accesskit::Action::ShowContextMenu)
879 {
880 builder.add_action(accesskit::Action::ShowContextMenu);
881 }
882 }
883
884 fn build_overridden_builder(&self, id: WidgetId) -> AccessNodeBuilder {
885 use crate::widget_builder::AccessSubtreeMode;
886 let node = self.arena.get(id).expect("widget id is active in arena");
887 let mut builder = AccessNodeBuilder::for_widget(id);
888 node.widget.accessibility(&mut builder);
889 self.announce_context_menu(id, &mut builder);
890 self.announce_focusable(id, &mut builder);
891 if let Some(ov) = node.access_overrides.as_deref() {
892 ov.apply(&mut builder);
893 if let Some(ref sid) = ov.shortcut_id
896 && let Some(eff) = self.shortcut_registry.effective(sid)
897 && let Some(ks) = eff.primary
898 {
899 builder.set_keyboard_shortcut(ks.to_string());
900 }
901 }
902 if node.access_subtree == AccessSubtreeMode::Merge {
903 merge_descendants_into(&mut builder, id, &self.arena);
904 }
905 builder
906 }
907
908 pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
926 self.arena
927 .get(content_id)
928 .and_then(|node| node.widget.as_any())
929 .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
930 .and_then(|d| d.materialized_child())
931 .unwrap_or(content_id)
932 }
933
934 pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
935 let content_id = self.tooltip_content_node(content_id);
936 let node = self.arena.get(content_id)?;
937 let mut probe = AccessNodeBuilder::for_widget(content_id);
938 node.widget.accessibility(&mut probe);
939 if let Some(ov) = node.access_overrides.as_deref() {
940 ov.apply(&mut probe);
941 }
942 probe
943 .name()
944 .map(str::trim)
945 .filter(|name| !name.is_empty())
946 .map(str::to_owned)
947 }
948
949 pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
950 let node = self.arena.get(id).expect("widget id is active in arena");
951 let builder = self.build_overridden_builder(id);
952 let role = builder.role();
953 let name = builder.name().map(|s| s.to_string());
954 let actions = builder.actions().to_vec();
955 let mut info = AccessibilityInfo::new(role, name, actions);
956 if let Some(toggled) = builder.toggled() {
957 info = info.with_toggled(toggled);
958 }
959 if let Some(expanded) = builder.expanded() {
960 info = info.with_expanded(expanded);
961 }
962 if let Some(selected) = builder.selected() {
963 info = info.with_selected(selected);
964 }
965 let force_clear_disabled =
969 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
970 let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
971 let disabled_override =
976 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(true);
977 if disabled_arena || disabled_override {
978 info = info.with_disabled(true);
979 }
980 if builder.is_hidden() {
981 info = info.with_hidden(true);
982 }
983 info
984 }
985
986 pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
987 self.arena
988 .active_ids_iter()
989 .find(|&id| self.build_overridden_builder(id).role() == role)
990 }
991
992 pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
993 self.arena
994 .active_ids_iter()
995 .find(|&id| self.build_overridden_builder(id).name() == Some(label))
996 }
997
998 pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
999 self.arena.active_ids_iter().find(|&id| {
1000 self.build_overridden_builder(id)
1001 .actions()
1002 .contains(&action)
1003 })
1004 }
1005
1006 pub fn text_content(&self, id: WidgetId) -> Option<String> {
1010 self.arena.get(id)?;
1011 self.build_overridden_builder(id)
1012 .name()
1013 .map(|s| s.to_string())
1014 }
1015
1016 pub fn text_value(&self, id: WidgetId) -> Option<String> {
1020 self.arena.get(id)?;
1021 self.build_overridden_builder(id)
1022 .value()
1023 .map(|s| s.to_string())
1024 }
1025}
1026
1027fn is_presentational_container(node: &accesskit::Node) -> bool {
1041 use accesskit::Role;
1042 if !matches!(node.role(), Role::GenericContainer | Role::Unknown) {
1043 return false;
1044 }
1045 let mut probe = node.clone();
1055 probe.clear_children();
1056 probe.clear_bounds();
1057 probe.clear_disabled();
1058 format!("{probe:?}") == format!("{:?}", accesskit::Node::new(node.role()))
1059}
1060
1061fn merge_descendants_into(
1083 parent_builder: &mut AccessNodeBuilder,
1084 parent_id: WidgetId,
1085 arena: &crate::arena::WidgetArena,
1086) {
1087 let mut acc = MergeAccumulator::default();
1088 for &child in arena.children(parent_id) {
1089 merge_collect_recursive(child, arena, &mut acc);
1090 }
1091 acc.flush_into(parent_builder);
1092}
1093
1094fn merge_collect_recursive(
1095 id: WidgetId,
1096 arena: &crate::arena::WidgetArena,
1097 acc: &mut MergeAccumulator,
1098) {
1099 use crate::widget_builder::AccessSubtreeMode;
1100
1101 if !arena.is_active(id) {
1102 return;
1103 }
1104 let Some(node) = arena.get(id) else {
1105 return;
1106 };
1107
1108 let mut tmp = AccessNodeBuilder::for_widget(id);
1113 node.widget.accessibility(&mut tmp);
1114 if let Some(ov) = node.access_overrides.as_deref() {
1115 ov.apply(&mut tmp);
1116 }
1117 if matches!(node.access_subtree, AccessSubtreeMode::Merge) {
1122 merge_descendants_into(&mut tmp, id, arena);
1123 }
1124 if !tmp.is_hidden() {
1128 acc.absorb(&tmp);
1129 }
1130
1131 match node.access_subtree {
1132 AccessSubtreeMode::Exclude => {
1133 }
1135 AccessSubtreeMode::Merge => {
1136 }
1141 AccessSubtreeMode::Inherit => {
1142 for &grandchild in arena.children(id) {
1143 merge_collect_recursive(grandchild, arena, acc);
1144 }
1145 }
1146 }
1147}
1148
1149#[derive(Default)]
1163struct MergeAccumulator {
1164 label_parts: Vec<String>,
1165 value: Option<String>,
1167 actions: Vec<accesskit::Action>,
1168}
1169
1170impl MergeAccumulator {
1171 fn absorb(&mut self, src: &AccessNodeBuilder) {
1172 if let Some(name) = src.name()
1173 && !name.is_empty()
1174 {
1175 self.label_parts.push(name.to_string());
1176 }
1177 if let Some(value) = src.value()
1178 && self.value.is_none()
1179 && !value.is_empty()
1180 {
1181 self.value = Some(value.to_string());
1182 }
1183 for &action in src.actions() {
1184 if !self.actions.contains(&action) {
1185 self.actions.push(action);
1186 }
1187 }
1188 }
1189
1190 fn flush_into(self, dst: &mut AccessNodeBuilder) {
1191 if !self.label_parts.is_empty() {
1194 let existing = dst.name().map(|s| s.to_string());
1195 let merged = match existing {
1196 Some(e) if !e.is_empty() => {
1197 let mut s = e;
1198 for part in self.label_parts {
1199 s.push(' ');
1200 s.push_str(&part);
1201 }
1202 s
1203 }
1204 _ => self.label_parts.join(" "),
1205 };
1206 dst.set_name(merged);
1207 }
1208 if let Some(v) = self.value {
1209 if dst.value().is_none() {
1213 dst.set_value(v);
1214 }
1215 }
1216 for action in self.actions {
1217 if !dst.actions().contains(&action) {
1220 dst.add_action(action);
1221 }
1222 }
1223 }
1224}
1225
1226#[cfg(test)]
1227pub(crate) mod test_helpers {
1228 pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
1234 accesskit_consumer::Tree::new(update.clone(), false);
1235 }
1236
1237 pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
1242 let emitted: std::collections::HashSet<accesskit::NodeId> =
1243 update.nodes.iter().map(|(id, _)| *id).collect();
1244 for (parent_id, node) in &update.nodes {
1245 for &target in node.controls() {
1246 assert!(
1247 emitted.contains(&target),
1248 "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
1249 );
1250 }
1251 for &target in node.described_by() {
1252 assert!(
1253 emitted.contains(&target),
1254 "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
1255 );
1256 }
1257 }
1258 }
1259
1260 #[allow(dead_code)]
1262 pub(crate) fn nodes_with_role(
1263 update: &accesskit::TreeUpdate,
1264 role: accesskit::Role,
1265 ) -> Vec<accesskit::NodeId> {
1266 update
1267 .nodes
1268 .iter()
1269 .filter(|(_, node)| node.role() == role)
1270 .map(|(id, _)| *id)
1271 .collect()
1272 }
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277 use super::test_helpers::*;
1278 use super::*;
1279 use crate::test_widgets::{FillWidget, StackWidget};
1280
1281 const ANNOUNCER_NODES: usize = 2;
1285
1286 fn announced_by_consumer(update: &accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
1302 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
1303 let state = consumer.state();
1304 let mut found = Vec::new();
1305 let mut stack = vec![state.root()];
1306 while let Some(node) = stack.pop() {
1307 if node.live() != accesskit::Live::Off
1308 && let Some(label) = node.label()
1309 {
1310 found.push((label, node.live()));
1311 }
1312 for child in node.filtered_children(&accesskit_consumer::common_filter) {
1313 stack.push(child);
1314 }
1315 }
1316 found
1317 }
1318
1319 fn live_nodes_in_filtered_tree(update: &accesskit::TreeUpdate) -> Vec<String> {
1328 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
1329 let state = consumer.state();
1330 let mut found = Vec::new();
1331 let mut stack = vec![state.root()];
1332 while let Some(node) = stack.pop() {
1333 if node.live() != accesskit::Live::Off {
1334 found.push(format!("{:?}", node.id()));
1335 }
1336 for child in node.filtered_children(&accesskit_consumer::common_filter) {
1337 stack.push(child);
1338 }
1339 }
1340 found.sort();
1341 found
1342 }
1343
1344 fn tree_with_one_widget() -> WidgetTree {
1345 let mut tree = WidgetTree::new();
1346 tree.add(FillWidget::new().label("content"));
1347 tree.layout(SizeProposal::exact(200.0, 100.0));
1348 tree
1349 }
1350
1351 #[test]
1354 fn an_announcement_enters_the_filtered_tree_then_leaves_it() {
1355 let mut tree = tree_with_one_widget();
1356 let update = tree.sync_accessibility();
1359 assert_eq!(announced_by_consumer(&update), Vec::new());
1360 assert_eq!(
1361 live_nodes_in_filtered_tree(&update),
1362 Vec::<String>::new(),
1363 "an idle announcer must be outside the filtered tree"
1364 );
1365 assert_a11y_tree_valid(&update);
1366
1367 tree.announce("Event added");
1368
1369 let exposed = tree.sync_accessibility();
1370 assert_eq!(
1371 announced_by_consumer(&exposed),
1372 vec![("Event added".to_string(), accesskit::Live::Polite)],
1373 "the message must be a label on a live node inside the filtered tree"
1374 );
1375 assert_eq!(
1376 live_nodes_in_filtered_tree(&exposed).len(),
1377 1,
1378 "exactly the polite announcer must have entered the filtered tree"
1379 );
1380 assert_a11y_tree_valid(&exposed);
1381
1382 let retracted = tree.sync_accessibility();
1383 assert_eq!(
1384 announced_by_consumer(&retracted),
1385 Vec::new(),
1386 "the node must stop carrying the message"
1387 );
1388 assert_eq!(
1394 live_nodes_in_filtered_tree(&retracted),
1395 Vec::<String>::new(),
1396 "the live node must leave the filtered tree entirely, not merely \
1397 lose its label"
1398 );
1399 assert_a11y_tree_valid(&retracted);
1400 }
1401
1402 #[test]
1407 fn the_same_message_announced_twice_is_exposed_twice() {
1408 let mut tree = tree_with_one_widget();
1409 tree.announce("Saved");
1410 tree.announce("Saved");
1411
1412 let mut exposures = 0;
1413 for _ in 0..6 {
1414 let update = tree.sync_accessibility();
1415 if announced_by_consumer(&update)
1416 .iter()
1417 .any(|(text, _)| text == "Saved")
1418 {
1419 exposures += 1;
1420 }
1421 }
1422 assert_eq!(exposures, 2, "each announcement needs its own exposure");
1423 }
1424
1425 #[test]
1426 fn an_assertive_announcement_uses_the_assertive_live_setting() {
1427 let mut tree = tree_with_one_widget();
1428 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
1429 let update = tree.sync_accessibility();
1430 assert_eq!(
1431 announced_by_consumer(&update),
1432 vec![("Could not save".to_string(), accesskit::Live::Assertive)]
1433 );
1434 }
1435
1436 #[test]
1439 fn the_two_politeness_levels_announce_independently() {
1440 let mut tree = tree_with_one_widget();
1441 tree.announce("Event added");
1442 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
1443
1444 let mut spoken = announced_by_consumer(&tree.sync_accessibility());
1445 spoken.sort_by(|a, b| a.0.cmp(&b.0));
1446 assert_eq!(
1447 spoken,
1448 vec![
1449 ("Could not save".to_string(), accesskit::Live::Assertive),
1450 ("Event added".to_string(), accesskit::Live::Polite),
1451 ],
1452 "both levels must be exposed on the same update"
1453 );
1454 }
1455
1456 #[test]
1460 fn announcing_requests_the_syncs_it_needs() {
1461 let mut tree = tree_with_one_widget();
1462 let _ = tree.sync_accessibility();
1463 tree.a11y_update_requested.set(false);
1464 tree.frame_tick_requested.set(false);
1465
1466 tree.announce("Event added");
1467 assert!(
1468 tree.a11y_update_requested.get(),
1469 "an announcement must dirty the accessibility tree"
1470 );
1471 assert!(
1472 tree.frame_requested(),
1473 "an announcement must ask for the frame that carries it"
1474 );
1475
1476 tree.a11y_update_requested.set(false);
1478 tree.frame_tick_requested.set(false);
1479 let _ = tree.sync_accessibility();
1480 assert!(tree.a11y_update_requested.get());
1481 assert!(tree.frame_requested());
1482
1483 tree.a11y_update_requested.set(false);
1485 tree.frame_tick_requested.set(false);
1486 let _ = tree.sync_accessibility();
1487 assert!(!tree.a11y_update_requested.get());
1488 assert!(!tree.frame_requested());
1489 }
1490
1491 #[test]
1494 fn an_announcement_reaches_the_automation_ring() {
1495 let mut tree = tree_with_one_widget();
1496 let before = tree.announcements_since(0).len() as u64;
1497 tree.announce("Event added");
1498 let _ = tree.sync_accessibility();
1499 let got = tree.announcements_since(before);
1500 assert_eq!(got.len(), 1);
1501 assert_eq!(got[0].text, "Event added");
1502 assert!(!got[0].assertive);
1503 }
1504
1505 #[test]
1509 fn a_tree_snapshot_does_not_consume_a_pending_announcement() {
1510 let mut tree = tree_with_one_widget();
1511 tree.announce("Event added");
1512
1513 let snapshot = tree.accessibility_tree_snapshot();
1514 assert_eq!(
1515 announced_by_consumer(&snapshot),
1516 Vec::new(),
1517 "a snapshot must not advance the announcer"
1518 );
1519
1520 let delivered = tree.sync_accessibility();
1521 assert_eq!(
1522 announced_by_consumer(&delivered),
1523 vec![("Event added".to_string(), accesskit::Live::Polite)],
1524 "the message must still be there to deliver"
1525 );
1526 }
1527
1528 #[test]
1531 fn an_empty_announcement_is_not_exposed() {
1532 let mut tree = tree_with_one_widget();
1533 let _ = tree.sync_accessibility();
1534 tree.announce(" ");
1535 let update = tree.sync_accessibility();
1536 assert_eq!(announced_by_consumer(&update), Vec::new());
1537 }
1538
1539 #[derive(Debug)]
1540 struct ActionWidget;
1541
1542 impl Widget for ActionWidget {
1543 fn layout_response(
1544 &self,
1545 proposal: SizeProposal,
1546 _ctx: &LayoutContext,
1547 ) -> crate::widget::LayoutResponse {
1548 proposal.resolve(0.0, 0.0).into()
1549 }
1550
1551 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1552 builder.set_role(accesskit::Role::Button);
1553 builder.set_name("Save");
1554 builder.add_action(accesskit::Action::Click);
1555 builder.add_action(accesskit::Action::Focus);
1556 }
1557 }
1558
1559 #[derive(Debug)]
1560 struct ClickableWidget;
1561
1562 impl Widget for ClickableWidget {
1563 fn layout_response(
1564 &self,
1565 proposal: SizeProposal,
1566 _ctx: &LayoutContext,
1567 ) -> crate::widget::LayoutResponse {
1568 proposal.resolve(0.0, 0.0).into()
1569 }
1570
1571 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1572 builder.set_role(accesskit::Role::Button);
1573 builder.set_name("Click Me");
1574 builder.add_action(accesskit::Action::Click);
1575 }
1576 }
1577
1578 #[test]
1583 fn a_focusable_node_advertises_focus_without_the_widget_saying_so() {
1584 let mut tree = WidgetTree::new();
1585 let id = tree.add(ClickableWidget.focusable(true));
1587 tree.layout(SizeProposal::exact(100.0, 100.0));
1588
1589 let info = tree.accessibility_node(id);
1590 assert!(
1591 info.actions().contains(&accesskit::Action::Focus),
1592 "the arena knows the node is focusable; the AT node must say so"
1593 );
1594
1595 let mut ops = crate::window::NoopWindowOps;
1597 assert!(tree.dispatch_access_action(
1598 crate::accessibility::widget_id_to_node_id(id),
1599 accesskit::Action::Focus,
1600 None,
1601 &mut ops,
1602 ));
1603 assert_eq!(tree.focused(), Some(id));
1604 }
1605
1606 #[test]
1609 fn a_derived_focus_action_is_still_removable_by_an_override() {
1610 let mut tree = WidgetTree::new();
1611 let id = tree.add(
1612 ClickableWidget
1613 .focusable(true)
1614 .access_remove_action(accesskit::Action::Focus),
1615 );
1616 tree.layout(SizeProposal::exact(100.0, 100.0));
1617 assert!(
1618 !tree
1619 .accessibility_node(id)
1620 .actions()
1621 .contains(&accesskit::Action::Focus)
1622 );
1623 }
1624
1625 #[test]
1628 fn a_non_focusable_node_does_not_advertise_focus() {
1629 let mut tree = WidgetTree::new();
1630 let id = tree.add(ClickableWidget);
1631 tree.layout(SizeProposal::exact(100.0, 100.0));
1632 assert!(
1633 !tree
1634 .accessibility_node(id)
1635 .actions()
1636 .contains(&accesskit::Action::Focus)
1637 );
1638 }
1639
1640 #[test]
1641 fn labeled_widget_has_accessibility() {
1642 let mut tree = WidgetTree::new();
1643 let widget = tree.add(FillWidget::new().label("Hello"));
1644 tree.layout(SizeProposal::exact(100.0, 20.0));
1645 let info = tree.accessibility_node(widget);
1646 assert_eq!(info.role(), accesskit::Role::Label);
1647 assert_eq!(info.name(), Some("Hello"));
1648 }
1649
1650 #[test]
1651 fn find_by_label_works() {
1652 let mut tree = WidgetTree::new();
1653 let widget = tree.add(FillWidget::new().label("Save"));
1654 tree.layout(SizeProposal::exact(100.0, 20.0));
1655 assert_eq!(tree.find_by_label("Save"), Some(widget));
1656 }
1657
1658 #[test]
1659 fn label_role_emits_text_as_value_not_label() {
1660 let mut tree = WidgetTree::new();
1667 let widget = tree.add(FillWidget::new().label("Section")); tree.layout(SizeProposal::exact(100.0, 20.0));
1669
1670 assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1672
1673 let update = tree.sync_accessibility();
1675 let node = &update
1676 .nodes
1677 .iter()
1678 .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1679 .expect("label node must be present in the tree update")
1680 .1;
1681 assert_eq!(node.role(), accesskit::Role::Label);
1682 assert_eq!(
1683 node.value(),
1684 Some("Section"),
1685 "Role::Label text must be exposed via `value`"
1686 );
1687 assert_eq!(
1688 node.label(),
1689 None,
1690 "Role::Label must not leave text in the `label` property"
1691 );
1692 }
1693
1694 #[test]
1695 fn find_by_role_works() {
1696 let mut tree = WidgetTree::new();
1697 tree.add(FillWidget::new().label("Text"));
1698 tree.layout(SizeProposal::exact(100.0, 20.0));
1699 assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1700 }
1701
1702 #[test]
1703 fn access_hidden_bound_to_signal_toggles_reactively() {
1704 use crate::signal::Signal;
1705 use crate::widget_builder::WidgetBuilder;
1706 let hidden = Signal::new(false);
1707 let mut tree = WidgetTree::new();
1708 let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1709 tree.layout(SizeProposal::exact(100.0, 30.0));
1710 assert!(
1711 !tree.accessibility_node(w).is_hidden(),
1712 "node should be visible to AT while the signal is false"
1713 );
1714 hidden.set(true);
1715 assert!(
1717 tree.accessibility_node(w).is_hidden(),
1718 "node should hide from AT when the bound signal flips to true"
1719 );
1720 hidden.set(false);
1721 assert!(!tree.accessibility_node(w).is_hidden());
1722 }
1723
1724 #[test]
1728 fn a_widget_with_a_context_menu_advertises_the_action() {
1729 let mut tree = WidgetTree::new();
1730 let plain = tree.add(FillWidget::new().label("no menu"));
1731 let with_menu = tree.add(
1732 FillWidget::new()
1733 .label("has menu")
1734 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1735 );
1736 tree.layout(SizeProposal::exact(100.0, 20.0));
1737
1738 assert!(
1739 tree.accessibility_node(with_menu)
1740 .actions()
1741 .contains(&Action::ShowContextMenu),
1742 "a node owning a context-menu factory must announce it"
1743 );
1744 assert!(
1745 !tree
1746 .accessibility_node(plain)
1747 .actions()
1748 .contains(&Action::ShowContextMenu),
1749 "and a node without one must not"
1750 );
1751 }
1752
1753 #[test]
1756 fn a_removed_context_menu_action_stays_removed() {
1757 let mut tree = WidgetTree::new();
1758 let id = tree.add(
1759 FillWidget::new()
1760 .label("suppressed")
1761 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1762 .access_remove_action(Action::ShowContextMenu),
1763 );
1764 tree.layout(SizeProposal::exact(100.0, 20.0));
1765 assert!(
1766 !tree
1767 .accessibility_node(id)
1768 .actions()
1769 .contains(&Action::ShowContextMenu)
1770 );
1771 }
1772
1773 #[test]
1778 fn a_child_does_not_advertise_its_parents_context_menu() {
1779 let mut tree = WidgetTree::new();
1780 let child = tree.add(FillWidget::new().label("inner"));
1781 let parent = tree.add(
1782 StackWidget::new()
1783 .add_child(child)
1784 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1785 );
1786 tree.layout(SizeProposal::exact(100.0, 20.0));
1787
1788 assert!(
1789 tree.accessibility_node(parent)
1790 .actions()
1791 .contains(&Action::ShowContextMenu)
1792 );
1793 assert!(
1794 !tree
1795 .accessibility_node(child)
1796 .actions()
1797 .contains(&Action::ShowContextMenu),
1798 "the menu belongs to the widget that declared it"
1799 );
1800 }
1801
1802 #[test]
1803 fn accessibility_node_collects_actions() {
1804 let mut tree = WidgetTree::new();
1805 let widget = tree.add(ActionWidget);
1806 tree.layout(SizeProposal::exact(100.0, 40.0));
1807
1808 let info = tree.accessibility_node(widget);
1809 assert_eq!(info.role(), accesskit::Role::Button);
1810 assert_eq!(info.name(), Some("Save"));
1811 assert_eq!(info.actions().len(), 2);
1812 assert!(info.actions().contains(&accesskit::Action::Click));
1813 assert!(info.actions().contains(&accesskit::Action::Focus));
1814 }
1815
1816 #[test]
1817 fn sync_accessibility_produces_tree_update() {
1818 let mut tree = WidgetTree::new();
1819 tree.add(FillWidget::new().label("First"));
1820 tree.add(FillWidget::new().label("Second"));
1821 tree.layout(SizeProposal::exact(200.0, 100.0));
1822
1823 let update = tree.sync_accessibility();
1824 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1827 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1828 assert!(update.tree.is_some());
1829 assert_a11y_tree_valid(&update);
1830 }
1831
1832 #[test]
1833 fn root_node_carries_locale_as_language() {
1834 let mut tree = WidgetTree::new();
1835 tree.add(FillWidget::new().label("Bonjour"));
1836
1837 tree.layout(SizeProposal::exact(200.0, 100.0));
1839 let update = tree.sync_accessibility();
1840 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1841 assert_eq!(
1842 update.nodes[0].1.language(),
1843 None,
1844 "no language before a locale is set"
1845 );
1846
1847 tree.set_locale("fr-FR".to_string());
1850 let update = tree.sync_accessibility();
1851 assert_eq!(
1852 update.nodes[0].1.language(),
1853 Some("fr-FR"),
1854 "root node must advertise the active locale as its language"
1855 );
1856 }
1857
1858 #[test]
1859 fn sync_accessibility_excludes_dormant_widgets() {
1860 let mut tree = WidgetTree::new();
1861 tree.add(FillWidget::new().label("Active"));
1862 let dormant = tree.add(FillWidget::new().label("Dormant"));
1863 tree.layout(SizeProposal::exact(200.0, 100.0));
1864
1865 tree.set_dormant(dormant);
1866
1867 let update = tree.sync_accessibility();
1868 assert_eq!(update.nodes.len(), 2 + ANNOUNCER_NODES);
1869 assert_a11y_tree_valid(&update);
1870 }
1871
1872 #[test]
1879 fn relayout_without_activation_change_does_not_dirty_a11y() {
1880 let mut tree = WidgetTree::new();
1881 let id = tree.add(FillWidget::new().label("Static"));
1882 tree.layout(SizeProposal::exact(200.0, 100.0));
1883
1884 let _ = tree.sync_accessibility();
1886 assert!(
1887 !tree.a11y_dirty,
1888 "sync_accessibility must clear the dirty flag"
1889 );
1890
1891 tree.arena.mark_needs_layout(id);
1895 tree.layout(SizeProposal::exact(200.0, 100.0));
1896
1897 assert!(
1898 !tree.a11y_dirty,
1899 "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1900 );
1901 }
1902
1903 #[test]
1907 fn activation_transition_does_dirty_a11y() {
1908 let mut tree = WidgetTree::new();
1909 let id = tree.add(FillWidget::new().label("Toggle"));
1910 tree.layout(SizeProposal::exact(200.0, 100.0));
1911 let _ = tree.sync_accessibility();
1912 assert!(!tree.a11y_dirty);
1913
1914 tree.set_dormant(id);
1915 tree.layout(SizeProposal::exact(200.0, 100.0));
1916 assert!(
1917 tree.a11y_dirty,
1918 "active→dormant transition must dirty the AT cache so the dormant node is removed"
1919 );
1920 }
1921
1922 #[test]
1923 fn sync_accessibility_includes_focus() {
1924 let mut tree = WidgetTree::new();
1925 let widget = tree.add(FillWidget::new().focusable().label("Focused"));
1926 tree.layout(SizeProposal::exact(100.0, 50.0));
1927 tree.focus(widget);
1928
1929 let update = tree.sync_accessibility();
1930 let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
1931 assert_eq!(update.focus, expected_focus);
1932 assert_a11y_tree_valid(&update);
1933 }
1934
1935 #[test]
1936 fn sync_accessibility_parent_child_relationship() {
1937 let mut tree = WidgetTree::new();
1938 let child = tree.add(FillWidget::new().label("Child"));
1939 let parent = tree.add(
1942 StackWidget::new()
1943 .add_child(child)
1944 .access_label_literal("Parent"),
1945 );
1946 tree.layout(SizeProposal::exact(100.0, 50.0));
1947
1948 let update = tree.sync_accessibility();
1949 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1950
1951 let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
1952 let parent_node = update
1953 .nodes
1954 .iter()
1955 .find(|(id, _)| *id == parent_node_id)
1956 .map(|(_, node)| node)
1957 .unwrap();
1958
1959 let child_node_id = crate::accessibility::widget_id_to_node_id(child);
1960 assert!(parent_node.children().contains(&child_node_id));
1961 assert_a11y_tree_valid(&update);
1962 }
1963
1964 #[test]
1965 fn presentational_containers_collapse_and_promote_children() {
1966 let mut tree = WidgetTree::new();
1972 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(
1976 StackWidget::new()
1977 .add_child(outer)
1978 .access_label_literal("Group"),
1979 );
1980 tree.layout(SizeProposal::exact(100.0, 50.0));
1981 let update = tree.sync_accessibility();
1982
1983 assert!(find_node(&update, inner).is_none(), "bare inner pruned");
1984 assert!(find_node(&update, outer).is_none(), "bare outer pruned");
1985
1986 let labeled_node = find_node(&update, labeled).expect("labeled group survives");
1987 let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
1988 assert!(
1989 labeled_node.children().contains(&leaf_nid),
1990 "leaf promoted past both bare containers to the labeled ancestor"
1991 );
1992 assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
1993 assert_a11y_tree_valid(&update);
1994 }
1995
1996 #[test]
1997 fn find_by_action_finds_clickable() {
1998 let mut tree = WidgetTree::new();
1999 let widget = tree.add(ClickableWidget);
2000 tree.layout(SizeProposal::exact(100.0, 40.0));
2001
2002 assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
2003 assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
2004 }
2005
2006 #[test]
2007 fn text_content_returns_accessibility_name() {
2008 let mut tree = WidgetTree::new();
2009 let widget = tree.add(FillWidget::new().label("Hello World"));
2010 tree.layout(SizeProposal::exact(100.0, 50.0));
2011
2012 assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
2013 }
2014
2015 #[test]
2016 fn text_content_returns_none_without_label() {
2017 let mut tree = WidgetTree::new();
2018 let widget = tree.add(FillWidget::new());
2019 tree.layout(SizeProposal::exact(100.0, 50.0));
2020
2021 assert_eq!(tree.text_content(widget), None);
2022 }
2023
2024 #[test]
2025 fn descendant_of_disabled_ancestor_reports_disabled() {
2026 use crate::signal::Signal;
2027
2028 let mut tree = WidgetTree::new();
2029 let child = tree.add(FillWidget::new().label("Child"));
2030 let parent = tree.add(StackWidget::new().add_child(child));
2031 tree.enabled_when(parent, Signal::new(false));
2032 tree.layout(SizeProposal::exact(100.0, 50.0));
2033
2034 assert!(
2035 tree.accessibility_node(child).is_disabled(),
2036 "descendant should report disabled when ancestor is disabled"
2037 );
2038 }
2039
2040 #[test]
2041 fn text_value_returns_accessibility_value() {
2042 #[derive(Debug)]
2043 struct ValueWidget;
2044
2045 impl Widget for ValueWidget {
2046 fn layout_response(
2047 &self,
2048 proposal: SizeProposal,
2049 _ctx: &LayoutContext,
2050 ) -> crate::widget::LayoutResponse {
2051 proposal.resolve(0.0, 0.0).into()
2052 }
2053
2054 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
2055 builder.set_role(accesskit::Role::Slider);
2056 builder.set_name("Volume");
2057 builder.set_value("75%");
2058 }
2059 }
2060
2061 let mut tree = WidgetTree::new();
2062 let widget = tree.add(ValueWidget);
2063 tree.layout(SizeProposal::exact(100.0, 40.0));
2064
2065 assert_eq!(tree.text_value(widget), Some("75%".to_string()));
2066 assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
2067 }
2068
2069 #[test]
2070 fn sync_accessibility_has_no_duplicate_children() {
2071 let mut tree = WidgetTree::new();
2075 let grandchild = tree.add(FillWidget::new().label("Grandchild"));
2076 let child_a = tree.add(StackWidget::new().add_child(grandchild));
2077 let child_b = tree.add(FillWidget::new().label("Sibling"));
2078 let _root = tree.add(StackWidget::new().add_child(child_a).add_child(child_b));
2079 tree.layout(SizeProposal::exact(200.0, 100.0));
2080
2081 let update = tree.sync_accessibility();
2082
2083 let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
2084 std::collections::HashMap::new();
2085 for (parent_id, node) in &update.nodes {
2086 for &child_id in node.children() {
2087 let prev = all_children.insert(child_id, *parent_id);
2088 assert!(
2089 prev.is_none(),
2090 "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
2091 );
2092 }
2093 }
2094 assert_a11y_tree_valid(&update);
2095 }
2096
2097 #[test]
2098 fn no_dangling_relationships_in_basic_tree() {
2099 let mut tree = WidgetTree::new();
2100 let child = tree.add(FillWidget::new().label("Child"));
2101 let _parent = tree.add(StackWidget::new().add_child(child));
2102 tree.layout(SizeProposal::exact(100.0, 50.0));
2103
2104 let update = tree.sync_accessibility();
2105 assert_no_dangling_relationships(&update);
2106 assert_a11y_tree_valid(&update);
2107 }
2108
2109 use crate::widget_builder::WidgetBuilder;
2114 use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
2115
2116 fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
2118 let nid = crate::accessibility::widget_id_to_node_id(id);
2119 update
2120 .nodes
2121 .iter()
2122 .find(|(node_id, _)| *node_id == nid)
2123 .map(|(_, n)| n)
2124 }
2125
2126 #[derive(Debug)]
2129 struct AlwaysHiddenWidget;
2130 impl Widget for AlwaysHiddenWidget {
2131 fn layout_response(
2132 &self,
2133 proposal: SizeProposal,
2134 _ctx: &LayoutContext,
2135 ) -> crate::widget::LayoutResponse {
2136 proposal.resolve(0.0, 0.0).into()
2137 }
2138 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2139 builder.set_role(Role::GenericContainer);
2140 builder.set_hidden();
2141 }
2142 }
2143
2144 #[test]
2146 fn access_label_replaces_widget_label() {
2147 let mut tree = WidgetTree::new();
2148 let id = tree.add(ClickableWidget.access_label_literal("Publish"));
2149 tree.layout(SizeProposal::exact(100.0, 40.0));
2150 assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
2151 }
2152
2153 #[test]
2155 fn access_description_appears_on_bare_widget() {
2156 let mut tree = WidgetTree::new();
2157 let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
2158 tree.layout(SizeProposal::exact(50.0, 50.0));
2159 let update = tree.sync_accessibility();
2160 let node = find_node(&update, id).expect("node present");
2161 assert_eq!(node.description(), Some("Decorative"));
2162 }
2163
2164 #[test]
2166 fn access_value_replaces_widget_value() {
2167 #[derive(Debug)]
2168 struct SliderWidget;
2169 impl Widget for SliderWidget {
2170 fn layout_response(
2171 &self,
2172 proposal: SizeProposal,
2173 _ctx: &LayoutContext,
2174 ) -> crate::widget::LayoutResponse {
2175 proposal.resolve(0.0, 0.0).into()
2176 }
2177 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2178 builder.set_role(Role::Slider);
2179 builder.set_value("50");
2180 }
2181 }
2182 let mut tree = WidgetTree::new();
2183 let id = tree.add(SliderWidget.access_value_literal("Custom"));
2184 tree.layout(SizeProposal::exact(100.0, 40.0));
2185 assert_eq!(tree.text_value(id), Some("Custom".to_string()));
2186 }
2187
2188 #[test]
2190 fn access_role_overrides_widget_role() {
2191 let mut tree = WidgetTree::new();
2192 let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
2193 tree.layout(SizeProposal::exact(100.0, 40.0));
2194 assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
2195 }
2196
2197 #[test]
2198 fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
2199 let mut tree = WidgetTree::new();
2206 let anchor = tree.add(FillWidget::new().label("Save"));
2207 let tip = tree.add(FillWidget::new().label("Save the file"));
2208 tree.layout(SizeProposal::exact(200.0, 100.0));
2209 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
2210
2211 assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
2212 let update = tree.sync_accessibility();
2213 let node = find_node(&update, anchor).expect("anchor node present");
2214 assert_eq!(
2215 node.description(),
2216 Some("Save the file"),
2217 "the anchor must describe itself with its tooltip text while unshown"
2218 );
2219 }
2220
2221 #[test]
2222 fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
2223 let mut tree = WidgetTree::new();
2226 let anchor = tree.add(FillWidget::new().label("Save"));
2227 let tip = tree.add(FillWidget::new().label("Save the file"));
2228 tree.layout(SizeProposal::exact(200.0, 100.0));
2229 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2230
2231 tree.pointer_move(tree.bounds(anchor).center());
2232 tree.advance_time(std::time::Duration::from_millis(150));
2233 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
2234
2235 let update = tree.sync_accessibility();
2236 let node = find_node(&update, anchor).expect("anchor node present");
2237 assert!(
2238 !node.described_by().is_empty(),
2239 "a shown tooltip is wired as a described_by relation"
2240 );
2241 }
2242
2243 #[test]
2245 fn access_hint_alias_writes_description_field() {
2246 let mut tree = WidgetTree::new();
2247 let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
2248 tree.layout(SizeProposal::exact(50.0, 50.0));
2249 let update = tree.sync_accessibility();
2250 let node = find_node(&update, id).unwrap();
2251 assert_eq!(node.description(), Some("Tip"));
2252 }
2253
2254 #[test]
2256 fn access_identifier_writes_author_id() {
2257 let mut tree = WidgetTree::new();
2258 let id = tree.add(FillWidget::new().access_identifier("save-button"));
2259 tree.layout(SizeProposal::exact(50.0, 50.0));
2260 let update = tree.sync_accessibility();
2261 let node = find_node(&update, id).unwrap();
2262 assert_eq!(node.author_id(), Some("save-button"));
2263 }
2264
2265 #[test]
2267 fn access_shortcut_literal_set() {
2268 let mut tree = WidgetTree::new();
2269 let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
2270 tree.layout(SizeProposal::exact(50.0, 50.0));
2271 let update = tree.sync_accessibility();
2272 let node = find_node(&update, id).unwrap();
2273 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
2274 }
2275
2276 #[test]
2279 fn access_shortcut_id_resolves_and_tracks_rebinds() {
2280 use crate::event::Key;
2281 use crate::shortcut::{KeyStroke, Shortcut};
2282
2283 let mut tree = WidgetTree::new();
2284 tree.shortcut_registry_mut().register(
2285 Shortcut::new("app.save")
2286 .name("Save")
2287 .primary(KeyStroke::command(Key::S))
2288 .build(),
2289 );
2290 let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
2291 tree.layout(SizeProposal::exact(50.0, 50.0));
2292
2293 let accel = if cfg!(target_os = "macos") {
2297 "Cmd"
2298 } else {
2299 "Ctrl"
2300 };
2301
2302 let update = tree.sync_accessibility();
2304 let node = find_node(&update, id).unwrap();
2305 assert_eq!(
2306 node.keyboard_shortcut(),
2307 Some(format!("{accel}+S").as_str())
2308 );
2309
2310 tree.shortcut_registry_mut()
2312 .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
2313 let update = tree.sync_accessibility();
2314 let node = find_node(&update, id).unwrap();
2315 assert_eq!(
2316 node.keyboard_shortcut(),
2317 Some(format!("{accel}+Q").as_str())
2318 );
2319 }
2320
2321 #[test]
2324 fn access_shortcut_id_unknown_id_omits_announcement() {
2325 let mut tree = WidgetTree::new();
2326 let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
2327 tree.layout(SizeProposal::exact(50.0, 50.0));
2328 let update = tree.sync_accessibility();
2329 let node = find_node(&update, id).unwrap();
2330 assert_eq!(node.keyboard_shortcut(), None);
2331 }
2332
2333 #[test]
2335 fn access_hidden_true_hides_widget() {
2336 let mut tree = WidgetTree::new();
2337 let id = tree.add(ClickableWidget.access_hidden(true));
2338 tree.layout(SizeProposal::exact(50.0, 50.0));
2339 assert!(tree.accessibility_node(id).is_hidden());
2340 }
2341
2342 #[test]
2344 fn access_hidden_false_clears_widget_set_hidden() {
2345 let mut tree = WidgetTree::new();
2346 let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
2347 tree.layout(SizeProposal::exact(50.0, 50.0));
2348 assert!(
2349 !tree.accessibility_node(id).is_hidden(),
2350 "access_hidden(false) should clear widget-emitted hidden"
2351 );
2352 }
2353
2354 #[test]
2356 fn access_disabled_true_marks_disabled() {
2357 let mut tree = WidgetTree::new();
2358 let id = tree.add(FillWidget::new().access_disabled(true));
2359 tree.layout(SizeProposal::exact(50.0, 50.0));
2360 assert!(tree.accessibility_node(id).is_disabled());
2361 }
2362
2363 #[test]
2365 fn access_disabled_false_clears_arena_driven_disabled() {
2366 use crate::signal::Signal;
2367 let mut tree = WidgetTree::new();
2368 let id = tree.add(FillWidget::new().label("X").access_disabled(false));
2369 tree.enabled_when(id, Signal::new(false));
2370 tree.layout(SizeProposal::exact(50.0, 50.0));
2371 assert!(
2372 !tree.accessibility_node(id).is_disabled(),
2373 "access_disabled(false) should clear even arena-driven disabled"
2374 );
2375 }
2376
2377 #[test]
2379 fn access_controls_appends() {
2380 let mut tree = WidgetTree::new();
2381 let target = tree.add(FillWidget::new().label("Target"));
2382 let controller = tree.add(
2383 FillWidget::new()
2384 .label("Controller")
2385 .access_controls(target),
2386 );
2387 tree.layout(SizeProposal::exact(100.0, 50.0));
2388 let update = tree.sync_accessibility();
2389 let node = find_node(&update, controller).unwrap();
2390 let target_nid = crate::accessibility::widget_id_to_node_id(target);
2391 assert!(
2392 node.controls().contains(&target_nid),
2393 "controls list should contain the target NodeId"
2394 );
2395 }
2396
2397 #[test]
2399 fn access_described_by_appends() {
2400 let mut tree = WidgetTree::new();
2401 let other = tree.add(FillWidget::new().label("Desc"));
2402 let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
2403 tree.layout(SizeProposal::exact(100.0, 50.0));
2404 let update = tree.sync_accessibility();
2405 let node = find_node(&update, id).unwrap();
2406 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2407 assert!(node.described_by().contains(&other_nid));
2408 }
2409
2410 #[test]
2412 fn access_labelled_by_appends() {
2413 let mut tree = WidgetTree::new();
2414 let other = tree.add(FillWidget::new().label("Lbl"));
2415 let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
2416 tree.layout(SizeProposal::exact(100.0, 50.0));
2417 let update = tree.sync_accessibility();
2418 let node = find_node(&update, id).unwrap();
2419 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2420 assert!(node.labelled_by().contains(&other_nid));
2421 }
2422
2423 #[test]
2425 fn access_live_assertive_set() {
2426 let mut tree = WidgetTree::new();
2427 let id = tree.add(FillWidget::new().access_live(Live::Assertive));
2428 tree.layout(SizeProposal::exact(50.0, 50.0));
2429 let update = tree.sync_accessibility();
2430 let node = find_node(&update, id).unwrap();
2431 assert_eq!(node.live(), Some(Live::Assertive));
2432 }
2433
2434 #[test]
2436 fn access_aria_current_set() {
2437 let mut tree = WidgetTree::new();
2438 let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
2439 tree.layout(SizeProposal::exact(50.0, 50.0));
2440 let update = tree.sync_accessibility();
2441 let node = find_node(&update, id).unwrap();
2442 assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
2443 }
2444
2445 #[test]
2447 fn access_has_popup_set() {
2448 let mut tree = WidgetTree::new();
2449 let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
2450 tree.layout(SizeProposal::exact(50.0, 50.0));
2451 let update = tree.sync_accessibility();
2452 let node = find_node(&update, id).unwrap();
2453 assert_eq!(node.has_popup(), Some(HasPopup::Menu));
2454 }
2455
2456 #[test]
2458 fn access_orientation_set() {
2459 let mut tree = WidgetTree::new();
2460 let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
2461 tree.layout(SizeProposal::exact(50.0, 50.0));
2462 let update = tree.sync_accessibility();
2463 let node = find_node(&update, id).unwrap();
2464 assert_eq!(node.orientation(), Some(Orientation::Vertical));
2465 }
2466
2467 #[test]
2469 fn access_numeric_value_and_range() {
2470 let mut tree = WidgetTree::new();
2471 let id = tree.add(
2472 FillWidget::new()
2473 .access_role(Role::Slider)
2474 .access_numeric_value(50.0)
2475 .access_numeric_range(0.0, 100.0)
2476 .access_numeric_step(5.0),
2477 );
2478 tree.layout(SizeProposal::exact(50.0, 50.0));
2479 let update = tree.sync_accessibility();
2480 let node = find_node(&update, id).unwrap();
2481 assert_eq!(node.numeric_value(), Some(50.0));
2482 assert_eq!(node.min_numeric_value(), Some(0.0));
2483 assert_eq!(node.max_numeric_value(), Some(100.0));
2484 assert_eq!(node.numeric_value_step(), Some(5.0));
2485 }
2486
2487 #[test]
2489 fn access_action_advertises_and_routes() {
2490 use crate::signal::Signal;
2491 let flag = Signal::new(false);
2492 let flag_for_cb = flag.clone();
2493 let mut tree = WidgetTree::new();
2494 let id = tree.add(
2495 FillWidget::new()
2496 .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
2497 );
2498 tree.layout(SizeProposal::exact(50.0, 50.0));
2499 let info = tree.accessibility_node(id);
2500 assert!(info.actions().contains(&Action::ShowContextMenu));
2501 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2502 action: Action::ShowContextMenu,
2503 target: Some(id),
2504 target_node: crate::accessibility::widget_id_to_node_id(id),
2505 data: None,
2506 });
2507 assert!(flag.get(), "callback should have been invoked");
2508 }
2509
2510 #[test]
2512 fn access_two_actions_both_route() {
2513 use crate::signal::Signal;
2514 let click = Signal::new(false);
2515 let increment = Signal::new(false);
2516 let click_cb = click.clone();
2517 let inc_cb = increment.clone();
2518 let mut tree = WidgetTree::new();
2519 let id = tree.add(
2520 FillWidget::new()
2521 .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
2522 .access_action(Action::Increment, move |_| inc_cb.set(true)),
2523 );
2524 tree.layout(SizeProposal::exact(50.0, 50.0));
2525 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2526 action: Action::ShowContextMenu,
2527 target: Some(id),
2528 target_node: crate::accessibility::widget_id_to_node_id(id),
2529 data: None,
2530 });
2531 assert!(click.get() && !increment.get(), "only first action fired");
2532 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2533 action: Action::Increment,
2534 target: Some(id),
2535 target_node: crate::accessibility::widget_id_to_node_id(id),
2536 data: None,
2537 });
2538 assert!(
2539 click.get() && increment.get(),
2540 "both actions fired exactly once each"
2541 );
2542 }
2543
2544 #[test]
2546 fn access_remove_action_suppresses_widget_action() {
2547 let mut tree = WidgetTree::new();
2548 let id = tree.add(ActionWidget.access_remove_action(Action::Click));
2549 tree.layout(SizeProposal::exact(50.0, 50.0));
2550 let info = tree.accessibility_node(id);
2551 assert!(!info.actions().contains(&Action::Click));
2552 assert!(info.actions().contains(&Action::Focus));
2553 }
2554
2555 #[test]
2557 fn access_custom_action_uses_localized_label() {
2558 let mut tree = WidgetTree::new();
2559 let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
2560 tree.layout(SizeProposal::exact(50.0, 50.0));
2561 let update = tree.sync_accessibility();
2562 let node = find_node(&update, id).unwrap();
2563 let actions = node.custom_actions();
2564 assert_eq!(actions.len(), 1);
2565 assert_eq!(actions[0].id, 0);
2566 assert_eq!(actions[0].description.as_str(), "Reply");
2567 }
2568
2569 #[test]
2571 fn access_custom_action_routes_by_index() {
2572 use crate::signal::Signal;
2573 let first = Signal::new(false);
2574 let second = Signal::new(false);
2575 let f = first.clone();
2576 let s = second.clone();
2577 let mut tree = WidgetTree::new();
2578 let id = tree.add(
2579 FillWidget::new()
2580 .access_custom_action_literal("First", move |_| f.set(true))
2581 .access_custom_action_literal("Second", move |_| s.set(true)),
2582 );
2583 tree.layout(SizeProposal::exact(50.0, 50.0));
2584 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2585 action: Action::CustomAction,
2586 target: Some(id),
2587 target_node: crate::accessibility::widget_id_to_node_id(id),
2588 data: Some(accesskit::ActionData::CustomAction(1)),
2589 });
2590 assert!(!first.get(), "first should not fire");
2591 assert!(second.get(), "second should fire (index 1)");
2592 }
2593
2594 #[test]
2596 fn access_action_layered_with_on_access_action() {
2597 use crate::signal::Signal;
2598 let from_override = Signal::new(false);
2599 let from_user = Signal::new(false);
2600 let ov_cb = from_override.clone();
2601 let user_cb = from_user.clone();
2602 let mut tree = WidgetTree::new();
2603 let id = tree.add(
2604 FillWidget::new()
2605 .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
2606 .on_access_action(move |_action, _ctx| {
2607 user_cb.set(true);
2608 crate::event::EventResponse::Handled
2609 }),
2610 );
2611 tree.layout(SizeProposal::exact(50.0, 50.0));
2612 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2613 action: Action::ShowContextMenu,
2614 target: Some(id),
2615 target_node: crate::accessibility::widget_id_to_node_id(id),
2616 data: None,
2617 });
2618 assert!(from_override.get(), "override callback fired");
2619 assert!(from_user.get(), "user catch-all fired");
2620 }
2621
2622 #[test]
2628 fn access_label_accepts_resolved_string_via_into() {
2629 struct ResolvedAtCall(String);
2633 impl From<ResolvedAtCall> for crate::signal::Prop<String> {
2634 fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
2635 crate::signal::Prop::Static(v.0)
2636 }
2637 }
2638 let mut tree = WidgetTree::new();
2639 let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
2640 tree.layout(SizeProposal::exact(50.0, 50.0));
2641 assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
2642 }
2643
2644 #[test]
2646 fn access_exclude_subtree_prunes_children_from_at_tree() {
2647 let mut tree = WidgetTree::new();
2648 let inner1 = tree.add(FillWidget::new().label("A"));
2649 let inner2 = tree.add(FillWidget::new().label("B"));
2650 let outer = tree.add(
2651 StackWidget::new()
2652 .add_child(inner1)
2653 .add_child(inner2)
2654 .access_label_literal("Section")
2658 .access_exclude_subtree(),
2659 );
2660 tree.layout(SizeProposal::exact(100.0, 50.0));
2661 let update = tree.sync_accessibility();
2662 assert!(find_node(&update, outer).is_some());
2664 assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
2665 assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
2666 let outer_node = find_node(&update, outer).unwrap();
2667 assert!(
2668 outer_node.children().is_empty(),
2669 "outer should have no AT children when excluded"
2670 );
2671 }
2672
2673 #[test]
2675 fn access_merge_subtree_concatenates_descendant_labels() {
2676 let mut tree = WidgetTree::new();
2677 let title = tree.add(FillWidget::new().label("Title"));
2678 let subtitle = tree.add(FillWidget::new().label("Subtitle"));
2679 let card = tree.add(
2680 StackWidget::new()
2681 .add_child(title)
2682 .add_child(subtitle)
2683 .access_merge_subtree(),
2684 );
2685 tree.layout(SizeProposal::exact(100.0, 50.0));
2686 assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
2688 let update = tree.sync_accessibility();
2689 assert!(find_node(&update, title).is_none());
2690 assert!(find_node(&update, subtitle).is_none());
2691 }
2692
2693 #[test]
2695 fn access_merge_subtree_unions_actions() {
2696 let mut tree = WidgetTree::new();
2697 let click_a = tree.add(ClickableWidget);
2698 let click_b = tree.add(ClickableWidget);
2699 let card = tree.add(
2700 StackWidget::new()
2701 .add_child(click_a)
2702 .add_child(click_b)
2703 .access_merge_subtree(),
2704 );
2705 tree.layout(SizeProposal::exact(100.0, 50.0));
2706 let actions = tree.accessibility_node(card).actions().to_vec();
2707 let click_count = actions.iter().filter(|a| **a == Action::Click).count();
2708 assert_eq!(
2709 click_count, 1,
2710 "Click should be present exactly once after merge (deduplicated)"
2711 );
2712 }
2713
2714 #[test]
2724 fn access_merge_subtree_first_nonempty_value_wins() {
2725 #[derive(Debug)]
2726 struct ValueWidget(&'static str);
2727 impl Widget for ValueWidget {
2728 fn layout_response(
2729 &self,
2730 proposal: SizeProposal,
2731 _ctx: &LayoutContext,
2732 ) -> crate::widget::LayoutResponse {
2733 proposal.resolve(0.0, 0.0).into()
2734 }
2735 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2736 builder.set_role(Role::Slider);
2737 builder.set_value(self.0);
2738 }
2739 }
2740 let mut tree = WidgetTree::new();
2741 let v1 = tree.add(ValueWidget("first"));
2742 let v2 = tree.add(ValueWidget("second"));
2743 let card = tree.add(
2744 StackWidget::new()
2745 .add_child(v1)
2746 .add_child(v2)
2747 .access_merge_subtree(),
2748 );
2749 tree.layout(SizeProposal::exact(100.0, 50.0));
2750 assert_eq!(tree.text_value(card), Some("first".to_string()));
2751 }
2752
2753 #[test]
2755 fn access_exclude_inside_merge() {
2756 let mut tree = WidgetTree::new();
2757 let visible = tree.add(FillWidget::new().label("VISIBLE"));
2758 let pruned = tree.add(FillWidget::new().label("PRUNED"));
2759 let inner_excluded = tree.add(
2760 StackWidget::new()
2761 .add_child(pruned)
2762 .access_exclude_subtree(),
2763 );
2764 let card = tree.add(
2765 StackWidget::new()
2766 .add_child(visible)
2767 .add_child(inner_excluded)
2768 .access_merge_subtree(),
2769 );
2770 tree.layout(SizeProposal::exact(100.0, 50.0));
2771 let merged = tree.text_content(card).unwrap_or_default();
2772 assert!(merged.contains("VISIBLE"));
2775 assert!(
2776 !merged.contains("PRUNED"),
2777 "excluded subtree should not contribute to merge"
2778 );
2779 }
2780
2781 #[test]
2783 fn access_merge_inside_merge() {
2784 let mut tree = WidgetTree::new();
2785 let inner_a = tree.add(FillWidget::new().label("a"));
2786 let inner_b = tree.add(FillWidget::new().label("b"));
2787 let inner_card = tree.add(
2788 StackWidget::new()
2789 .add_child(inner_a)
2790 .add_child(inner_b)
2791 .access_merge_subtree(),
2792 );
2793 let outer_extra = tree.add(FillWidget::new().label("X"));
2794 let outer = tree.add(
2795 StackWidget::new()
2796 .add_child(inner_card)
2797 .add_child(outer_extra)
2798 .access_merge_subtree(),
2799 );
2800 tree.layout(SizeProposal::exact(200.0, 100.0));
2801 let merged = tree.text_content(outer).unwrap_or_default();
2806 assert!(merged.contains("a"));
2807 assert!(merged.contains("b"));
2808 assert!(merged.contains("X"));
2809 let update = tree.sync_accessibility();
2811 assert!(find_node(&update, inner_a).is_none());
2812 assert!(find_node(&update, inner_b).is_none());
2813 assert!(find_node(&update, inner_card).is_none());
2814 assert!(find_node(&update, outer_extra).is_none());
2815 }
2816
2817 #[test]
2819 fn access_customize_runs_last() {
2820 let mut tree = WidgetTree::new();
2821 let id = tree.add(
2822 FillWidget::new()
2823 .access_label_literal("A")
2824 .access_customize(|b| b.set_name("B")),
2825 );
2826 tree.layout(SizeProposal::exact(50.0, 50.0));
2827 assert_eq!(tree.accessibility_node(id).name(), Some("B"));
2828 }
2829
2830 #[test]
2832 fn access_customize_can_reach_inner_mut() {
2833 let mut tree = WidgetTree::new();
2834 let id = tree.add(FillWidget::new().access_customize(|b| {
2835 b.inner_mut().set_author_id("from-customize");
2836 }));
2837 tree.layout(SizeProposal::exact(50.0, 50.0));
2838 let update = tree.sync_accessibility();
2839 let node = find_node(&update, id).unwrap();
2840 assert_eq!(node.author_id(), Some("from-customize"));
2841 }
2842
2843 #[test]
2845 fn access_overrides_zero_cost_when_unused() {
2846 use std::mem::size_of;
2853 assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
2854 assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
2855 }
2856
2857 #[test]
2858 fn accessibility_children_overrides_at_reading_order() {
2859 #[derive(Debug)]
2864 struct NamedLeaf(&'static str);
2865 impl Widget for NamedLeaf {
2866 fn layout_response(
2867 &self,
2868 proposal: SizeProposal,
2869 _ctx: &LayoutContext,
2870 ) -> crate::widget::LayoutResponse {
2871 proposal.resolve(10.0, 10.0).into()
2872 }
2873 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2874 builder.set_role(accesskit::Role::Button);
2878 builder.set_name(self.0);
2879 }
2880 }
2881
2882 #[derive(Debug, Default)]
2883 struct ReorderContainer {
2884 a: Option<WidgetId>,
2885 b: Option<WidgetId>,
2886 }
2887 impl Widget for ReorderContainer {
2888 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2889 let a = ctx.add(NamedLeaf("A"));
2890 let b = ctx.add(NamedLeaf("B"));
2891 self.a = Some(a);
2892 self.b = Some(b);
2893 vec![a, b]
2894 }
2895 fn layout_response(
2896 &self,
2897 proposal: SizeProposal,
2898 _ctx: &LayoutContext,
2899 ) -> crate::widget::LayoutResponse {
2900 proposal.resolve(100.0, 100.0).into()
2901 }
2902 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2903 builder.set_role(accesskit::Role::Group);
2906 builder.set_name("Container");
2907 }
2908 fn children(&self) -> Vec<WidgetId> {
2909 [self.a, self.b].into_iter().flatten().collect()
2910 }
2911 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2912 Some([self.b, self.a].into_iter().flatten().collect())
2914 }
2915 }
2916
2917 let mut tree = WidgetTree::new();
2918 let container = tree.add(ReorderContainer::default());
2919 tree.layout(SizeProposal::exact(100.0, 100.0));
2920 let update = tree.sync_accessibility();
2921
2922 let a_nid = update
2923 .nodes
2924 .iter()
2925 .find(|(_, n)| n.label() == Some("A"))
2926 .map(|(id, _)| *id)
2927 .expect("A node present");
2928 let b_nid = update
2929 .nodes
2930 .iter()
2931 .find(|(_, n)| n.label() == Some("B"))
2932 .map(|(id, _)| *id)
2933 .expect("B node present");
2934 let cnid = crate::accessibility::widget_id_to_node_id(container);
2935 let cnode = update
2936 .nodes
2937 .iter()
2938 .find(|(id, _)| *id == cnid)
2939 .map(|(_, n)| n)
2940 .expect("container node present");
2941 let kids = cnode.children();
2942 let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
2943 let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
2944 assert!(
2945 pb < pa,
2946 "accessibility_children() must set AT order B-before-A; got {kids:?}"
2947 );
2948 }
2949}