1use super::*;
5
6use crate::accessibility::{AccessNodeBuilder, AccessibilityInfo};
7
8fn to_accesskit_rect(rect: teksilo_canvas::Rect) -> accesskit::Rect {
11 accesskit::Rect {
12 x0: rect.x as f64,
13 y0: rect.y as f64,
14 x1: (rect.x + rect.width) as f64,
15 y1: (rect.y + rect.height) as f64,
16 }
17}
18
19impl WidgetTree {
20 pub fn sync_accessibility(&mut self) -> accesskit::TreeUpdate {
31 if self.a11y_update_requested.replace(false) {
36 self.a11y_dirty = true;
37 }
38 let current_shortcut_version = self.shortcut_registry.version().get();
46 if current_shortcut_version != self.last_synced_shortcut_version {
47 self.a11y_dirty = true;
48 self.last_synced_shortcut_version = current_shortcut_version;
49 }
50
51 let current_locale = self.locale_signal.get();
60 if current_locale != self.last_synced_locale {
61 self.a11y_dirty = true;
62 self.last_synced_locale = current_locale;
63 }
64
65 let announcers_busy = self.step_announcers();
73
74 if !self.a11y_dirty
75 && let Some(cached) = &self.cached_a11y
76 {
77 let _ = cached;
90 self.patch_accessibility_bounds();
91 return self
92 .cached_a11y
93 .as_ref()
94 .expect("the cache was present a line ago")
95 .clone();
96 }
97
98 let (update, parents, local_bounds) = self.build_accessibility_tree();
99 self.collect_announcements(&update);
105 let content_changed = self.cached_a11y.as_ref() != Some(&update);
112 self.cached_a11y = Some(update.clone());
113 self.synthetic_parent_map = parents;
114 self.synthetic_local_bounds = local_bounds;
115 self.a11y_dirty = false;
116 self.arena.take_a11y_moved();
119 self.arena.take_a11y_resized();
120 self.a11y_walk_generation = self.a11y_walk_generation.saturating_add(1);
121 if content_changed {
122 self.at_version.set(self.at_version.get().saturating_add(1));
125 }
126 if announcers_busy {
127 self.request_accessibility_update();
128 self.request_frame();
129 }
130 update
131 }
132
133 pub fn a11y_walk_generation(&self) -> u64 {
139 self.a11y_walk_generation
140 }
141
142 fn patch_accessibility_bounds(&mut self) {
154 let moved = self.arena.take_a11y_moved();
155 if moved.is_empty() {
156 return;
157 }
158 let Some(cached) = self.cached_a11y.as_mut() else {
159 return;
160 };
161 let index: std::collections::HashMap<accesskit::NodeId, usize> = cached
162 .nodes
163 .iter()
164 .enumerate()
165 .map(|(i, (id, _))| (*id, i))
166 .collect();
167
168 for &id in moved.keys() {
169 let bounds = self.arena.bounds(id);
170 if let Some(&slot) = index.get(&crate::accessibility::widget_id_to_node_id(id)) {
171 cached.nodes[slot].1.set_bounds(to_accesskit_rect(bounds));
172 }
173 }
174
175 for (&syn_id, &owner) in &self.synthetic_parent_map {
181 let Some(delta) = moved.get(&owner) else {
182 continue;
183 };
184 let Some(&slot) = index.get(&syn_id) else {
185 continue;
186 };
187 let node = &mut cached.nodes[slot].1;
188 if let Some(local) = self.synthetic_local_bounds.get(&syn_id) {
189 let origin = self.arena.bounds(owner).origin();
190 node.set_bounds(to_accesskit_rect(teksilo_canvas::Rect::new(
191 origin.x + local.x,
192 origin.y + local.y,
193 local.width,
194 local.height,
195 )));
196 } else if let Some(rect) = node.bounds() {
197 node.set_bounds(accesskit::Rect {
198 x0: rect.x0 + delta.x as f64,
199 y0: rect.y0 + delta.y as f64,
200 x1: rect.x1 + delta.x as f64,
201 y1: rect.y1 + delta.y as f64,
202 });
203 }
204 }
205 }
206
207 pub fn accessibility_tree_snapshot(&self) -> accesskit::TreeUpdate {
221 self.build_accessibility_tree().0
222 }
223
224 fn step_announcers(&mut self) -> bool {
227 let polite = self.announcer_polite.step();
231 let assertive = self.announcer_assertive.step();
232 if polite || assertive {
233 self.a11y_dirty = true;
234 }
235 polite || assertive
236 }
237
238 fn collect_announcements(&mut self, update: &accesskit::TreeUpdate) {
244 use accesskit::Live;
245 let mut seen: std::collections::HashSet<accesskit::NodeId> =
246 std::collections::HashSet::with_capacity(self.automation_last_text.len());
247 for (node_id, node) in &update.nodes {
248 let assertive = match node.live() {
249 Some(Live::Polite) => false,
250 Some(Live::Assertive) => true,
251 _ => continue,
253 };
254 let text = node
257 .value()
258 .or_else(|| node.label())
259 .unwrap_or("")
260 .trim()
261 .to_string();
262 if text.is_empty() {
263 self.automation_last_text.remove(node_id);
268 continue;
269 }
270 seen.insert(*node_id);
271 let changed = self
272 .automation_last_text
273 .get(node_id)
274 .map(|prev| prev != &text)
275 .unwrap_or(true);
276 if !changed {
277 continue;
278 }
279 self.automation_last_text.insert(*node_id, text.clone());
280 self.automation_announce_seq = self.automation_announce_seq.saturating_add(1);
281 if self.automation_announcements.len() >= super::AUTOMATION_ANNOUNCE_CAP {
282 self.automation_announcements.pop_front();
283 }
284 self.automation_announcements
285 .push_back(crate::accessibility::Announcement {
286 seq: self.automation_announce_seq,
287 text,
288 assertive,
289 });
290 }
291 self.automation_last_text.retain(|k, _| seen.contains(k));
294 }
295
296 pub fn dispatch_access_action(
319 &mut self,
320 node_id: accesskit::NodeId,
321 action: accesskit::Action,
322 data: Option<accesskit::ActionData>,
323 ops: &mut dyn crate::window::WindowOps,
324 ) -> bool {
325 let target = crate::accessibility::node_id_to_widget_id_maybe(node_id)
326 .or_else(|| self.widget_for_synthetic(node_id));
327 let event = crate::event::WidgetEvent::AccessAction {
328 action,
329 target,
330 target_node: node_id,
331 data,
332 };
333 self.access_action_handled = false;
334 self.dispatch_event_with_ops(event, ops);
335 self.access_action_handled
336 }
337
338 #[allow(clippy::type_complexity)]
339 fn build_accessibility_tree(
340 &self,
341 ) -> (
342 accesskit::TreeUpdate,
343 std::collections::HashMap<accesskit::NodeId, WidgetId>,
344 std::collections::HashMap<accesskit::NodeId, teksilo_canvas::Rect>,
345 ) {
346 use crate::accessibility::{root_node_id, widget_id_to_node_id};
347
348 let roots = self.arena.roots();
349 let mut nodes: Vec<(accesskit::NodeId, accesskit::Node)> = Vec::new();
350 let mut synthetic_parents: std::collections::HashMap<accesskit::NodeId, WidgetId> =
351 std::collections::HashMap::new();
352 let mut seen_children: std::collections::HashMap<accesskit::NodeId, WidgetId> =
357 std::collections::HashMap::new();
358 let mut local_bounds: std::collections::HashMap<accesskit::NodeId, teksilo_canvas::Rect> =
362 std::collections::HashMap::new();
363
364 let mut root = accesskit::Node::new(accesskit::Role::Window);
365 if let Some(locale) = self.locale_signal.get() {
372 root.set_language(locale);
373 }
374 for &root_id in &roots {
375 if self.arena.is_active(root_id) {
376 let child_nid = widget_id_to_node_id(root_id);
377 if seen_children.insert(child_nid, root_id).is_none() {
378 root.push_child(child_nid);
379 } else {
380 eprintln!(
381 "Teksilo bug: duplicate accessibility child {:?} in Window root — \
382 already claimed by another parent. Please file a bug report.",
383 root_id
384 );
385 }
386 }
387 }
388 let announcer_nodes = [
394 self.announcer_polite.node(),
395 self.announcer_assertive.node(),
396 ];
397 for (id, _) in &announcer_nodes {
398 root.push_child(*id);
399 }
400
401 nodes.push((root_node_id(), root));
402 nodes.extend(announcer_nodes);
403
404 let mut handled_tooltips: std::collections::HashSet<WidgetId> =
408 std::collections::HashSet::new();
409 for &root_id in &roots {
410 self.build_accessibility_recursive(
411 root_id,
412 &mut nodes,
413 &mut synthetic_parents,
414 &mut local_bounds,
415 &mut seen_children,
416 &mut handled_tooltips,
417 );
418 }
419
420 let focus = self
421 .focused
422 .filter(|id| self.arena.is_active(*id))
423 .map(widget_id_to_node_id)
424 .unwrap_or_else(root_node_id);
425
426 {
442 use accesskit::Role;
443 use std::collections::HashMap;
444
445 fn names_from_content(role: Role) -> bool {
449 matches!(role, Role::TreeItem | Role::ListBoxOption)
450 }
451
452 let index: HashMap<accesskit::NodeId, usize> = nodes
453 .iter()
454 .enumerate()
455 .map(|(i, (nid, _))| (*nid, i))
456 .collect();
457
458 let mut hoisted: Vec<(usize, String)> = Vec::new();
459 for (i, (_, node)) in nodes.iter().enumerate() {
460 if !names_from_content(node.role()) || node.label().is_some() {
461 continue;
462 }
463 let mut stack: Vec<accesskit::NodeId> =
465 node.children().iter().rev().copied().collect();
466 while let Some(nid) = stack.pop() {
467 let Some(&j) = index.get(&nid) else { continue };
468 let descendant = &nodes[j].1;
469 if names_from_content(descendant.role()) {
470 continue;
471 }
472 match descendant.label() {
473 Some(label) if !label.trim().is_empty() => {
474 hoisted.push((i, label.to_string()));
475 break;
476 }
477 _ => stack.extend(descendant.children().iter().rev().copied()),
478 }
479 }
480 }
481 for (i, label) in hoisted {
482 nodes[i].1.set_label(label);
483 }
484 }
485
486 {
502 use std::collections::{HashMap, HashSet};
503
504 let mut relation_targets: HashSet<accesskit::NodeId> = HashSet::new();
507 for (_, node) in &nodes {
508 relation_targets.extend(node.controls());
509 relation_targets.extend(node.described_by());
510 relation_targets.extend(node.labelled_by());
511 }
512
513 let prunable: HashSet<accesskit::NodeId> = nodes
514 .iter()
515 .filter(|(nid, node)| {
516 *nid != root_node_id()
517 && *nid != focus
518 && !relation_targets.contains(nid)
519 && is_presentational_container(node)
520 })
521 .map(|(nid, _)| *nid)
522 .collect();
523
524 if !prunable.is_empty() {
525 let children_map: HashMap<accesskit::NodeId, Vec<accesskit::NodeId>> = nodes
527 .iter()
528 .map(|(nid, node)| (*nid, node.children().to_vec()))
529 .collect();
530
531 fn resolve(
536 nid: accesskit::NodeId,
537 children_map: &HashMap<accesskit::NodeId, Vec<accesskit::NodeId>>,
538 prunable: &HashSet<accesskit::NodeId>,
539 memo: &mut HashMap<accesskit::NodeId, Vec<accesskit::NodeId>>,
540 ) -> Vec<accesskit::NodeId> {
541 if let Some(cached) = memo.get(&nid) {
542 return cached.clone();
543 }
544 let mut out = Vec::new();
545 if let Some(kids) = children_map.get(&nid) {
546 for &c in kids {
547 if prunable.contains(&c) {
548 out.extend(resolve(c, children_map, prunable, memo));
549 } else {
550 out.push(c);
551 }
552 }
553 }
554 memo.insert(nid, out.clone());
555 out
556 }
557
558 let mut memo: HashMap<accesskit::NodeId, Vec<accesskit::NodeId>> = HashMap::new();
559 for (nid, node) in &mut nodes {
560 if prunable.contains(nid) {
561 continue;
562 }
563 let resolved = resolve(*nid, &children_map, &prunable, &mut memo);
564 if children_map.get(nid) != Some(&resolved) {
565 node.set_children(resolved);
566 }
567 }
568 nodes.retain(|(nid, _)| !prunable.contains(nid));
569 }
570 }
571
572 let emitted: std::collections::HashSet<accesskit::NodeId> =
584 nodes.iter().map(|(id, _)| *id).collect();
585 for (_, node) in &mut nodes {
586 let controlled: Vec<_> = node
587 .controls()
588 .iter()
589 .filter(|id| emitted.contains(*id))
590 .copied()
591 .collect();
592 if controlled.len() != node.controls().len() {
593 node.set_controls(controlled);
594 }
595 let described: Vec<_> = node
596 .described_by()
597 .iter()
598 .filter(|id| emitted.contains(*id))
599 .copied()
600 .collect();
601 if described.len() != node.described_by().len() {
602 node.set_described_by(described);
603 }
604 let labelled: Vec<_> = node
605 .labelled_by()
606 .iter()
607 .filter(|id| emitted.contains(*id))
608 .copied()
609 .collect();
610 if labelled.len() != node.labelled_by().len() {
611 node.set_labelled_by(labelled);
612 }
613 }
614
615 (
616 accesskit::TreeUpdate {
617 nodes,
618 tree: Some(accesskit::TreeInfo::new(root_node_id())),
619 tree_id: accesskit::TreeId::ROOT,
620 focus,
621 },
622 synthetic_parents,
623 local_bounds,
624 )
625 }
626
627 pub fn widget_for_synthetic(&self, node_id: accesskit::NodeId) -> Option<WidgetId> {
635 self.synthetic_parent_map.get(&node_id).copied()
636 }
637
638 #[allow(clippy::too_many_arguments)]
639 fn build_accessibility_recursive(
640 &self,
641 id: WidgetId,
642 nodes: &mut Vec<(accesskit::NodeId, accesskit::Node)>,
643 synthetic_parents: &mut std::collections::HashMap<accesskit::NodeId, WidgetId>,
644 local_bounds: &mut std::collections::HashMap<accesskit::NodeId, teksilo_canvas::Rect>,
645 seen_children: &mut std::collections::HashMap<accesskit::NodeId, WidgetId>,
646 handled_tooltips: &mut std::collections::HashSet<WidgetId>,
652 ) {
653 use crate::accessibility::widget_id_to_node_id;
654 use crate::widget_builder::AccessSubtreeMode;
655
656 if !self.arena.is_active(id) {
657 return;
658 }
659
660 let node = self.arena.get(id).expect("widget id is active in arena");
661 let mut builder = AccessNodeBuilder::for_widget(id);
662 node.widget.accessibility(&mut builder);
663 self.announce_context_menu(id, &mut builder);
667 self.announce_focusable(id, &mut builder);
668
669 if let Some(ov) = node.access_overrides.as_deref() {
673 ov.apply(&mut builder);
674 if let Some(ref id) = ov.shortcut_id
682 && let Some(eff) = self.shortcut_registry.effective(id)
683 && let Some(ks) = eff.primary
684 {
685 builder.set_keyboard_shortcut(ks.to_string());
686 }
687 }
688
689 let subtree_mode = node.access_subtree;
690 let at_children_owned;
698 let children: &[WidgetId] = match node.widget.accessibility_children() {
699 Some(v) => {
700 at_children_owned = v;
701 &at_children_owned
702 }
703 None => self.arena.children(id),
704 };
705
706 match subtree_mode {
712 AccessSubtreeMode::Inherit => {
713 for &child_id in children {
714 if self.arena.is_active(child_id) {
715 let child_nid = widget_id_to_node_id(child_id);
716 if self.ancestor_chain_redirects(id, child_id) {
733 seen_children.insert(child_nid, id);
736 continue;
737 }
738 if let Some(&prior_parent) = seen_children.get(&child_nid) {
739 eprintln!(
740 "Teksilo bug: duplicate accessibility child {:?}: \
741 first claimed by parent {:?}, now also claimed by {:?}. \
742 Please file a bug report.",
743 child_id, prior_parent, id
744 );
745 continue;
746 }
747 seen_children.insert(child_nid, id);
748 builder.inner_mut().push_child(child_nid);
749 }
750 }
751 }
752 AccessSubtreeMode::Exclude => {
753 }
755 AccessSubtreeMode::Merge => {
756 merge_descendants_into(&mut builder, id, &self.arena);
757 }
758 }
759
760 let bounds = self.arena.bounds(id);
761 builder.inner_mut().set_bounds(to_accesskit_rect(bounds));
762
763 let force_clear_disabled =
768 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
769 if !self.arena.is_enabled(id) && !force_clear_disabled {
770 builder.set_disabled();
771 }
772
773 if let Some(content_id) =
793 self.tooltip_description_target(id, handled_tooltips, &mut builder)
794 {
795 if self
796 .tooltips
797 .iter()
798 .any(|t| t.content_id == content_id && t.overlay_id.is_some())
799 {
800 builder
803 .inner_mut()
804 .push_described_by(widget_id_to_node_id(self.tooltip_content_node(content_id)));
805 handled_tooltips.insert(content_id);
806 } else if let Some(text) = self.tooltip_access_description(content_id) {
807 builder.set_description(text);
815 handled_tooltips.insert(content_id);
816 }
817 }
818
819 let (node_id, ak_node, synthetic_children, synthetic_local_bounds) = builder.build(id);
823 nodes.push((node_id, ak_node));
824 local_bounds.extend(synthetic_local_bounds);
825 for (syn_id, syn_node) in synthetic_children {
831 nodes.push((syn_id, syn_node));
832 synthetic_parents.insert(syn_id, id);
833 }
834
835 if matches!(subtree_mode, AccessSubtreeMode::Inherit) {
838 for &child_id in children {
839 self.build_accessibility_recursive(
840 child_id,
841 nodes,
842 synthetic_parents,
843 local_bounds,
844 seen_children,
845 handled_tooltips,
846 );
847 }
848 }
849 }
850
851 fn ancestor_chain_redirects(&self, parent_id: WidgetId, descendant: WidgetId) -> bool {
868 let mut current = Some(parent_id);
869 while let Some(curr) = current {
870 let Some(curr_node) = self.arena.get(curr) else {
871 break;
872 };
873 if curr_node.widget.wants_descendant_redirects()
874 && curr_node
875 .widget
876 .a11y_redirect_descendant(curr, descendant)
877 .is_some()
878 {
879 return true;
880 }
881 current = self.arena.parent(curr);
882 }
883 false
884 }
885
886 fn tooltip_description_target(
918 &self,
919 id: WidgetId,
920 handled: &std::collections::HashSet<WidgetId>,
921 builder: &mut AccessNodeBuilder,
922 ) -> Option<WidgetId> {
923 let mut claims = self
924 .tooltips
925 .iter()
926 .filter(|t| t.description_owner_id == id && !handled.contains(&t.content_id));
927 if let (Some(only), None) = (claims.next(), claims.next())
930 && only.anchor_id != id
931 && !is_presentational_container(builder.inner_mut())
937 && builder.inner_mut().description().is_none()
942 {
943 return Some(only.content_id);
944 }
945 self.tooltips
946 .iter()
947 .find(|t| t.anchor_id == id && !handled.contains(&t.content_id))
948 .map(|t| t.content_id)
949 }
950
951 fn announce_focusable(&self, id: WidgetId, builder: &mut AccessNodeBuilder) {
995 if self
996 .arena
997 .get(id)
998 .is_some_and(|node| self.is_node_focusable(node))
999 && !builder.actions().contains(&accesskit::Action::Focus)
1000 {
1001 builder.add_action(accesskit::Action::Focus);
1002 }
1003 }
1004
1005 fn announce_context_menu(&self, id: WidgetId, builder: &mut AccessNodeBuilder) {
1006 if self
1007 .arena
1008 .get(id)
1009 .is_some_and(|node| node.context_menu_factory.is_some())
1010 && !builder
1011 .actions()
1012 .contains(&accesskit::Action::ShowContextMenu)
1013 {
1014 builder.add_action(accesskit::Action::ShowContextMenu);
1015 }
1016 }
1017
1018 pub(crate) fn advertises_focus_action(&self, id: WidgetId) -> bool {
1035 self.arena.get(id).is_some()
1036 && self
1037 .build_overridden_builder(id)
1038 .actions()
1039 .contains(&accesskit::Action::Focus)
1040 }
1041
1042 fn build_overridden_builder(&self, id: WidgetId) -> AccessNodeBuilder {
1043 use crate::widget_builder::AccessSubtreeMode;
1044 let node = self.arena.get(id).expect("widget id is active in arena");
1045 let mut builder = AccessNodeBuilder::for_name_probe(id);
1049 node.widget.accessibility(&mut builder);
1050 self.announce_context_menu(id, &mut builder);
1051 self.announce_focusable(id, &mut builder);
1052 if let Some(ov) = node.access_overrides.as_deref() {
1053 ov.apply(&mut builder);
1054 if let Some(ref sid) = ov.shortcut_id
1057 && let Some(eff) = self.shortcut_registry.effective(sid)
1058 && let Some(ks) = eff.primary
1059 {
1060 builder.set_keyboard_shortcut(ks.to_string());
1061 }
1062 }
1063 if node.access_subtree == AccessSubtreeMode::Merge {
1064 merge_descendants_into(&mut builder, id, &self.arena);
1065 }
1066 builder
1067 }
1068
1069 pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
1087 self.arena
1088 .get(content_id)
1089 .and_then(|node| node.widget.as_any())
1090 .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
1091 .and_then(|d| d.materialized_child())
1092 .unwrap_or(content_id)
1093 }
1094
1095 pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
1096 let content_id = self.tooltip_content_node(content_id);
1097 let node = self.arena.get(content_id)?;
1098 let mut probe = AccessNodeBuilder::for_name_probe(content_id);
1099 node.widget.accessibility(&mut probe);
1100 if let Some(ov) = node.access_overrides.as_deref() {
1101 ov.apply(&mut probe);
1102 }
1103 probe
1104 .name()
1105 .map(str::trim)
1106 .filter(|name| !name.is_empty())
1107 .map(str::to_owned)
1108 }
1109
1110 pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
1111 let node = self.arena.get(id).expect("widget id is active in arena");
1112 let builder = self.build_overridden_builder(id);
1113 let role = builder.role();
1114 let name = builder.name().map(|s| s.to_string());
1115 let actions = builder.actions().to_vec();
1116 let mut info = AccessibilityInfo::new(role, name, actions);
1117 if let Some(toggled) = builder.toggled() {
1118 info = info.with_toggled(toggled);
1119 }
1120 if let Some(expanded) = builder.expanded() {
1121 info = info.with_expanded(expanded);
1122 }
1123 if let Some(selected) = builder.selected() {
1124 info = info.with_selected(selected);
1125 }
1126 let force_clear_disabled =
1130 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
1131 let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
1132 let disabled_override =
1137 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(true);
1138 if disabled_arena || disabled_override {
1139 info = info.with_disabled(true);
1140 }
1141 if builder.is_hidden() {
1142 info = info.with_hidden(true);
1143 }
1144 info
1145 }
1146
1147 pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
1148 self.arena
1149 .active_ids_iter()
1150 .find(|&id| self.build_overridden_builder(id).role() == role)
1151 }
1152
1153 pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
1154 self.arena
1155 .active_ids_iter()
1156 .find(|&id| self.build_overridden_builder(id).name() == Some(label))
1157 }
1158
1159 pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
1160 self.arena.active_ids_iter().find(|&id| {
1161 self.build_overridden_builder(id)
1162 .actions()
1163 .contains(&action)
1164 })
1165 }
1166
1167 pub fn text_content(&self, id: WidgetId) -> Option<String> {
1171 self.arena.get(id)?;
1172 self.build_overridden_builder(id)
1173 .name()
1174 .map(|s| s.to_string())
1175 }
1176
1177 pub fn text_value(&self, id: WidgetId) -> Option<String> {
1181 self.arena.get(id)?;
1182 self.build_overridden_builder(id)
1183 .value()
1184 .map(|s| s.to_string())
1185 }
1186}
1187
1188fn is_presentational_container(node: &accesskit::Node) -> bool {
1202 use accesskit::Role;
1203 if !matches!(node.role(), Role::GenericContainer | Role::Unknown) {
1204 return false;
1205 }
1206 let mut probe = node.clone();
1216 probe.clear_children();
1217 probe.clear_bounds();
1218 probe.clear_disabled();
1219 format!("{probe:?}") == format!("{:?}", accesskit::Node::new(node.role()))
1220}
1221
1222fn merge_descendants_into(
1244 parent_builder: &mut AccessNodeBuilder,
1245 parent_id: WidgetId,
1246 arena: &crate::arena::WidgetArena,
1247) {
1248 let mut acc = MergeAccumulator::default();
1249 for &child in arena.children(parent_id) {
1250 merge_collect_recursive(child, arena, &mut acc);
1251 }
1252 acc.flush_into(parent_builder);
1253}
1254
1255fn merge_collect_recursive(
1256 id: WidgetId,
1257 arena: &crate::arena::WidgetArena,
1258 acc: &mut MergeAccumulator,
1259) {
1260 use crate::widget_builder::AccessSubtreeMode;
1261
1262 if !arena.is_active(id) {
1263 return;
1264 }
1265 let Some(node) = arena.get(id) else {
1266 return;
1267 };
1268
1269 let mut tmp = AccessNodeBuilder::for_name_probe(id);
1278 node.widget.accessibility(&mut tmp);
1279 if let Some(ov) = node.access_overrides.as_deref() {
1280 ov.apply(&mut tmp);
1281 }
1282 if matches!(node.access_subtree, AccessSubtreeMode::Merge) {
1287 merge_descendants_into(&mut tmp, id, arena);
1288 }
1289 if !tmp.is_hidden() {
1293 acc.absorb(&tmp);
1294 }
1295
1296 match node.access_subtree {
1297 AccessSubtreeMode::Exclude => {
1298 }
1300 AccessSubtreeMode::Merge => {
1301 }
1306 AccessSubtreeMode::Inherit => {
1307 for &grandchild in arena.children(id) {
1308 merge_collect_recursive(grandchild, arena, acc);
1309 }
1310 }
1311 }
1312}
1313
1314#[derive(Default)]
1328struct MergeAccumulator {
1329 label_parts: Vec<String>,
1330 value: Option<String>,
1332 actions: Vec<accesskit::Action>,
1333}
1334
1335impl MergeAccumulator {
1336 fn absorb(&mut self, src: &AccessNodeBuilder) {
1337 if let Some(name) = src.name()
1338 && !name.is_empty()
1339 {
1340 self.label_parts.push(name.to_string());
1341 }
1342 if let Some(value) = src.value()
1343 && self.value.is_none()
1344 && !value.is_empty()
1345 {
1346 self.value = Some(value.to_string());
1347 }
1348 for &action in src.actions() {
1349 if !self.actions.contains(&action) {
1350 self.actions.push(action);
1351 }
1352 }
1353 }
1354
1355 fn flush_into(self, dst: &mut AccessNodeBuilder) {
1356 if !self.label_parts.is_empty() {
1359 let existing = dst.name().map(|s| s.to_string());
1360 let merged = match existing {
1361 Some(e) if !e.is_empty() => {
1362 let mut s = e;
1363 for part in self.label_parts {
1364 s.push(' ');
1365 s.push_str(&part);
1366 }
1367 s
1368 }
1369 _ => self.label_parts.join(" "),
1370 };
1371 dst.set_name(merged);
1372 }
1373 if let Some(v) = self.value {
1374 if dst.value().is_none() {
1378 dst.set_value(v);
1379 }
1380 }
1381 for action in self.actions {
1382 if !dst.actions().contains(&action) {
1385 dst.add_action(action);
1386 }
1387 }
1388 }
1389}
1390
1391#[cfg(test)]
1392pub(crate) mod test_helpers {
1393 pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
1399 accesskit_consumer::Tree::new(update.clone(), false);
1400 }
1401
1402 pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
1411 let emitted: std::collections::HashSet<accesskit::NodeId> =
1412 update.nodes.iter().map(|(id, _)| *id).collect();
1413 for (parent_id, node) in &update.nodes {
1414 for &target in node.controls() {
1415 assert!(
1416 emitted.contains(&target),
1417 "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
1418 );
1419 }
1420 for &target in node.described_by() {
1421 assert!(
1422 emitted.contains(&target),
1423 "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
1424 );
1425 }
1426 for &target in node.labelled_by() {
1427 assert!(
1428 emitted.contains(&target),
1429 "node {parent_id:?} has labelled_by() → {target:?} which is absent from the tree"
1430 );
1431 }
1432 if let Some(target) = node.next_on_line() {
1433 assert!(
1434 emitted.contains(&target),
1435 "node {parent_id:?} has next_on_line() → {target:?} which is absent from the tree"
1436 );
1437 }
1438 if let Some(target) = node.previous_on_line() {
1439 assert!(
1440 emitted.contains(&target),
1441 "node {parent_id:?} has previous_on_line() → {target:?} which is absent from the tree"
1442 );
1443 }
1444 }
1445 }
1446
1447 #[allow(dead_code)]
1449 pub(crate) fn nodes_with_role(
1450 update: &accesskit::TreeUpdate,
1451 role: accesskit::Role,
1452 ) -> Vec<accesskit::NodeId> {
1453 update
1454 .nodes
1455 .iter()
1456 .filter(|(_, node)| node.role() == role)
1457 .map(|(id, _)| *id)
1458 .collect()
1459 }
1460}
1461
1462#[cfg(test)]
1463mod tests {
1464 use super::test_helpers::*;
1465 use super::*;
1466 use crate::test_widgets::{FillWidget, StackWidget};
1467
1468 const ANNOUNCER_NODES: usize = 2;
1472
1473 fn announced_by_consumer(update: &accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
1489 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
1490 let state = consumer.state();
1491 let mut found = Vec::new();
1492 let mut stack = vec![state.root()];
1493 while let Some(node) = stack.pop() {
1494 if node.live() != accesskit::Live::Off
1495 && let Some(label) = node.label()
1496 {
1497 found.push((label, node.live()));
1498 }
1499 for child in node.filtered_children(&accesskit_consumer::common_filter) {
1500 stack.push(child);
1501 }
1502 }
1503 found
1504 }
1505
1506 fn live_nodes_in_filtered_tree(update: &accesskit::TreeUpdate) -> Vec<String> {
1515 let consumer = accesskit_consumer::Tree::new(update.clone(), false);
1516 let state = consumer.state();
1517 let mut found = Vec::new();
1518 let mut stack = vec![state.root()];
1519 while let Some(node) = stack.pop() {
1520 if node.live() != accesskit::Live::Off {
1521 found.push(format!("{:?}", node.id()));
1522 }
1523 for child in node.filtered_children(&accesskit_consumer::common_filter) {
1524 stack.push(child);
1525 }
1526 }
1527 found.sort();
1528 found
1529 }
1530
1531 fn tree_with_one_widget() -> WidgetTree {
1532 let mut tree = WidgetTree::new();
1533 tree.add(FillWidget::new().label("content"));
1534 tree.layout(SizeProposal::exact(200.0, 100.0));
1535 tree
1536 }
1537
1538 #[test]
1541 fn an_announcement_enters_the_filtered_tree_then_leaves_it() {
1542 let mut tree = tree_with_one_widget();
1543 let update = tree.sync_accessibility();
1546 assert_eq!(announced_by_consumer(&update), Vec::new());
1547 assert_eq!(
1548 live_nodes_in_filtered_tree(&update),
1549 Vec::<String>::new(),
1550 "an idle announcer must be outside the filtered tree"
1551 );
1552 assert_a11y_tree_valid(&update);
1553
1554 tree.announce("Event added");
1555
1556 let exposed = tree.sync_accessibility();
1557 assert_eq!(
1558 announced_by_consumer(&exposed),
1559 vec![("Event added".to_string(), accesskit::Live::Polite)],
1560 "the message must be a label on a live node inside the filtered tree"
1561 );
1562 assert_eq!(
1563 live_nodes_in_filtered_tree(&exposed).len(),
1564 1,
1565 "exactly the polite announcer must have entered the filtered tree"
1566 );
1567 assert_a11y_tree_valid(&exposed);
1568
1569 let retracted = tree.sync_accessibility();
1570 assert_eq!(
1571 announced_by_consumer(&retracted),
1572 Vec::new(),
1573 "the node must stop carrying the message"
1574 );
1575 assert_eq!(
1581 live_nodes_in_filtered_tree(&retracted),
1582 Vec::<String>::new(),
1583 "the live node must leave the filtered tree entirely, not merely \
1584 lose its label"
1585 );
1586 assert_a11y_tree_valid(&retracted);
1587 }
1588
1589 #[test]
1594 fn the_same_message_announced_twice_is_exposed_twice() {
1595 let mut tree = tree_with_one_widget();
1596 tree.announce("Saved");
1597 tree.announce("Saved");
1598
1599 let mut exposures = 0;
1600 for _ in 0..6 {
1601 let update = tree.sync_accessibility();
1602 if announced_by_consumer(&update)
1603 .iter()
1604 .any(|(text, _)| text == "Saved")
1605 {
1606 exposures += 1;
1607 }
1608 }
1609 assert_eq!(exposures, 2, "each announcement needs its own exposure");
1610 }
1611
1612 #[test]
1613 fn an_assertive_announcement_uses_the_assertive_live_setting() {
1614 let mut tree = tree_with_one_widget();
1615 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
1616 let update = tree.sync_accessibility();
1617 assert_eq!(
1618 announced_by_consumer(&update),
1619 vec![("Could not save".to_string(), accesskit::Live::Assertive)]
1620 );
1621 }
1622
1623 #[test]
1626 fn the_two_politeness_levels_announce_independently() {
1627 let mut tree = tree_with_one_widget();
1628 tree.announce("Event added");
1629 tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
1630
1631 let mut spoken = announced_by_consumer(&tree.sync_accessibility());
1632 spoken.sort_by(|a, b| a.0.cmp(&b.0));
1633 assert_eq!(
1634 spoken,
1635 vec![
1636 ("Could not save".to_string(), accesskit::Live::Assertive),
1637 ("Event added".to_string(), accesskit::Live::Polite),
1638 ],
1639 "both levels must be exposed on the same update"
1640 );
1641 }
1642
1643 #[test]
1647 fn announcing_requests_the_syncs_it_needs() {
1648 let mut tree = tree_with_one_widget();
1649 let _ = tree.sync_accessibility();
1650 tree.a11y_update_requested.set(false);
1651 tree.frame_tick_requested.set(false);
1652
1653 tree.announce("Event added");
1654 assert!(
1655 tree.a11y_update_requested.get(),
1656 "an announcement must dirty the accessibility tree"
1657 );
1658 assert!(
1659 tree.frame_requested(),
1660 "an announcement must ask for the frame that carries it"
1661 );
1662
1663 tree.a11y_update_requested.set(false);
1665 tree.frame_tick_requested.set(false);
1666 let _ = tree.sync_accessibility();
1667 assert!(tree.a11y_update_requested.get());
1668 assert!(tree.frame_requested());
1669
1670 tree.a11y_update_requested.set(false);
1672 tree.frame_tick_requested.set(false);
1673 let _ = tree.sync_accessibility();
1674 assert!(!tree.a11y_update_requested.get());
1675 assert!(!tree.frame_requested());
1676 }
1677
1678 #[test]
1681 fn an_announcement_reaches_the_automation_ring() {
1682 let mut tree = tree_with_one_widget();
1683 let before = tree.announcements_since(0).len() as u64;
1684 tree.announce("Event added");
1685 let _ = tree.sync_accessibility();
1686 let got = tree.announcements_since(before);
1687 assert_eq!(got.len(), 1);
1688 assert_eq!(got[0].text, "Event added");
1689 assert!(!got[0].assertive);
1690 }
1691
1692 #[test]
1696 fn a_tree_snapshot_does_not_consume_a_pending_announcement() {
1697 let mut tree = tree_with_one_widget();
1698 tree.announce("Event added");
1699
1700 let snapshot = tree.accessibility_tree_snapshot();
1701 assert_eq!(
1702 announced_by_consumer(&snapshot),
1703 Vec::new(),
1704 "a snapshot must not advance the announcer"
1705 );
1706
1707 let delivered = tree.sync_accessibility();
1708 assert_eq!(
1709 announced_by_consumer(&delivered),
1710 vec![("Event added".to_string(), accesskit::Live::Polite)],
1711 "the message must still be there to deliver"
1712 );
1713 }
1714
1715 #[test]
1718 fn an_empty_announcement_is_not_exposed() {
1719 let mut tree = tree_with_one_widget();
1720 let _ = tree.sync_accessibility();
1721 tree.announce(" ");
1722 let update = tree.sync_accessibility();
1723 assert_eq!(announced_by_consumer(&update), Vec::new());
1724 }
1725
1726 #[derive(Debug)]
1727 struct ActionWidget;
1728
1729 impl Widget for ActionWidget {
1730 fn layout_response(
1731 &self,
1732 proposal: SizeProposal,
1733 _ctx: &LayoutContext,
1734 ) -> crate::widget::LayoutResponse {
1735 proposal.resolve(0.0, 0.0).into()
1736 }
1737
1738 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1739 builder.set_role(accesskit::Role::Button);
1740 builder.set_name("Save");
1741 builder.add_action(accesskit::Action::Click);
1742 builder.add_action(accesskit::Action::Focus);
1743 }
1744 }
1745
1746 #[derive(Debug)]
1747 struct ClickableWidget;
1748
1749 impl Widget for ClickableWidget {
1750 fn layout_response(
1751 &self,
1752 proposal: SizeProposal,
1753 _ctx: &LayoutContext,
1754 ) -> crate::widget::LayoutResponse {
1755 proposal.resolve(0.0, 0.0).into()
1756 }
1757
1758 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1759 builder.set_role(accesskit::Role::Button);
1760 builder.set_name("Click Me");
1761 builder.add_action(accesskit::Action::Click);
1762 }
1763 }
1764
1765 #[test]
1770 fn a_focusable_node_advertises_focus_without_the_widget_saying_so() {
1771 let mut tree = WidgetTree::new();
1772 let id = tree.add(ClickableWidget.focusable(true));
1774 tree.layout(SizeProposal::exact(100.0, 100.0));
1775
1776 let info = tree.accessibility_node(id);
1777 assert!(
1778 info.actions().contains(&accesskit::Action::Focus),
1779 "the arena knows the node is focusable; the AT node must say so"
1780 );
1781
1782 let mut ops = crate::window::NoopWindowOps;
1784 assert!(tree.dispatch_access_action(
1785 crate::accessibility::widget_id_to_node_id(id),
1786 accesskit::Action::Focus,
1787 None,
1788 &mut ops,
1789 ));
1790 assert_eq!(tree.focused(), Some(id));
1791 }
1792
1793 #[derive(Debug)]
1799 struct CompositeWidget {
1800 child_ids: Vec<WidgetId>,
1801 }
1802
1803 impl Widget for CompositeWidget {
1804 fn layout_response(
1805 &self,
1806 proposal: SizeProposal,
1807 _ctx: &LayoutContext,
1808 ) -> crate::widget::LayoutResponse {
1809 proposal.resolve(0.0, 0.0).into()
1810 }
1811
1812 fn place_children(
1813 &self,
1814 bounds: Rect,
1815 _proposal: SizeProposal,
1816 children: &mut [crate::widget::WidgetPlacement],
1817 _ctx: &LayoutContext,
1818 ) {
1819 for child in children.iter_mut() {
1820 child.origin = bounds.origin();
1821 child.size = bounds.size();
1822 }
1823 }
1824
1825 fn children(&self) -> Vec<WidgetId> {
1826 self.child_ids.clone()
1827 }
1828
1829 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1830 builder.set_role(accesskit::Role::SpinButton);
1831 builder.add_action(accesskit::Action::Focus);
1832 }
1833 }
1834
1835 #[test]
1846 fn an_at_focus_on_a_non_focusable_composite_lands_on_its_focusable_leaf() {
1847 let mut tree = WidgetTree::new();
1848 let leaf = tree.add(ClickableWidget.focusable(true));
1849 let root = tree.add(CompositeWidget {
1850 child_ids: vec![leaf],
1851 });
1852 tree.layout(SizeProposal::exact(100.0, 100.0));
1853
1854 let mut ops = crate::window::NoopWindowOps;
1855 assert!(tree.dispatch_access_action(
1856 crate::accessibility::widget_id_to_node_id(root),
1857 accesskit::Action::Focus,
1858 None,
1859 &mut ops,
1860 ));
1861 assert_eq!(
1862 tree.focused(),
1863 Some(leaf),
1864 "focus must reach the focusable leaf, not the composite root"
1865 );
1866 }
1867
1868 #[test]
1877 fn an_at_focus_on_a_plain_container_does_not_walk_into_it() {
1878 let mut tree = WidgetTree::new();
1879 let leaf = tree.add(ClickableWidget.focusable(true));
1880 let root = tree.add(StackWidget::new().add_child(leaf));
1881 tree.layout(SizeProposal::exact(100.0, 100.0));
1882
1883 let mut ops = crate::window::NoopWindowOps;
1884 assert!(
1885 !tree.dispatch_access_action(
1886 crate::accessibility::widget_id_to_node_id(root),
1887 accesskit::Action::Focus,
1888 None,
1889 &mut ops,
1890 ),
1891 "a node that never advertised `Focus` must report the action unhandled"
1892 );
1893 assert_eq!(
1894 tree.focused(),
1895 None,
1896 "focus must stay where it was, not fall into the container's first control"
1897 );
1898 }
1899
1900 #[test]
1903 fn an_at_focus_on_a_focusable_node_still_lands_on_that_node() {
1904 let mut tree = WidgetTree::new();
1905 let outer = tree.add(ClickableWidget.focusable(true));
1906 tree.layout(SizeProposal::exact(100.0, 100.0));
1907
1908 let mut ops = crate::window::NoopWindowOps;
1909 assert!(tree.dispatch_access_action(
1910 crate::accessibility::widget_id_to_node_id(outer),
1911 accesskit::Action::Focus,
1912 None,
1913 &mut ops,
1914 ));
1915 assert_eq!(tree.focused(), Some(outer));
1916 }
1917
1918 #[test]
1921 fn a_derived_focus_action_is_still_removable_by_an_override() {
1922 let mut tree = WidgetTree::new();
1923 let id = tree.add(
1924 ClickableWidget
1925 .focusable(true)
1926 .access_remove_action(accesskit::Action::Focus),
1927 );
1928 tree.layout(SizeProposal::exact(100.0, 100.0));
1929 assert!(
1930 !tree
1931 .accessibility_node(id)
1932 .actions()
1933 .contains(&accesskit::Action::Focus)
1934 );
1935 }
1936
1937 #[test]
1940 fn a_non_focusable_node_does_not_advertise_focus() {
1941 let mut tree = WidgetTree::new();
1942 let id = tree.add(ClickableWidget);
1943 tree.layout(SizeProposal::exact(100.0, 100.0));
1944 assert!(
1945 !tree
1946 .accessibility_node(id)
1947 .actions()
1948 .contains(&accesskit::Action::Focus)
1949 );
1950 }
1951
1952 #[test]
1953 fn labeled_widget_has_accessibility() {
1954 let mut tree = WidgetTree::new();
1955 let widget = tree.add(FillWidget::new().label("Hello"));
1956 tree.layout(SizeProposal::exact(100.0, 20.0));
1957 let info = tree.accessibility_node(widget);
1958 assert_eq!(info.role(), accesskit::Role::Label);
1959 assert_eq!(info.name(), Some("Hello"));
1960 }
1961
1962 #[test]
1963 fn find_by_label_works() {
1964 let mut tree = WidgetTree::new();
1965 let widget = tree.add(FillWidget::new().label("Save"));
1966 tree.layout(SizeProposal::exact(100.0, 20.0));
1967 assert_eq!(tree.find_by_label("Save"), Some(widget));
1968 }
1969
1970 #[test]
1971 fn label_role_emits_text_as_value_not_label() {
1972 let mut tree = WidgetTree::new();
1979 let widget = tree.add(FillWidget::new().label("Section")); tree.layout(SizeProposal::exact(100.0, 20.0));
1981
1982 assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1984
1985 let update = tree.sync_accessibility();
1987 let node = &update
1988 .nodes
1989 .iter()
1990 .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1991 .expect("label node must be present in the tree update")
1992 .1;
1993 assert_eq!(node.role(), accesskit::Role::Label);
1994 assert_eq!(
1995 node.value(),
1996 Some("Section"),
1997 "Role::Label text must be exposed via `value`"
1998 );
1999 assert_eq!(
2000 node.label(),
2001 None,
2002 "Role::Label must not leave text in the `label` property"
2003 );
2004 }
2005
2006 #[test]
2007 fn find_by_role_works() {
2008 let mut tree = WidgetTree::new();
2009 tree.add(FillWidget::new().label("Text"));
2010 tree.layout(SizeProposal::exact(100.0, 20.0));
2011 assert!(tree.find_by_role(accesskit::Role::Label).is_some());
2012 }
2013
2014 #[test]
2015 fn access_hidden_bound_to_signal_toggles_reactively() {
2016 use crate::signal::Signal;
2017 use crate::widget_builder::WidgetBuilder;
2018 let hidden = Signal::new(false);
2019 let mut tree = WidgetTree::new();
2020 let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
2021 tree.layout(SizeProposal::exact(100.0, 30.0));
2022 assert!(
2023 !tree.accessibility_node(w).is_hidden(),
2024 "node should be visible to AT while the signal is false"
2025 );
2026 hidden.set(true);
2027 assert!(
2029 tree.accessibility_node(w).is_hidden(),
2030 "node should hide from AT when the bound signal flips to true"
2031 );
2032 hidden.set(false);
2033 assert!(!tree.accessibility_node(w).is_hidden());
2034 }
2035
2036 #[test]
2040 fn a_widget_with_a_context_menu_advertises_the_action() {
2041 let mut tree = WidgetTree::new();
2042 let plain = tree.add(FillWidget::new().label("no menu"));
2043 let with_menu = tree.add(
2044 FillWidget::new()
2045 .label("has menu")
2046 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
2047 );
2048 tree.layout(SizeProposal::exact(100.0, 20.0));
2049
2050 assert!(
2051 tree.accessibility_node(with_menu)
2052 .actions()
2053 .contains(&Action::ShowContextMenu),
2054 "a node owning a context-menu factory must announce it"
2055 );
2056 assert!(
2057 !tree
2058 .accessibility_node(plain)
2059 .actions()
2060 .contains(&Action::ShowContextMenu),
2061 "and a node without one must not"
2062 );
2063 }
2064
2065 #[test]
2068 fn a_removed_context_menu_action_stays_removed() {
2069 let mut tree = WidgetTree::new();
2070 let id = tree.add(
2071 FillWidget::new()
2072 .label("suppressed")
2073 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
2074 .access_remove_action(Action::ShowContextMenu),
2075 );
2076 tree.layout(SizeProposal::exact(100.0, 20.0));
2077 assert!(
2078 !tree
2079 .accessibility_node(id)
2080 .actions()
2081 .contains(&Action::ShowContextMenu)
2082 );
2083 }
2084
2085 #[test]
2090 fn a_child_does_not_advertise_its_parents_context_menu() {
2091 let mut tree = WidgetTree::new();
2092 let child = tree.add(FillWidget::new().label("inner"));
2093 let parent = tree.add(
2094 StackWidget::new()
2095 .add_child(child)
2096 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
2097 );
2098 tree.layout(SizeProposal::exact(100.0, 20.0));
2099
2100 assert!(
2101 tree.accessibility_node(parent)
2102 .actions()
2103 .contains(&Action::ShowContextMenu)
2104 );
2105 assert!(
2106 !tree
2107 .accessibility_node(child)
2108 .actions()
2109 .contains(&Action::ShowContextMenu),
2110 "the menu belongs to the widget that declared it"
2111 );
2112 }
2113
2114 #[test]
2115 fn accessibility_node_collects_actions() {
2116 let mut tree = WidgetTree::new();
2117 let widget = tree.add(ActionWidget);
2118 tree.layout(SizeProposal::exact(100.0, 40.0));
2119
2120 let info = tree.accessibility_node(widget);
2121 assert_eq!(info.role(), accesskit::Role::Button);
2122 assert_eq!(info.name(), Some("Save"));
2123 assert_eq!(info.actions().len(), 2);
2124 assert!(info.actions().contains(&accesskit::Action::Click));
2125 assert!(info.actions().contains(&accesskit::Action::Focus));
2126 }
2127
2128 #[test]
2129 fn sync_accessibility_produces_tree_update() {
2130 let mut tree = WidgetTree::new();
2131 tree.add(FillWidget::new().label("First"));
2132 tree.add(FillWidget::new().label("Second"));
2133 tree.layout(SizeProposal::exact(200.0, 100.0));
2134
2135 let update = tree.sync_accessibility();
2136 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
2139 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
2140 assert!(update.tree.is_some());
2141 assert_a11y_tree_valid(&update);
2142 }
2143
2144 #[test]
2145 fn root_node_carries_locale_as_language() {
2146 let mut tree = WidgetTree::new();
2147 tree.add(FillWidget::new().label("Bonjour"));
2148
2149 tree.layout(SizeProposal::exact(200.0, 100.0));
2151 let update = tree.sync_accessibility();
2152 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
2153 assert_eq!(
2154 update.nodes[0].1.language(),
2155 None,
2156 "no language before a locale is set"
2157 );
2158
2159 tree.set_locale("fr-FR".to_string());
2162 let update = tree.sync_accessibility();
2163 assert_eq!(
2164 update.nodes[0].1.language(),
2165 Some("fr-FR"),
2166 "root node must advertise the active locale as its language"
2167 );
2168 }
2169
2170 #[test]
2171 fn sync_accessibility_excludes_dormant_widgets() {
2172 let mut tree = WidgetTree::new();
2173 tree.add(FillWidget::new().label("Active"));
2174 let dormant = tree.add(FillWidget::new().label("Dormant"));
2175 tree.layout(SizeProposal::exact(200.0, 100.0));
2176
2177 tree.set_dormant(dormant);
2178
2179 let update = tree.sync_accessibility();
2180 assert_eq!(update.nodes.len(), 2 + ANNOUNCER_NODES);
2181 assert_a11y_tree_valid(&update);
2182 }
2183
2184 #[test]
2191 fn relayout_without_activation_change_does_not_dirty_a11y() {
2192 let mut tree = WidgetTree::new();
2193 let id = tree.add(FillWidget::new().label("Static"));
2194 tree.layout(SizeProposal::exact(200.0, 100.0));
2195
2196 let _ = tree.sync_accessibility();
2198 assert!(
2199 !tree.a11y_dirty,
2200 "sync_accessibility must clear the dirty flag"
2201 );
2202
2203 tree.arena.mark_needs_layout(id);
2207 tree.layout(SizeProposal::exact(200.0, 100.0));
2208
2209 assert!(
2210 !tree.a11y_dirty,
2211 "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
2212 );
2213 }
2214
2215 #[derive(Debug)]
2218 struct Mover {
2219 offset: crate::signal::Signal<f32>,
2220 child: WidgetId,
2221 }
2222
2223 impl crate::widget::Widget for Mover {
2224 fn children(&self) -> Vec<WidgetId> {
2225 vec![self.child]
2226 }
2227 fn layout_response(
2228 &self,
2229 proposal: SizeProposal,
2230 _ctx: &crate::widget::LayoutContext,
2231 ) -> crate::widget::LayoutResponse {
2232 proposal.resolve(200.0, 100.0).into()
2233 }
2234 fn place_children(
2235 &self,
2236 bounds: teksilo_canvas::Rect,
2237 _proposal: SizeProposal,
2238 children: &mut [crate::widget::WidgetPlacement],
2239 _ctx: &crate::widget::LayoutContext,
2240 ) {
2241 if let Some(placement) = children.first_mut() {
2242 placement.origin =
2243 teksilo_canvas::Point::new(bounds.x + self.offset.get(), bounds.y);
2244 placement.size = teksilo_canvas::Size::new(50.0, 20.0);
2245 }
2246 }
2247 }
2248
2249 #[test]
2256 fn translating_a_widget_patches_the_cache_without_a_walk() {
2257 let mut tree = WidgetTree::new();
2258 let inner = tree.add(FillWidget::new().label("Row"));
2259 let offset = crate::signal::Signal::new(0.0f32);
2260 let root = tree.add(Mover {
2261 offset: offset.clone(),
2262 child: inner,
2263 });
2264 tree.layout(SizeProposal::exact(200.0, 100.0));
2265 let update = tree.sync_accessibility();
2266 let walks = tree.a11y_walk_generation();
2267 let before = find_node(&update, inner)
2268 .unwrap()
2269 .bounds()
2270 .expect("the node carries bounds");
2271
2272 offset.set(20.0);
2274 tree.arena.mark_needs_layout(root);
2275 tree.layout(SizeProposal::exact(200.0, 100.0));
2276
2277 let update = tree.sync_accessibility();
2278 assert_eq!(
2279 tree.a11y_walk_generation(),
2280 walks,
2281 "a pure translation must not cost a walk"
2282 );
2283 let after = find_node(&update, inner)
2284 .unwrap()
2285 .bounds()
2286 .expect("the node still carries bounds");
2287 assert!(
2288 (after.x0 - before.x0 - 20.0).abs() < 0.01,
2289 "the cached node must follow the move: {before:?} -> {after:?}"
2290 );
2291 }
2292
2293 #[test]
2294 fn at_version_does_not_bump_for_a_move() {
2295 let mut tree = WidgetTree::new();
2299 let inner = tree.add(FillWidget::new().label("Row"));
2300 let offset = crate::signal::Signal::new(0.0f32);
2301 let root = tree.add(Mover {
2302 offset: offset.clone(),
2303 child: inner,
2304 });
2305 tree.layout(SizeProposal::exact(200.0, 100.0));
2306 let _ = tree.sync_accessibility();
2307 let version = tree.at_version().get();
2308
2309 offset.set(20.0);
2310 tree.arena.mark_needs_layout(root);
2311 tree.layout(SizeProposal::exact(200.0, 100.0));
2312 let _ = tree.sync_accessibility();
2313
2314 assert_eq!(tree.at_version().get(), version);
2315 }
2316
2317 #[test]
2318 fn resizing_a_widget_dirties_the_accessibility_tree() {
2319 let mut tree = WidgetTree::new();
2323 let id = tree.add(FillWidget::new().label("Static"));
2324 tree.layout(SizeProposal::exact(200.0, 100.0));
2325 let _ = tree.sync_accessibility();
2326 let walks = tree.a11y_walk_generation();
2327
2328 tree.layout(SizeProposal::exact(300.0, 100.0));
2329 let _ = tree.sync_accessibility();
2330
2331 assert!(
2332 tree.a11y_walk_generation() > walks,
2333 "a resize must be walked, not patched"
2334 );
2335 let _ = id;
2336 }
2337
2338 #[test]
2339 fn changing_the_theme_dirties_the_accessibility_tree() {
2340 let mut tree = WidgetTree::new();
2345 tree.add(FillWidget::new().label("Static"));
2346 tree.layout(SizeProposal::exact(200.0, 100.0));
2347 let _ = tree.sync_accessibility();
2348 let walks = tree.a11y_walk_generation();
2349
2350 tree.set_theme(crate::presets::intui::dark());
2351 let _ = tree.sync_accessibility();
2352
2353 assert!(tree.a11y_walk_generation() > walks);
2354 }
2355
2356 #[test]
2357 fn changing_the_text_scale_dirties_the_accessibility_tree() {
2358 let mut tree = WidgetTree::new();
2359 tree.add(FillWidget::new().label("Static"));
2360 tree.layout(SizeProposal::exact(200.0, 100.0));
2361 let _ = tree.sync_accessibility();
2362 let walks = tree.a11y_walk_generation();
2363
2364 tree.set_user_text_scale(1.5);
2365 let _ = tree.sync_accessibility();
2366
2367 assert!(tree.a11y_walk_generation() > walks);
2368 }
2369
2370 #[test]
2371 fn a_moved_node_absent_from_the_cache_is_skipped() {
2372 use crate::widget_builder::WidgetBuilder;
2377 let mut tree = WidgetTree::new();
2378 let hidden = tree.add(FillWidget::new().label("Inner"));
2379 let offset = crate::signal::Signal::new(0.0f32);
2380 let root = tree.add(
2381 Mover {
2382 offset: offset.clone(),
2383 child: hidden,
2384 }
2385 .access_exclude_subtree(),
2386 );
2387 tree.layout(SizeProposal::exact(200.0, 100.0));
2388 let update = tree.sync_accessibility();
2389 assert!(
2390 find_node(&update, hidden).is_none(),
2391 "the excluded descendant must be absent for this test to mean anything"
2392 );
2393
2394 offset.set(20.0);
2395 tree.arena.mark_needs_layout(root);
2396 tree.layout(SizeProposal::exact(200.0, 100.0));
2397 let update = tree.sync_accessibility();
2398
2399 assert!(find_node(&update, hidden).is_none());
2400 assert_no_dangling_relationships(&update);
2401 }
2402
2403 #[test]
2407 fn activation_transition_does_dirty_a11y() {
2408 let mut tree = WidgetTree::new();
2409 let id = tree.add(FillWidget::new().label("Toggle"));
2410 tree.layout(SizeProposal::exact(200.0, 100.0));
2411 let _ = tree.sync_accessibility();
2412 assert!(!tree.a11y_dirty);
2413
2414 tree.set_dormant(id);
2415 tree.layout(SizeProposal::exact(200.0, 100.0));
2416 assert!(
2417 tree.a11y_dirty,
2418 "active→dormant transition must dirty the AT cache so the dormant node is removed"
2419 );
2420 }
2421
2422 #[test]
2423 fn sync_accessibility_includes_focus() {
2424 let mut tree = WidgetTree::new();
2425 let widget = tree.add(FillWidget::new().focusable().label("Focused"));
2426 tree.layout(SizeProposal::exact(100.0, 50.0));
2427 tree.focus(widget);
2428
2429 let update = tree.sync_accessibility();
2430 let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
2431 assert_eq!(update.focus, expected_focus);
2432 assert_a11y_tree_valid(&update);
2433 }
2434
2435 #[test]
2436 fn sync_accessibility_parent_child_relationship() {
2437 let mut tree = WidgetTree::new();
2438 let child = tree.add(FillWidget::new().label("Child"));
2439 let parent = tree.add(
2442 StackWidget::new()
2443 .add_child(child)
2444 .access_label_literal("Parent"),
2445 );
2446 tree.layout(SizeProposal::exact(100.0, 50.0));
2447
2448 let update = tree.sync_accessibility();
2449 assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
2450
2451 let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
2452 let parent_node = update
2453 .nodes
2454 .iter()
2455 .find(|(id, _)| *id == parent_node_id)
2456 .map(|(_, node)| node)
2457 .unwrap();
2458
2459 let child_node_id = crate::accessibility::widget_id_to_node_id(child);
2460 assert!(parent_node.children().contains(&child_node_id));
2461 assert_a11y_tree_valid(&update);
2462 }
2463
2464 #[test]
2465 fn presentational_containers_collapse_and_promote_children() {
2466 let mut tree = WidgetTree::new();
2472 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(
2476 StackWidget::new()
2477 .add_child(outer)
2478 .access_label_literal("Group"),
2479 );
2480 tree.layout(SizeProposal::exact(100.0, 50.0));
2481 let update = tree.sync_accessibility();
2482
2483 assert!(find_node(&update, inner).is_none(), "bare inner pruned");
2484 assert!(find_node(&update, outer).is_none(), "bare outer pruned");
2485
2486 let labeled_node = find_node(&update, labeled).expect("labeled group survives");
2487 let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
2488 assert!(
2489 labeled_node.children().contains(&leaf_nid),
2490 "leaf promoted past both bare containers to the labeled ancestor"
2491 );
2492 assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
2493 assert_a11y_tree_valid(&update);
2494 }
2495
2496 #[test]
2497 fn find_by_action_finds_clickable() {
2498 let mut tree = WidgetTree::new();
2499 let widget = tree.add(ClickableWidget);
2500 tree.layout(SizeProposal::exact(100.0, 40.0));
2501
2502 assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
2503 assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
2504 }
2505
2506 #[test]
2507 fn text_content_returns_accessibility_name() {
2508 let mut tree = WidgetTree::new();
2509 let widget = tree.add(FillWidget::new().label("Hello World"));
2510 tree.layout(SizeProposal::exact(100.0, 50.0));
2511
2512 assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
2513 }
2514
2515 #[test]
2516 fn text_content_returns_none_without_label() {
2517 let mut tree = WidgetTree::new();
2518 let widget = tree.add(FillWidget::new());
2519 tree.layout(SizeProposal::exact(100.0, 50.0));
2520
2521 assert_eq!(tree.text_content(widget), None);
2522 }
2523
2524 #[test]
2525 fn descendant_of_disabled_ancestor_reports_disabled() {
2526 use crate::signal::Signal;
2527
2528 let mut tree = WidgetTree::new();
2529 let child = tree.add(FillWidget::new().label("Child"));
2530 let parent = tree.add(StackWidget::new().add_child(child));
2531 tree.enabled_when(parent, Signal::new(false));
2532 tree.layout(SizeProposal::exact(100.0, 50.0));
2533
2534 assert!(
2535 tree.accessibility_node(child).is_disabled(),
2536 "descendant should report disabled when ancestor is disabled"
2537 );
2538 }
2539
2540 #[test]
2541 fn text_value_returns_accessibility_value() {
2542 #[derive(Debug)]
2543 struct ValueWidget;
2544
2545 impl Widget for ValueWidget {
2546 fn layout_response(
2547 &self,
2548 proposal: SizeProposal,
2549 _ctx: &LayoutContext,
2550 ) -> crate::widget::LayoutResponse {
2551 proposal.resolve(0.0, 0.0).into()
2552 }
2553
2554 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
2555 builder.set_role(accesskit::Role::Slider);
2556 builder.set_name("Volume");
2557 builder.set_value("75%");
2558 }
2559 }
2560
2561 let mut tree = WidgetTree::new();
2562 let widget = tree.add(ValueWidget);
2563 tree.layout(SizeProposal::exact(100.0, 40.0));
2564
2565 assert_eq!(tree.text_value(widget), Some("75%".to_string()));
2566 assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
2567 }
2568
2569 #[test]
2570 fn sync_accessibility_has_no_duplicate_children() {
2571 let mut tree = WidgetTree::new();
2575 let grandchild = tree.add(FillWidget::new().label("Grandchild"));
2576 let child_a = tree.add(StackWidget::new().add_child(grandchild));
2577 let child_b = tree.add(FillWidget::new().label("Sibling"));
2578 let _root = tree.add(StackWidget::new().add_child(child_a).add_child(child_b));
2579 tree.layout(SizeProposal::exact(200.0, 100.0));
2580
2581 let update = tree.sync_accessibility();
2582
2583 let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
2584 std::collections::HashMap::new();
2585 for (parent_id, node) in &update.nodes {
2586 for &child_id in node.children() {
2587 let prev = all_children.insert(child_id, *parent_id);
2588 assert!(
2589 prev.is_none(),
2590 "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
2591 );
2592 }
2593 }
2594 assert_a11y_tree_valid(&update);
2595 }
2596
2597 #[test]
2598 fn no_dangling_relationships_in_basic_tree() {
2599 let mut tree = WidgetTree::new();
2600 let child = tree.add(FillWidget::new().label("Child"));
2601 let _parent = tree.add(StackWidget::new().add_child(child));
2602 tree.layout(SizeProposal::exact(100.0, 50.0));
2603
2604 let update = tree.sync_accessibility();
2605 assert_no_dangling_relationships(&update);
2606 assert_a11y_tree_valid(&update);
2607 }
2608
2609 use crate::widget_builder::WidgetBuilder;
2614 use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
2615
2616 fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
2618 let nid = crate::accessibility::widget_id_to_node_id(id);
2619 update
2620 .nodes
2621 .iter()
2622 .find(|(node_id, _)| *node_id == nid)
2623 .map(|(_, n)| n)
2624 }
2625
2626 #[derive(Debug)]
2629 struct AlwaysHiddenWidget;
2630 impl Widget for AlwaysHiddenWidget {
2631 fn layout_response(
2632 &self,
2633 proposal: SizeProposal,
2634 _ctx: &LayoutContext,
2635 ) -> crate::widget::LayoutResponse {
2636 proposal.resolve(0.0, 0.0).into()
2637 }
2638 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2639 builder.set_role(Role::GenericContainer);
2640 builder.set_hidden();
2641 }
2642 }
2643
2644 #[test]
2646 fn access_label_replaces_widget_label() {
2647 let mut tree = WidgetTree::new();
2648 let id = tree.add(ClickableWidget.access_label_literal("Publish"));
2649 tree.layout(SizeProposal::exact(100.0, 40.0));
2650 assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
2651 }
2652
2653 #[test]
2655 fn access_description_appears_on_bare_widget() {
2656 let mut tree = WidgetTree::new();
2657 let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
2658 tree.layout(SizeProposal::exact(50.0, 50.0));
2659 let update = tree.sync_accessibility();
2660 let node = find_node(&update, id).expect("node present");
2661 assert_eq!(node.description(), Some("Decorative"));
2662 }
2663
2664 #[test]
2666 fn access_value_replaces_widget_value() {
2667 #[derive(Debug)]
2668 struct SliderWidget;
2669 impl Widget for SliderWidget {
2670 fn layout_response(
2671 &self,
2672 proposal: SizeProposal,
2673 _ctx: &LayoutContext,
2674 ) -> crate::widget::LayoutResponse {
2675 proposal.resolve(0.0, 0.0).into()
2676 }
2677 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2678 builder.set_role(Role::Slider);
2679 builder.set_value("50");
2680 }
2681 }
2682 let mut tree = WidgetTree::new();
2683 let id = tree.add(SliderWidget.access_value_literal("Custom"));
2684 tree.layout(SizeProposal::exact(100.0, 40.0));
2685 assert_eq!(tree.text_value(id), Some("Custom".to_string()));
2686 }
2687
2688 #[test]
2690 fn access_role_overrides_widget_role() {
2691 let mut tree = WidgetTree::new();
2692 let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
2693 tree.layout(SizeProposal::exact(100.0, 40.0));
2694 assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
2695 }
2696
2697 #[test]
2698 fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
2699 let mut tree = WidgetTree::new();
2706 let anchor = tree.add(FillWidget::new().label("Save"));
2707 let tip = tree.add(FillWidget::new().label("Save the file"));
2708 tree.layout(SizeProposal::exact(200.0, 100.0));
2709 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
2710
2711 assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
2712 let update = tree.sync_accessibility();
2713 let node = find_node(&update, anchor).expect("anchor node present");
2714 assert_eq!(
2715 node.description(),
2716 Some("Save the file"),
2717 "the anchor must describe itself with its tooltip text while unshown"
2718 );
2719 }
2720
2721 #[test]
2722 fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
2723 let mut tree = WidgetTree::new();
2726 let anchor = tree.add(FillWidget::new().label("Save"));
2727 let tip = tree.add(FillWidget::new().label("Save the file"));
2728 tree.layout(SizeProposal::exact(200.0, 100.0));
2729 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2730
2731 tree.pointer_move(tree.bounds(anchor).center());
2732 tree.advance_time(std::time::Duration::from_millis(150));
2733 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
2734
2735 let update = tree.sync_accessibility();
2736 let node = find_node(&update, anchor).expect("anchor node present");
2737 assert!(
2738 !node.described_by().is_empty(),
2739 "a shown tooltip is wired as a described_by relation"
2740 );
2741 }
2742
2743 #[test]
2745 fn access_hint_alias_writes_description_field() {
2746 let mut tree = WidgetTree::new();
2747 let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
2748 tree.layout(SizeProposal::exact(50.0, 50.0));
2749 let update = tree.sync_accessibility();
2750 let node = find_node(&update, id).unwrap();
2751 assert_eq!(node.description(), Some("Tip"));
2752 }
2753
2754 #[test]
2756 fn access_identifier_writes_author_id() {
2757 let mut tree = WidgetTree::new();
2758 let id = tree.add(FillWidget::new().access_identifier("save-button"));
2759 tree.layout(SizeProposal::exact(50.0, 50.0));
2760 let update = tree.sync_accessibility();
2761 let node = find_node(&update, id).unwrap();
2762 assert_eq!(node.author_id(), Some("save-button"));
2763 }
2764
2765 #[test]
2767 fn access_shortcut_literal_set() {
2768 let mut tree = WidgetTree::new();
2769 let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
2770 tree.layout(SizeProposal::exact(50.0, 50.0));
2771 let update = tree.sync_accessibility();
2772 let node = find_node(&update, id).unwrap();
2773 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
2774 }
2775
2776 #[test]
2779 fn access_shortcut_id_resolves_and_tracks_rebinds() {
2780 use crate::event::Key;
2781 use crate::shortcut::{KeyStroke, Shortcut};
2782
2783 let mut tree = WidgetTree::new();
2784 tree.shortcut_registry_mut().register(
2785 Shortcut::new("app.save")
2786 .name("Save")
2787 .primary(KeyStroke::command(Key::S))
2788 .build(),
2789 );
2790 let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
2791 tree.layout(SizeProposal::exact(50.0, 50.0));
2792
2793 let accel = if cfg!(target_os = "macos") {
2797 "Cmd"
2798 } else {
2799 "Ctrl"
2800 };
2801
2802 let update = tree.sync_accessibility();
2804 let node = find_node(&update, id).unwrap();
2805 assert_eq!(
2806 node.keyboard_shortcut(),
2807 Some(format!("{accel}+S").as_str())
2808 );
2809
2810 tree.shortcut_registry_mut()
2812 .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
2813 let update = tree.sync_accessibility();
2814 let node = find_node(&update, id).unwrap();
2815 assert_eq!(
2816 node.keyboard_shortcut(),
2817 Some(format!("{accel}+Q").as_str())
2818 );
2819 }
2820
2821 #[test]
2824 fn access_shortcut_id_unknown_id_omits_announcement() {
2825 let mut tree = WidgetTree::new();
2826 let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
2827 tree.layout(SizeProposal::exact(50.0, 50.0));
2828 let update = tree.sync_accessibility();
2829 let node = find_node(&update, id).unwrap();
2830 assert_eq!(node.keyboard_shortcut(), None);
2831 }
2832
2833 #[test]
2835 fn access_hidden_true_hides_widget() {
2836 let mut tree = WidgetTree::new();
2837 let id = tree.add(ClickableWidget.access_hidden(true));
2838 tree.layout(SizeProposal::exact(50.0, 50.0));
2839 assert!(tree.accessibility_node(id).is_hidden());
2840 }
2841
2842 #[test]
2844 fn access_hidden_false_clears_widget_set_hidden() {
2845 let mut tree = WidgetTree::new();
2846 let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
2847 tree.layout(SizeProposal::exact(50.0, 50.0));
2848 assert!(
2849 !tree.accessibility_node(id).is_hidden(),
2850 "access_hidden(false) should clear widget-emitted hidden"
2851 );
2852 }
2853
2854 #[test]
2856 fn access_disabled_true_marks_disabled() {
2857 let mut tree = WidgetTree::new();
2858 let id = tree.add(FillWidget::new().access_disabled(true));
2859 tree.layout(SizeProposal::exact(50.0, 50.0));
2860 assert!(tree.accessibility_node(id).is_disabled());
2861 }
2862
2863 #[test]
2865 fn access_disabled_false_clears_arena_driven_disabled() {
2866 use crate::signal::Signal;
2867 let mut tree = WidgetTree::new();
2868 let id = tree.add(FillWidget::new().label("X").access_disabled(false));
2869 tree.enabled_when(id, Signal::new(false));
2870 tree.layout(SizeProposal::exact(50.0, 50.0));
2871 assert!(
2872 !tree.accessibility_node(id).is_disabled(),
2873 "access_disabled(false) should clear even arena-driven disabled"
2874 );
2875 }
2876
2877 #[test]
2879 fn access_controls_appends() {
2880 let mut tree = WidgetTree::new();
2881 let target = tree.add(FillWidget::new().label("Target"));
2882 let controller = tree.add(
2883 FillWidget::new()
2884 .label("Controller")
2885 .access_controls(target),
2886 );
2887 tree.layout(SizeProposal::exact(100.0, 50.0));
2888 let update = tree.sync_accessibility();
2889 let node = find_node(&update, controller).unwrap();
2890 let target_nid = crate::accessibility::widget_id_to_node_id(target);
2891 assert!(
2892 node.controls().contains(&target_nid),
2893 "controls list should contain the target NodeId"
2894 );
2895 }
2896
2897 #[test]
2899 fn access_described_by_appends() {
2900 let mut tree = WidgetTree::new();
2901 let other = tree.add(FillWidget::new().label("Desc"));
2902 let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
2903 tree.layout(SizeProposal::exact(100.0, 50.0));
2904 let update = tree.sync_accessibility();
2905 let node = find_node(&update, id).unwrap();
2906 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2907 assert!(node.described_by().contains(&other_nid));
2908 }
2909
2910 #[test]
2912 fn access_labelled_by_appends() {
2913 let mut tree = WidgetTree::new();
2914 let other = tree.add(FillWidget::new().label("Lbl"));
2915 let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
2916 tree.layout(SizeProposal::exact(100.0, 50.0));
2917 let update = tree.sync_accessibility();
2918 let node = find_node(&update, id).unwrap();
2919 let other_nid = crate::accessibility::widget_id_to_node_id(other);
2920 assert!(node.labelled_by().contains(&other_nid));
2921 }
2922
2923 #[test]
2924 fn a_labelled_by_target_that_went_dormant_is_stripped() {
2925 let mut tree = WidgetTree::new();
2931 let title = tree.add(FillWidget::new().label("Preferences"));
2932 let dialog = tree.add(FillWidget::new().access_labelled_by(title));
2933 tree.layout(SizeProposal::exact(200.0, 100.0));
2934
2935 let update = tree.sync_accessibility();
2936 assert!(
2937 !find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2938 "the relation is live while the target is in the tree"
2939 );
2940
2941 tree.set_dormant(title);
2942 let update = tree.sync_accessibility();
2943 assert!(
2944 find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2945 "a target absent from the tree must not survive in the relation"
2946 );
2947 assert_no_dangling_relationships(&update);
2948 }
2949
2950 #[test]
2951 fn re_registering_a_labelled_by_relation_does_not_duplicate_it() {
2952 let mut tree = WidgetTree::new();
2956 let title = tree.add(FillWidget::new().label("Name"));
2957 let field = tree.add(FillWidget::new());
2958 tree.layout(SizeProposal::exact(200.0, 100.0));
2959
2960 for _ in 0..3 {
2961 tree.push_access_labelled_by(field, title);
2962 }
2963 let update = tree.sync_accessibility();
2964 assert_eq!(
2965 find_node(&update, field).unwrap().labelled_by().len(),
2966 1,
2967 "the same target registered repeatedly is one relation"
2968 );
2969 }
2970
2971 #[test]
2972 fn re_registering_a_described_by_relation_does_not_duplicate_it() {
2973 let mut tree = WidgetTree::new();
2974 let hint = tree.add(FillWidget::new().label("Must be unique"));
2975 let field = tree.add(FillWidget::new());
2976 tree.layout(SizeProposal::exact(200.0, 100.0));
2977
2978 for _ in 0..3 {
2979 tree.push_access_described_by(field, hint);
2980 }
2981 let update = tree.sync_accessibility();
2982 assert_eq!(
2983 find_node(&update, field).unwrap().described_by().len(),
2984 1,
2985 "the same target registered repeatedly is one relation"
2986 );
2987 }
2988
2989 #[test]
2991 fn access_live_assertive_set() {
2992 let mut tree = WidgetTree::new();
2993 let id = tree.add(FillWidget::new().access_live(Live::Assertive));
2994 tree.layout(SizeProposal::exact(50.0, 50.0));
2995 let update = tree.sync_accessibility();
2996 let node = find_node(&update, id).unwrap();
2997 assert_eq!(node.live(), Some(Live::Assertive));
2998 }
2999
3000 #[test]
3002 fn access_aria_current_set() {
3003 let mut tree = WidgetTree::new();
3004 let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
3005 tree.layout(SizeProposal::exact(50.0, 50.0));
3006 let update = tree.sync_accessibility();
3007 let node = find_node(&update, id).unwrap();
3008 assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
3009 }
3010
3011 #[test]
3013 fn access_has_popup_set() {
3014 let mut tree = WidgetTree::new();
3015 let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
3016 tree.layout(SizeProposal::exact(50.0, 50.0));
3017 let update = tree.sync_accessibility();
3018 let node = find_node(&update, id).unwrap();
3019 assert_eq!(node.has_popup(), Some(HasPopup::Menu));
3020 }
3021
3022 #[test]
3024 fn access_orientation_set() {
3025 let mut tree = WidgetTree::new();
3026 let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
3027 tree.layout(SizeProposal::exact(50.0, 50.0));
3028 let update = tree.sync_accessibility();
3029 let node = find_node(&update, id).unwrap();
3030 assert_eq!(node.orientation(), Some(Orientation::Vertical));
3031 }
3032
3033 #[test]
3035 fn access_numeric_value_and_range() {
3036 let mut tree = WidgetTree::new();
3037 let id = tree.add(
3038 FillWidget::new()
3039 .access_role(Role::Slider)
3040 .access_numeric_value(50.0)
3041 .access_numeric_range(0.0, 100.0)
3042 .access_numeric_step(5.0),
3043 );
3044 tree.layout(SizeProposal::exact(50.0, 50.0));
3045 let update = tree.sync_accessibility();
3046 let node = find_node(&update, id).unwrap();
3047 assert_eq!(node.numeric_value(), Some(50.0));
3048 assert_eq!(node.min_numeric_value(), Some(0.0));
3049 assert_eq!(node.max_numeric_value(), Some(100.0));
3050 assert_eq!(node.numeric_value_step(), Some(5.0));
3051 }
3052
3053 #[test]
3055 fn access_action_advertises_and_routes() {
3056 use crate::signal::Signal;
3057 let flag = Signal::new(false);
3058 let flag_for_cb = flag.clone();
3059 let mut tree = WidgetTree::new();
3060 let id = tree.add(
3061 FillWidget::new()
3062 .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
3063 );
3064 tree.layout(SizeProposal::exact(50.0, 50.0));
3065 let info = tree.accessibility_node(id);
3066 assert!(info.actions().contains(&Action::ShowContextMenu));
3067 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
3068 action: Action::ShowContextMenu,
3069 target: Some(id),
3070 target_node: crate::accessibility::widget_id_to_node_id(id),
3071 data: None,
3072 });
3073 assert!(flag.get(), "callback should have been invoked");
3074 }
3075
3076 #[test]
3078 fn access_two_actions_both_route() {
3079 use crate::signal::Signal;
3080 let click = Signal::new(false);
3081 let increment = Signal::new(false);
3082 let click_cb = click.clone();
3083 let inc_cb = increment.clone();
3084 let mut tree = WidgetTree::new();
3085 let id = tree.add(
3086 FillWidget::new()
3087 .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
3088 .access_action(Action::Increment, move |_| inc_cb.set(true)),
3089 );
3090 tree.layout(SizeProposal::exact(50.0, 50.0));
3091 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
3092 action: Action::ShowContextMenu,
3093 target: Some(id),
3094 target_node: crate::accessibility::widget_id_to_node_id(id),
3095 data: None,
3096 });
3097 assert!(click.get() && !increment.get(), "only first action fired");
3098 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
3099 action: Action::Increment,
3100 target: Some(id),
3101 target_node: crate::accessibility::widget_id_to_node_id(id),
3102 data: None,
3103 });
3104 assert!(
3105 click.get() && increment.get(),
3106 "both actions fired exactly once each"
3107 );
3108 }
3109
3110 #[test]
3112 fn access_remove_action_suppresses_widget_action() {
3113 let mut tree = WidgetTree::new();
3114 let id = tree.add(ActionWidget.access_remove_action(Action::Click));
3115 tree.layout(SizeProposal::exact(50.0, 50.0));
3116 let info = tree.accessibility_node(id);
3117 assert!(!info.actions().contains(&Action::Click));
3118 assert!(info.actions().contains(&Action::Focus));
3119 }
3120
3121 #[test]
3123 fn access_custom_action_uses_localized_label() {
3124 let mut tree = WidgetTree::new();
3125 let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
3126 tree.layout(SizeProposal::exact(50.0, 50.0));
3127 let update = tree.sync_accessibility();
3128 let node = find_node(&update, id).unwrap();
3129 let actions = node.custom_actions();
3130 assert_eq!(actions.len(), 1);
3131 assert_eq!(actions[0].id, 0);
3132 assert_eq!(actions[0].description.as_str(), "Reply");
3133 }
3134
3135 #[test]
3137 fn access_custom_action_routes_by_index() {
3138 use crate::signal::Signal;
3139 let first = Signal::new(false);
3140 let second = Signal::new(false);
3141 let f = first.clone();
3142 let s = second.clone();
3143 let mut tree = WidgetTree::new();
3144 let id = tree.add(
3145 FillWidget::new()
3146 .access_custom_action_literal("First", move |_| f.set(true))
3147 .access_custom_action_literal("Second", move |_| s.set(true)),
3148 );
3149 tree.layout(SizeProposal::exact(50.0, 50.0));
3150 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
3151 action: Action::CustomAction,
3152 target: Some(id),
3153 target_node: crate::accessibility::widget_id_to_node_id(id),
3154 data: Some(accesskit::ActionData::CustomAction(1)),
3155 });
3156 assert!(!first.get(), "first should not fire");
3157 assert!(second.get(), "second should fire (index 1)");
3158 }
3159
3160 #[test]
3162 fn access_action_layered_with_on_access_action() {
3163 use crate::signal::Signal;
3164 let from_override = Signal::new(false);
3165 let from_user = Signal::new(false);
3166 let ov_cb = from_override.clone();
3167 let user_cb = from_user.clone();
3168 let mut tree = WidgetTree::new();
3169 let id = tree.add(
3170 FillWidget::new()
3171 .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
3172 .on_access_action(move |_action, _ctx| {
3173 user_cb.set(true);
3174 crate::event::EventResponse::Handled
3175 }),
3176 );
3177 tree.layout(SizeProposal::exact(50.0, 50.0));
3178 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
3179 action: Action::ShowContextMenu,
3180 target: Some(id),
3181 target_node: crate::accessibility::widget_id_to_node_id(id),
3182 data: None,
3183 });
3184 assert!(from_override.get(), "override callback fired");
3185 assert!(from_user.get(), "user catch-all fired");
3186 }
3187
3188 #[test]
3194 fn access_label_accepts_resolved_string_via_into() {
3195 struct ResolvedAtCall(String);
3199 impl From<ResolvedAtCall> for crate::signal::Prop<String> {
3200 fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
3201 crate::signal::Prop::Static(v.0)
3202 }
3203 }
3204 let mut tree = WidgetTree::new();
3205 let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
3206 tree.layout(SizeProposal::exact(50.0, 50.0));
3207 assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
3208 }
3209
3210 #[test]
3212 fn access_exclude_subtree_prunes_children_from_at_tree() {
3213 let mut tree = WidgetTree::new();
3214 let inner1 = tree.add(FillWidget::new().label("A"));
3215 let inner2 = tree.add(FillWidget::new().label("B"));
3216 let outer = tree.add(
3217 StackWidget::new()
3218 .add_child(inner1)
3219 .add_child(inner2)
3220 .access_label_literal("Section")
3224 .access_exclude_subtree(),
3225 );
3226 tree.layout(SizeProposal::exact(100.0, 50.0));
3227 let update = tree.sync_accessibility();
3228 assert!(find_node(&update, outer).is_some());
3230 assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
3231 assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
3232 let outer_node = find_node(&update, outer).unwrap();
3233 assert!(
3234 outer_node.children().is_empty(),
3235 "outer should have no AT children when excluded"
3236 );
3237 }
3238
3239 #[test]
3241 fn access_merge_subtree_concatenates_descendant_labels() {
3242 let mut tree = WidgetTree::new();
3243 let title = tree.add(FillWidget::new().label("Title"));
3244 let subtitle = tree.add(FillWidget::new().label("Subtitle"));
3245 let card = tree.add(
3246 StackWidget::new()
3247 .add_child(title)
3248 .add_child(subtitle)
3249 .access_merge_subtree(),
3250 );
3251 tree.layout(SizeProposal::exact(100.0, 50.0));
3252 assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
3254 let update = tree.sync_accessibility();
3255 assert!(find_node(&update, title).is_none());
3256 assert!(find_node(&update, subtitle).is_none());
3257 }
3258
3259 #[test]
3261 fn access_merge_subtree_unions_actions() {
3262 let mut tree = WidgetTree::new();
3263 let click_a = tree.add(ClickableWidget);
3264 let click_b = tree.add(ClickableWidget);
3265 let card = tree.add(
3266 StackWidget::new()
3267 .add_child(click_a)
3268 .add_child(click_b)
3269 .access_merge_subtree(),
3270 );
3271 tree.layout(SizeProposal::exact(100.0, 50.0));
3272 let actions = tree.accessibility_node(card).actions().to_vec();
3273 let click_count = actions.iter().filter(|a| **a == Action::Click).count();
3274 assert_eq!(
3275 click_count, 1,
3276 "Click should be present exactly once after merge (deduplicated)"
3277 );
3278 }
3279
3280 #[test]
3290 fn access_merge_subtree_first_nonempty_value_wins() {
3291 #[derive(Debug)]
3292 struct ValueWidget(&'static str);
3293 impl Widget for ValueWidget {
3294 fn layout_response(
3295 &self,
3296 proposal: SizeProposal,
3297 _ctx: &LayoutContext,
3298 ) -> crate::widget::LayoutResponse {
3299 proposal.resolve(0.0, 0.0).into()
3300 }
3301 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3302 builder.set_role(Role::Slider);
3303 builder.set_value(self.0);
3304 }
3305 }
3306 let mut tree = WidgetTree::new();
3307 let v1 = tree.add(ValueWidget("first"));
3308 let v2 = tree.add(ValueWidget("second"));
3309 let card = tree.add(
3310 StackWidget::new()
3311 .add_child(v1)
3312 .add_child(v2)
3313 .access_merge_subtree(),
3314 );
3315 tree.layout(SizeProposal::exact(100.0, 50.0));
3316 assert_eq!(tree.text_value(card), Some("first".to_string()));
3317 }
3318
3319 #[test]
3321 fn access_exclude_inside_merge() {
3322 let mut tree = WidgetTree::new();
3323 let visible = tree.add(FillWidget::new().label("VISIBLE"));
3324 let pruned = tree.add(FillWidget::new().label("PRUNED"));
3325 let inner_excluded = tree.add(
3326 StackWidget::new()
3327 .add_child(pruned)
3328 .access_exclude_subtree(),
3329 );
3330 let card = tree.add(
3331 StackWidget::new()
3332 .add_child(visible)
3333 .add_child(inner_excluded)
3334 .access_merge_subtree(),
3335 );
3336 tree.layout(SizeProposal::exact(100.0, 50.0));
3337 let merged = tree.text_content(card).unwrap_or_default();
3338 assert!(merged.contains("VISIBLE"));
3341 assert!(
3342 !merged.contains("PRUNED"),
3343 "excluded subtree should not contribute to merge"
3344 );
3345 }
3346
3347 #[test]
3349 fn access_merge_inside_merge() {
3350 let mut tree = WidgetTree::new();
3351 let inner_a = tree.add(FillWidget::new().label("a"));
3352 let inner_b = tree.add(FillWidget::new().label("b"));
3353 let inner_card = tree.add(
3354 StackWidget::new()
3355 .add_child(inner_a)
3356 .add_child(inner_b)
3357 .access_merge_subtree(),
3358 );
3359 let outer_extra = tree.add(FillWidget::new().label("X"));
3360 let outer = tree.add(
3361 StackWidget::new()
3362 .add_child(inner_card)
3363 .add_child(outer_extra)
3364 .access_merge_subtree(),
3365 );
3366 tree.layout(SizeProposal::exact(200.0, 100.0));
3367 let merged = tree.text_content(outer).unwrap_or_default();
3372 assert!(merged.contains("a"));
3373 assert!(merged.contains("b"));
3374 assert!(merged.contains("X"));
3375 let update = tree.sync_accessibility();
3377 assert!(find_node(&update, inner_a).is_none());
3378 assert!(find_node(&update, inner_b).is_none());
3379 assert!(find_node(&update, inner_card).is_none());
3380 assert!(find_node(&update, outer_extra).is_none());
3381 }
3382
3383 #[test]
3385 fn access_customize_runs_last() {
3386 let mut tree = WidgetTree::new();
3387 let id = tree.add(
3388 FillWidget::new()
3389 .access_label_literal("A")
3390 .access_customize(|b| b.set_name("B")),
3391 );
3392 tree.layout(SizeProposal::exact(50.0, 50.0));
3393 assert_eq!(tree.accessibility_node(id).name(), Some("B"));
3394 }
3395
3396 #[test]
3398 fn access_customize_can_reach_inner_mut() {
3399 let mut tree = WidgetTree::new();
3400 let id = tree.add(FillWidget::new().access_customize(|b| {
3401 b.inner_mut().set_author_id("from-customize");
3402 }));
3403 tree.layout(SizeProposal::exact(50.0, 50.0));
3404 let update = tree.sync_accessibility();
3405 let node = find_node(&update, id).unwrap();
3406 assert_eq!(node.author_id(), Some("from-customize"));
3407 }
3408
3409 #[test]
3411 fn access_overrides_zero_cost_when_unused() {
3412 use std::mem::size_of;
3419 assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
3420 assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
3421 }
3422
3423 #[test]
3424 fn accessibility_children_overrides_at_reading_order() {
3425 #[derive(Debug)]
3430 struct NamedLeaf(&'static str);
3431 impl Widget for NamedLeaf {
3432 fn layout_response(
3433 &self,
3434 proposal: SizeProposal,
3435 _ctx: &LayoutContext,
3436 ) -> crate::widget::LayoutResponse {
3437 proposal.resolve(10.0, 10.0).into()
3438 }
3439 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3440 builder.set_role(accesskit::Role::Button);
3444 builder.set_name(self.0);
3445 }
3446 }
3447
3448 #[derive(Debug, Default)]
3449 struct ReorderContainer {
3450 a: Option<WidgetId>,
3451 b: Option<WidgetId>,
3452 }
3453 impl Widget for ReorderContainer {
3454 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
3455 let a = ctx.add(NamedLeaf("A"));
3456 let b = ctx.add(NamedLeaf("B"));
3457 self.a = Some(a);
3458 self.b = Some(b);
3459 vec![a, b]
3460 }
3461 fn layout_response(
3462 &self,
3463 proposal: SizeProposal,
3464 _ctx: &LayoutContext,
3465 ) -> crate::widget::LayoutResponse {
3466 proposal.resolve(100.0, 100.0).into()
3467 }
3468 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3469 builder.set_role(accesskit::Role::Group);
3472 builder.set_name("Container");
3473 }
3474 fn children(&self) -> Vec<WidgetId> {
3475 [self.a, self.b].into_iter().flatten().collect()
3476 }
3477 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
3478 Some([self.b, self.a].into_iter().flatten().collect())
3480 }
3481 }
3482
3483 let mut tree = WidgetTree::new();
3484 let container = tree.add(ReorderContainer::default());
3485 tree.layout(SizeProposal::exact(100.0, 100.0));
3486 let update = tree.sync_accessibility();
3487
3488 let a_nid = update
3489 .nodes
3490 .iter()
3491 .find(|(_, n)| n.label() == Some("A"))
3492 .map(|(id, _)| *id)
3493 .expect("A node present");
3494 let b_nid = update
3495 .nodes
3496 .iter()
3497 .find(|(_, n)| n.label() == Some("B"))
3498 .map(|(id, _)| *id)
3499 .expect("B node present");
3500 let cnid = crate::accessibility::widget_id_to_node_id(container);
3501 let cnode = update
3502 .nodes
3503 .iter()
3504 .find(|(id, _)| *id == cnid)
3505 .map(|(_, n)| n)
3506 .expect("container node present");
3507 let kids = cnode.children();
3508 let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
3509 let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
3510 assert!(
3511 pb < pa,
3512 "accessibility_children() must set AT order B-before-A; got {kids:?}"
3513 );
3514 }
3515}