1use super::*;
5
6use crate::accessibility::{AccessNodeBuilder, AccessibilityInfo};
7
8impl WidgetTree {
9 pub fn sync_accessibility(&mut self) -> accesskit::TreeUpdate {
14 if self.a11y_update_requested.replace(false) {
19 self.a11y_dirty = true;
20 }
21 let current_shortcut_version = self.shortcut_registry.version().get();
29 if current_shortcut_version != self.last_synced_shortcut_version {
30 self.a11y_dirty = true;
31 self.last_synced_shortcut_version = current_shortcut_version;
32 }
33
34 let current_locale = self.locale_signal.get();
43 if current_locale != self.last_synced_locale {
44 self.a11y_dirty = true;
45 self.last_synced_locale = current_locale;
46 }
47
48 if !self.a11y_dirty
49 && let Some(cached) = &self.cached_a11y
50 {
51 return cached.clone();
52 }
53
54 let (update, parents) = self.build_accessibility_tree();
55 self.collect_announcements(&update);
61 let content_changed = self.cached_a11y.as_ref() != Some(&update);
68 self.cached_a11y = Some(update.clone());
69 self.synthetic_parent_map = parents;
70 self.a11y_dirty = false;
71 if content_changed {
72 self.at_version.set(self.at_version.get().saturating_add(1));
75 }
76 update
77 }
78
79 fn collect_announcements(&mut self, update: &accesskit::TreeUpdate) {
85 use accesskit::Live;
86 let mut seen: std::collections::HashSet<accesskit::NodeId> =
87 std::collections::HashSet::with_capacity(self.automation_last_text.len());
88 for (node_id, node) in &update.nodes {
89 let assertive = match node.live() {
90 Some(Live::Polite) => false,
91 Some(Live::Assertive) => true,
92 _ => continue,
94 };
95 let text = node
98 .value()
99 .or_else(|| node.label())
100 .unwrap_or("")
101 .trim()
102 .to_string();
103 if text.is_empty() {
104 self.automation_last_text.remove(node_id);
109 continue;
110 }
111 seen.insert(*node_id);
112 let changed = self
113 .automation_last_text
114 .get(node_id)
115 .map(|prev| prev != &text)
116 .unwrap_or(true);
117 if !changed {
118 continue;
119 }
120 self.automation_last_text.insert(*node_id, text.clone());
121 self.automation_announce_seq = self.automation_announce_seq.saturating_add(1);
122 if self.automation_announcements.len() >= super::AUTOMATION_ANNOUNCE_CAP {
123 self.automation_announcements.pop_front();
124 }
125 self.automation_announcements
126 .push_back(crate::accessibility::Announcement {
127 seq: self.automation_announce_seq,
128 text,
129 assertive,
130 });
131 }
132 self.automation_last_text.retain(|k, _| seen.contains(k));
135 }
136
137 pub fn dispatch_access_action(
160 &mut self,
161 node_id: accesskit::NodeId,
162 action: accesskit::Action,
163 data: Option<accesskit::ActionData>,
164 ops: &mut dyn crate::window::WindowOps,
165 ) -> bool {
166 let target = crate::accessibility::node_id_to_widget_id_maybe(node_id)
167 .or_else(|| self.widget_for_synthetic(node_id));
168 let event = crate::event::WidgetEvent::AccessAction {
169 action,
170 target,
171 target_node: node_id,
172 data,
173 };
174 self.access_action_handled = false;
175 self.dispatch_event_with_ops(event, ops);
176 self.access_action_handled
177 }
178
179 fn build_accessibility_tree(
180 &self,
181 ) -> (
182 accesskit::TreeUpdate,
183 std::collections::HashMap<accesskit::NodeId, WidgetId>,
184 ) {
185 use crate::accessibility::{root_node_id, widget_id_to_node_id};
186
187 let roots = self.arena.roots();
188 let mut nodes: Vec<(accesskit::NodeId, accesskit::Node)> = Vec::new();
189 let mut synthetic_parents: std::collections::HashMap<accesskit::NodeId, WidgetId> =
190 std::collections::HashMap::new();
191 let mut seen_children: std::collections::HashMap<accesskit::NodeId, WidgetId> =
196 std::collections::HashMap::new();
197
198 let mut root = accesskit::Node::new(accesskit::Role::Window);
199 if let Some(locale) = self.locale_signal.get() {
206 root.set_language(locale);
207 }
208 for &root_id in &roots {
209 if self.arena.is_active(root_id) {
210 let child_nid = widget_id_to_node_id(root_id);
211 if seen_children.insert(child_nid, root_id).is_none() {
212 root.push_child(child_nid);
213 } else {
214 eprintln!(
215 "Teksilo bug: duplicate accessibility child {:?} in Window root — \
216 already claimed by another parent. Please file a bug report.",
217 root_id
218 );
219 }
220 }
221 }
222 nodes.push((root_node_id(), root));
223
224 let mut handled_tooltips: std::collections::HashSet<WidgetId> =
228 std::collections::HashSet::new();
229 for &root_id in &roots {
230 self.build_accessibility_recursive(
231 root_id,
232 &mut nodes,
233 &mut synthetic_parents,
234 &mut seen_children,
235 &mut handled_tooltips,
236 );
237 }
238
239 let focus = self
240 .focused
241 .filter(|id| self.arena.is_active(*id))
242 .map(widget_id_to_node_id)
243 .unwrap_or_else(root_node_id);
244
245 {
261 use accesskit::Role;
262 use std::collections::HashMap;
263
264 fn names_from_content(role: Role) -> bool {
268 matches!(role, Role::TreeItem | Role::ListBoxOption)
269 }
270
271 let index: HashMap<accesskit::NodeId, usize> = nodes
272 .iter()
273 .enumerate()
274 .map(|(i, (nid, _))| (*nid, i))
275 .collect();
276
277 let mut hoisted: Vec<(usize, String)> = Vec::new();
278 for (i, (_, node)) in nodes.iter().enumerate() {
279 if !names_from_content(node.role()) || node.label().is_some() {
280 continue;
281 }
282 let mut stack: Vec<accesskit::NodeId> =
284 node.children().iter().rev().copied().collect();
285 while let Some(nid) = stack.pop() {
286 let Some(&j) = index.get(&nid) else { continue };
287 let descendant = &nodes[j].1;
288 if names_from_content(descendant.role()) {
289 continue;
290 }
291 match descendant.label() {
292 Some(label) if !label.trim().is_empty() => {
293 hoisted.push((i, label.to_string()));
294 break;
295 }
296 _ => stack.extend(descendant.children().iter().rev().copied()),
297 }
298 }
299 }
300 for (i, label) in hoisted {
301 nodes[i].1.set_label(label);
302 }
303 }
304
305 {
321 use std::collections::{HashMap, HashSet};
322
323 let mut relation_targets: HashSet<accesskit::NodeId> = HashSet::new();
326 for (_, node) in &nodes {
327 relation_targets.extend(node.controls());
328 relation_targets.extend(node.described_by());
329 relation_targets.extend(node.labelled_by());
330 }
331
332 let prunable: HashSet<accesskit::NodeId> = nodes
333 .iter()
334 .filter(|(nid, node)| {
335 *nid != root_node_id()
336 && *nid != focus
337 && !relation_targets.contains(nid)
338 && is_presentational_container(node)
339 })
340 .map(|(nid, _)| *nid)
341 .collect();
342
343 if !prunable.is_empty() {
344 let children_map: HashMap<accesskit::NodeId, Vec<accesskit::NodeId>> = nodes
346 .iter()
347 .map(|(nid, node)| (*nid, node.children().to_vec()))
348 .collect();
349
350 fn resolve(
355 nid: accesskit::NodeId,
356 children_map: &HashMap<accesskit::NodeId, Vec<accesskit::NodeId>>,
357 prunable: &HashSet<accesskit::NodeId>,
358 memo: &mut HashMap<accesskit::NodeId, Vec<accesskit::NodeId>>,
359 ) -> Vec<accesskit::NodeId> {
360 if let Some(cached) = memo.get(&nid) {
361 return cached.clone();
362 }
363 let mut out = Vec::new();
364 if let Some(kids) = children_map.get(&nid) {
365 for &c in kids {
366 if prunable.contains(&c) {
367 out.extend(resolve(c, children_map, prunable, memo));
368 } else {
369 out.push(c);
370 }
371 }
372 }
373 memo.insert(nid, out.clone());
374 out
375 }
376
377 let mut memo: HashMap<accesskit::NodeId, Vec<accesskit::NodeId>> = HashMap::new();
378 for (nid, node) in &mut nodes {
379 if prunable.contains(nid) {
380 continue;
381 }
382 let resolved = resolve(*nid, &children_map, &prunable, &mut memo);
383 if children_map.get(nid) != Some(&resolved) {
384 node.set_children(resolved);
385 }
386 }
387 nodes.retain(|(nid, _)| !prunable.contains(nid));
388 }
389 }
390
391 let emitted: std::collections::HashSet<accesskit::NodeId> =
398 nodes.iter().map(|(id, _)| *id).collect();
399 for (_, node) in &mut nodes {
400 let controlled: Vec<_> = node
401 .controls()
402 .iter()
403 .filter(|id| emitted.contains(*id))
404 .copied()
405 .collect();
406 if controlled.len() != node.controls().len() {
407 node.set_controls(controlled);
408 }
409 let described: Vec<_> = node
410 .described_by()
411 .iter()
412 .filter(|id| emitted.contains(*id))
413 .copied()
414 .collect();
415 if described.len() != node.described_by().len() {
416 node.set_described_by(described);
417 }
418 }
419
420 (
421 accesskit::TreeUpdate {
422 nodes,
423 tree: Some(accesskit::TreeInfo::new(root_node_id())),
424 tree_id: accesskit::TreeId::ROOT,
425 focus,
426 },
427 synthetic_parents,
428 )
429 }
430
431 pub fn widget_for_synthetic(&self, node_id: accesskit::NodeId) -> Option<WidgetId> {
439 self.synthetic_parent_map.get(&node_id).copied()
440 }
441
442 fn build_accessibility_recursive(
443 &self,
444 id: WidgetId,
445 nodes: &mut Vec<(accesskit::NodeId, accesskit::Node)>,
446 synthetic_parents: &mut std::collections::HashMap<accesskit::NodeId, WidgetId>,
447 seen_children: &mut std::collections::HashMap<accesskit::NodeId, WidgetId>,
448 handled_tooltips: &mut std::collections::HashSet<WidgetId>,
454 ) {
455 use crate::accessibility::widget_id_to_node_id;
456 use crate::widget_builder::AccessSubtreeMode;
457
458 if !self.arena.is_active(id) {
459 return;
460 }
461
462 let node = self.arena.get(id).expect("widget id is active in arena");
463 let mut builder = AccessNodeBuilder::for_widget(id);
464 node.widget.accessibility(&mut builder);
465 self.announce_context_menu(id, &mut builder);
469
470 if let Some(ov) = node.access_overrides.as_deref() {
474 ov.apply(&mut builder);
475 if let Some(ref id) = ov.shortcut_id
483 && let Some(eff) = self.shortcut_registry.effective(id)
484 && let Some(ks) = eff.primary
485 {
486 builder.set_keyboard_shortcut(ks.to_string());
487 }
488 }
489
490 let subtree_mode = node.access_subtree;
491 let at_children_owned;
499 let children: &[WidgetId] = match node.widget.accessibility_children() {
500 Some(v) => {
501 at_children_owned = v;
502 &at_children_owned
503 }
504 None => self.arena.children(id),
505 };
506
507 match subtree_mode {
513 AccessSubtreeMode::Inherit => {
514 for &child_id in children {
515 if self.arena.is_active(child_id) {
516 let child_nid = widget_id_to_node_id(child_id);
517 if self.ancestor_chain_redirects(id, child_id) {
534 seen_children.insert(child_nid, id);
537 continue;
538 }
539 if let Some(&prior_parent) = seen_children.get(&child_nid) {
540 eprintln!(
541 "Teksilo bug: duplicate accessibility child {:?}: \
542 first claimed by parent {:?}, now also claimed by {:?}. \
543 Please file a bug report.",
544 child_id, prior_parent, id
545 );
546 continue;
547 }
548 seen_children.insert(child_nid, id);
549 builder.inner_mut().push_child(child_nid);
550 }
551 }
552 }
553 AccessSubtreeMode::Exclude => {
554 }
556 AccessSubtreeMode::Merge => {
557 merge_descendants_into(&mut builder, id, &self.arena);
558 }
559 }
560
561 let bounds = self.arena.bounds(id);
562 builder.inner_mut().set_bounds(accesskit::Rect {
563 x0: bounds.x as f64,
564 y0: bounds.y as f64,
565 x1: (bounds.x + bounds.width) as f64,
566 y1: (bounds.y + bounds.height) as f64,
567 });
568
569 let force_clear_disabled =
574 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
575 if !self.arena.is_enabled(id) && !force_clear_disabled {
576 builder.set_disabled();
577 }
578
579 if let Some(content_id) =
599 self.tooltip_description_target(id, handled_tooltips, &mut builder)
600 {
601 if self
602 .tooltips
603 .iter()
604 .any(|t| t.content_id == content_id && t.overlay_id.is_some())
605 {
606 builder
609 .inner_mut()
610 .push_described_by(widget_id_to_node_id(self.tooltip_content_node(content_id)));
611 handled_tooltips.insert(content_id);
612 } else if let Some(text) = self.tooltip_access_description(content_id) {
613 builder.set_description(text);
621 handled_tooltips.insert(content_id);
622 }
623 }
624
625 let (node_id, ak_node, synthetic_children) = builder.build(id);
626 nodes.push((node_id, ak_node));
627 for (syn_id, syn_node) in synthetic_children {
633 nodes.push((syn_id, syn_node));
634 synthetic_parents.insert(syn_id, id);
635 }
636
637 if matches!(subtree_mode, AccessSubtreeMode::Inherit) {
640 for &child_id in children {
641 self.build_accessibility_recursive(
642 child_id,
643 nodes,
644 synthetic_parents,
645 seen_children,
646 handled_tooltips,
647 );
648 }
649 }
650 }
651
652 fn ancestor_chain_redirects(&self, parent_id: WidgetId, descendant: WidgetId) -> bool {
669 let mut current = Some(parent_id);
670 while let Some(curr) = current {
671 let Some(curr_node) = self.arena.get(curr) else {
672 break;
673 };
674 if curr_node.widget.wants_descendant_redirects()
675 && curr_node
676 .widget
677 .a11y_redirect_descendant(curr, descendant)
678 .is_some()
679 {
680 return true;
681 }
682 current = self.arena.parent(curr);
683 }
684 false
685 }
686
687 fn tooltip_description_target(
719 &self,
720 id: WidgetId,
721 handled: &std::collections::HashSet<WidgetId>,
722 builder: &mut AccessNodeBuilder,
723 ) -> Option<WidgetId> {
724 let mut claims = self
725 .tooltips
726 .iter()
727 .filter(|t| t.description_owner_id == id && !handled.contains(&t.content_id));
728 if let (Some(only), None) = (claims.next(), claims.next())
731 && only.anchor_id != id
732 && !is_presentational_container(builder.inner_mut())
738 && builder.inner_mut().description().is_none()
743 {
744 return Some(only.content_id);
745 }
746 self.tooltips
747 .iter()
748 .find(|t| t.anchor_id == id && !handled.contains(&t.content_id))
749 .map(|t| t.content_id)
750 }
751
752 fn announce_context_menu(&self, id: WidgetId, builder: &mut AccessNodeBuilder) {
773 if self
774 .arena
775 .get(id)
776 .is_some_and(|node| node.context_menu_factory.is_some())
777 && !builder
778 .actions()
779 .contains(&accesskit::Action::ShowContextMenu)
780 {
781 builder.add_action(accesskit::Action::ShowContextMenu);
782 }
783 }
784
785 fn build_overridden_builder(&self, id: WidgetId) -> AccessNodeBuilder {
786 use crate::widget_builder::AccessSubtreeMode;
787 let node = self.arena.get(id).expect("widget id is active in arena");
788 let mut builder = AccessNodeBuilder::for_widget(id);
789 node.widget.accessibility(&mut builder);
790 self.announce_context_menu(id, &mut builder);
791 if let Some(ov) = node.access_overrides.as_deref() {
792 ov.apply(&mut builder);
793 if let Some(ref sid) = ov.shortcut_id
796 && let Some(eff) = self.shortcut_registry.effective(sid)
797 && let Some(ks) = eff.primary
798 {
799 builder.set_keyboard_shortcut(ks.to_string());
800 }
801 }
802 if node.access_subtree == AccessSubtreeMode::Merge {
803 merge_descendants_into(&mut builder, id, &self.arena);
804 }
805 builder
806 }
807
808 pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
826 self.arena
827 .get(content_id)
828 .and_then(|node| node.widget.as_any())
829 .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
830 .and_then(|d| d.materialized_child())
831 .unwrap_or(content_id)
832 }
833
834 pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
835 let content_id = self.tooltip_content_node(content_id);
836 let node = self.arena.get(content_id)?;
837 let mut probe = AccessNodeBuilder::for_widget(content_id);
838 node.widget.accessibility(&mut probe);
839 if let Some(ov) = node.access_overrides.as_deref() {
840 ov.apply(&mut probe);
841 }
842 probe
843 .name()
844 .map(str::trim)
845 .filter(|name| !name.is_empty())
846 .map(str::to_owned)
847 }
848
849 pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
850 let node = self.arena.get(id).expect("widget id is active in arena");
851 let builder = self.build_overridden_builder(id);
852 let role = builder.role();
853 let name = builder.name().map(|s| s.to_string());
854 let actions = builder.actions().to_vec();
855 let mut info = AccessibilityInfo::new(role, name, actions);
856 if let Some(toggled) = builder.toggled() {
857 info = info.with_toggled(toggled);
858 }
859 if let Some(expanded) = builder.expanded() {
860 info = info.with_expanded(expanded);
861 }
862 if let Some(selected) = builder.selected() {
863 info = info.with_selected(selected);
864 }
865 let force_clear_disabled =
869 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
870 let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
871 let disabled_override =
876 node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(true);
877 if disabled_arena || disabled_override {
878 info = info.with_disabled(true);
879 }
880 if builder.is_hidden() {
881 info = info.with_hidden(true);
882 }
883 info
884 }
885
886 pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
887 self.arena
888 .active_ids_iter()
889 .find(|&id| self.build_overridden_builder(id).role() == role)
890 }
891
892 pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
893 self.arena
894 .active_ids_iter()
895 .find(|&id| self.build_overridden_builder(id).name() == Some(label))
896 }
897
898 pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
899 self.arena.active_ids_iter().find(|&id| {
900 self.build_overridden_builder(id)
901 .actions()
902 .contains(&action)
903 })
904 }
905
906 pub fn text_content(&self, id: WidgetId) -> Option<String> {
910 self.arena.get(id)?;
911 self.build_overridden_builder(id)
912 .name()
913 .map(|s| s.to_string())
914 }
915
916 pub fn text_value(&self, id: WidgetId) -> Option<String> {
920 self.arena.get(id)?;
921 self.build_overridden_builder(id)
922 .value()
923 .map(|s| s.to_string())
924 }
925}
926
927fn is_presentational_container(node: &accesskit::Node) -> bool {
941 use accesskit::Role;
942 if !matches!(node.role(), Role::GenericContainer | Role::Unknown) {
943 return false;
944 }
945 let mut probe = node.clone();
955 probe.clear_children();
956 probe.clear_bounds();
957 probe.clear_disabled();
958 format!("{probe:?}") == format!("{:?}", accesskit::Node::new(node.role()))
959}
960
961fn merge_descendants_into(
983 parent_builder: &mut AccessNodeBuilder,
984 parent_id: WidgetId,
985 arena: &crate::arena::WidgetArena,
986) {
987 let mut acc = MergeAccumulator::default();
988 for &child in arena.children(parent_id) {
989 merge_collect_recursive(child, arena, &mut acc);
990 }
991 acc.flush_into(parent_builder);
992}
993
994fn merge_collect_recursive(
995 id: WidgetId,
996 arena: &crate::arena::WidgetArena,
997 acc: &mut MergeAccumulator,
998) {
999 use crate::widget_builder::AccessSubtreeMode;
1000
1001 if !arena.is_active(id) {
1002 return;
1003 }
1004 let Some(node) = arena.get(id) else {
1005 return;
1006 };
1007
1008 let mut tmp = AccessNodeBuilder::for_widget(id);
1013 node.widget.accessibility(&mut tmp);
1014 if let Some(ov) = node.access_overrides.as_deref() {
1015 ov.apply(&mut tmp);
1016 }
1017 if matches!(node.access_subtree, AccessSubtreeMode::Merge) {
1022 merge_descendants_into(&mut tmp, id, arena);
1023 }
1024 if !tmp.is_hidden() {
1028 acc.absorb(&tmp);
1029 }
1030
1031 match node.access_subtree {
1032 AccessSubtreeMode::Exclude => {
1033 }
1035 AccessSubtreeMode::Merge => {
1036 }
1041 AccessSubtreeMode::Inherit => {
1042 for &grandchild in arena.children(id) {
1043 merge_collect_recursive(grandchild, arena, acc);
1044 }
1045 }
1046 }
1047}
1048
1049#[derive(Default)]
1063struct MergeAccumulator {
1064 label_parts: Vec<String>,
1065 value: Option<String>,
1067 actions: Vec<accesskit::Action>,
1068}
1069
1070impl MergeAccumulator {
1071 fn absorb(&mut self, src: &AccessNodeBuilder) {
1072 if let Some(name) = src.name()
1073 && !name.is_empty()
1074 {
1075 self.label_parts.push(name.to_string());
1076 }
1077 if let Some(value) = src.value()
1078 && self.value.is_none()
1079 && !value.is_empty()
1080 {
1081 self.value = Some(value.to_string());
1082 }
1083 for &action in src.actions() {
1084 if !self.actions.contains(&action) {
1085 self.actions.push(action);
1086 }
1087 }
1088 }
1089
1090 fn flush_into(self, dst: &mut AccessNodeBuilder) {
1091 if !self.label_parts.is_empty() {
1094 let existing = dst.name().map(|s| s.to_string());
1095 let merged = match existing {
1096 Some(e) if !e.is_empty() => {
1097 let mut s = e;
1098 for part in self.label_parts {
1099 s.push(' ');
1100 s.push_str(&part);
1101 }
1102 s
1103 }
1104 _ => self.label_parts.join(" "),
1105 };
1106 dst.set_name(merged);
1107 }
1108 if let Some(v) = self.value {
1109 if dst.value().is_none() {
1113 dst.set_value(v);
1114 }
1115 }
1116 for action in self.actions {
1117 if !dst.actions().contains(&action) {
1120 dst.add_action(action);
1121 }
1122 }
1123 }
1124}
1125
1126#[cfg(test)]
1127pub(crate) mod test_helpers {
1128 pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
1134 accesskit_consumer::Tree::new(update.clone(), false);
1135 }
1136
1137 pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
1142 let emitted: std::collections::HashSet<accesskit::NodeId> =
1143 update.nodes.iter().map(|(id, _)| *id).collect();
1144 for (parent_id, node) in &update.nodes {
1145 for &target in node.controls() {
1146 assert!(
1147 emitted.contains(&target),
1148 "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
1149 );
1150 }
1151 for &target in node.described_by() {
1152 assert!(
1153 emitted.contains(&target),
1154 "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
1155 );
1156 }
1157 }
1158 }
1159
1160 #[allow(dead_code)]
1162 pub(crate) fn nodes_with_role(
1163 update: &accesskit::TreeUpdate,
1164 role: accesskit::Role,
1165 ) -> Vec<accesskit::NodeId> {
1166 update
1167 .nodes
1168 .iter()
1169 .filter(|(_, node)| node.role() == role)
1170 .map(|(id, _)| *id)
1171 .collect()
1172 }
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177 use super::test_helpers::*;
1178 use super::*;
1179 use crate::test_widgets::{FillWidget, StackWidget};
1180
1181 #[derive(Debug)]
1182 struct ActionWidget;
1183
1184 impl Widget for ActionWidget {
1185 fn layout_response(
1186 &self,
1187 proposal: SizeProposal,
1188 _ctx: &LayoutContext,
1189 ) -> crate::widget::LayoutResponse {
1190 proposal.resolve(0.0, 0.0).into()
1191 }
1192
1193 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1194 builder.set_role(accesskit::Role::Button);
1195 builder.set_name("Save");
1196 builder.add_action(accesskit::Action::Click);
1197 builder.add_action(accesskit::Action::Focus);
1198 }
1199 }
1200
1201 #[derive(Debug)]
1202 struct ClickableWidget;
1203
1204 impl Widget for ClickableWidget {
1205 fn layout_response(
1206 &self,
1207 proposal: SizeProposal,
1208 _ctx: &LayoutContext,
1209 ) -> crate::widget::LayoutResponse {
1210 proposal.resolve(0.0, 0.0).into()
1211 }
1212
1213 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1214 builder.set_role(accesskit::Role::Button);
1215 builder.set_name("Click Me");
1216 builder.add_action(accesskit::Action::Click);
1217 }
1218 }
1219
1220 #[test]
1221 fn labeled_widget_has_accessibility() {
1222 let mut tree = WidgetTree::new();
1223 let widget = tree.add(FillWidget::new().label("Hello"));
1224 tree.layout(SizeProposal::exact(100.0, 20.0));
1225 let info = tree.accessibility_node(widget);
1226 assert_eq!(info.role(), accesskit::Role::Label);
1227 assert_eq!(info.name(), Some("Hello"));
1228 }
1229
1230 #[test]
1231 fn find_by_label_works() {
1232 let mut tree = WidgetTree::new();
1233 let widget = tree.add(FillWidget::new().label("Save"));
1234 tree.layout(SizeProposal::exact(100.0, 20.0));
1235 assert_eq!(tree.find_by_label("Save"), Some(widget));
1236 }
1237
1238 #[test]
1239 fn label_role_emits_text_as_value_not_label() {
1240 let mut tree = WidgetTree::new();
1247 let widget = tree.add(FillWidget::new().label("Section")); tree.layout(SizeProposal::exact(100.0, 20.0));
1249
1250 assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1252
1253 let update = tree.sync_accessibility();
1255 let node = &update
1256 .nodes
1257 .iter()
1258 .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1259 .expect("label node must be present in the tree update")
1260 .1;
1261 assert_eq!(node.role(), accesskit::Role::Label);
1262 assert_eq!(
1263 node.value(),
1264 Some("Section"),
1265 "Role::Label text must be exposed via `value`"
1266 );
1267 assert_eq!(
1268 node.label(),
1269 None,
1270 "Role::Label must not leave text in the `label` property"
1271 );
1272 }
1273
1274 #[test]
1275 fn find_by_role_works() {
1276 let mut tree = WidgetTree::new();
1277 tree.add(FillWidget::new().label("Text"));
1278 tree.layout(SizeProposal::exact(100.0, 20.0));
1279 assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1280 }
1281
1282 #[test]
1283 fn access_hidden_bound_to_signal_toggles_reactively() {
1284 use crate::signal::Signal;
1285 use crate::widget_builder::WidgetBuilder;
1286 let hidden = Signal::new(false);
1287 let mut tree = WidgetTree::new();
1288 let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1289 tree.layout(SizeProposal::exact(100.0, 30.0));
1290 assert!(
1291 !tree.accessibility_node(w).is_hidden(),
1292 "node should be visible to AT while the signal is false"
1293 );
1294 hidden.set(true);
1295 assert!(
1297 tree.accessibility_node(w).is_hidden(),
1298 "node should hide from AT when the bound signal flips to true"
1299 );
1300 hidden.set(false);
1301 assert!(!tree.accessibility_node(w).is_hidden());
1302 }
1303
1304 #[test]
1308 fn a_widget_with_a_context_menu_advertises_the_action() {
1309 let mut tree = WidgetTree::new();
1310 let plain = tree.add(FillWidget::new().label("no menu"));
1311 let with_menu = tree.add(
1312 FillWidget::new()
1313 .label("has menu")
1314 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1315 );
1316 tree.layout(SizeProposal::exact(100.0, 20.0));
1317
1318 assert!(
1319 tree.accessibility_node(with_menu)
1320 .actions()
1321 .contains(&Action::ShowContextMenu),
1322 "a node owning a context-menu factory must announce it"
1323 );
1324 assert!(
1325 !tree
1326 .accessibility_node(plain)
1327 .actions()
1328 .contains(&Action::ShowContextMenu),
1329 "and a node without one must not"
1330 );
1331 }
1332
1333 #[test]
1336 fn a_removed_context_menu_action_stays_removed() {
1337 let mut tree = WidgetTree::new();
1338 let id = tree.add(
1339 FillWidget::new()
1340 .label("suppressed")
1341 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1342 .access_remove_action(Action::ShowContextMenu),
1343 );
1344 tree.layout(SizeProposal::exact(100.0, 20.0));
1345 assert!(
1346 !tree
1347 .accessibility_node(id)
1348 .actions()
1349 .contains(&Action::ShowContextMenu)
1350 );
1351 }
1352
1353 #[test]
1358 fn a_child_does_not_advertise_its_parents_context_menu() {
1359 let mut tree = WidgetTree::new();
1360 let child = tree.add(FillWidget::new().label("inner"));
1361 let parent = tree.add(
1362 StackWidget::new()
1363 .add_child(child)
1364 .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1365 );
1366 tree.layout(SizeProposal::exact(100.0, 20.0));
1367
1368 assert!(
1369 tree.accessibility_node(parent)
1370 .actions()
1371 .contains(&Action::ShowContextMenu)
1372 );
1373 assert!(
1374 !tree
1375 .accessibility_node(child)
1376 .actions()
1377 .contains(&Action::ShowContextMenu),
1378 "the menu belongs to the widget that declared it"
1379 );
1380 }
1381
1382 #[test]
1383 fn accessibility_node_collects_actions() {
1384 let mut tree = WidgetTree::new();
1385 let widget = tree.add(ActionWidget);
1386 tree.layout(SizeProposal::exact(100.0, 40.0));
1387
1388 let info = tree.accessibility_node(widget);
1389 assert_eq!(info.role(), accesskit::Role::Button);
1390 assert_eq!(info.name(), Some("Save"));
1391 assert_eq!(info.actions().len(), 2);
1392 assert!(info.actions().contains(&accesskit::Action::Click));
1393 assert!(info.actions().contains(&accesskit::Action::Focus));
1394 }
1395
1396 #[test]
1397 fn sync_accessibility_produces_tree_update() {
1398 let mut tree = WidgetTree::new();
1399 tree.add(FillWidget::new().label("First"));
1400 tree.add(FillWidget::new().label("Second"));
1401 tree.layout(SizeProposal::exact(200.0, 100.0));
1402
1403 let update = tree.sync_accessibility();
1404 assert_eq!(update.nodes.len(), 3);
1405 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1406 assert!(update.tree.is_some());
1407 assert_a11y_tree_valid(&update);
1408 }
1409
1410 #[test]
1411 fn root_node_carries_locale_as_language() {
1412 let mut tree = WidgetTree::new();
1413 tree.add(FillWidget::new().label("Bonjour"));
1414
1415 tree.layout(SizeProposal::exact(200.0, 100.0));
1417 let update = tree.sync_accessibility();
1418 assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1419 assert_eq!(
1420 update.nodes[0].1.language(),
1421 None,
1422 "no language before a locale is set"
1423 );
1424
1425 tree.set_locale("fr-FR".to_string());
1428 let update = tree.sync_accessibility();
1429 assert_eq!(
1430 update.nodes[0].1.language(),
1431 Some("fr-FR"),
1432 "root node must advertise the active locale as its language"
1433 );
1434 }
1435
1436 #[test]
1437 fn sync_accessibility_excludes_dormant_widgets() {
1438 let mut tree = WidgetTree::new();
1439 tree.add(FillWidget::new().label("Active"));
1440 let dormant = tree.add(FillWidget::new().label("Dormant"));
1441 tree.layout(SizeProposal::exact(200.0, 100.0));
1442
1443 tree.set_dormant(dormant);
1444
1445 let update = tree.sync_accessibility();
1446 assert_eq!(update.nodes.len(), 2);
1447 assert_a11y_tree_valid(&update);
1448 }
1449
1450 #[test]
1457 fn relayout_without_activation_change_does_not_dirty_a11y() {
1458 let mut tree = WidgetTree::new();
1459 let id = tree.add(FillWidget::new().label("Static"));
1460 tree.layout(SizeProposal::exact(200.0, 100.0));
1461
1462 let _ = tree.sync_accessibility();
1464 assert!(
1465 !tree.a11y_dirty,
1466 "sync_accessibility must clear the dirty flag"
1467 );
1468
1469 tree.arena.mark_needs_layout(id);
1473 tree.layout(SizeProposal::exact(200.0, 100.0));
1474
1475 assert!(
1476 !tree.a11y_dirty,
1477 "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1478 );
1479 }
1480
1481 #[test]
1485 fn activation_transition_does_dirty_a11y() {
1486 let mut tree = WidgetTree::new();
1487 let id = tree.add(FillWidget::new().label("Toggle"));
1488 tree.layout(SizeProposal::exact(200.0, 100.0));
1489 let _ = tree.sync_accessibility();
1490 assert!(!tree.a11y_dirty);
1491
1492 tree.set_dormant(id);
1493 tree.layout(SizeProposal::exact(200.0, 100.0));
1494 assert!(
1495 tree.a11y_dirty,
1496 "active→dormant transition must dirty the AT cache so the dormant node is removed"
1497 );
1498 }
1499
1500 #[test]
1501 fn sync_accessibility_includes_focus() {
1502 let mut tree = WidgetTree::new();
1503 let widget = tree.add(FillWidget::new().focusable().label("Focused"));
1504 tree.layout(SizeProposal::exact(100.0, 50.0));
1505 tree.focus(widget);
1506
1507 let update = tree.sync_accessibility();
1508 let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
1509 assert_eq!(update.focus, expected_focus);
1510 assert_a11y_tree_valid(&update);
1511 }
1512
1513 #[test]
1514 fn sync_accessibility_parent_child_relationship() {
1515 let mut tree = WidgetTree::new();
1516 let child = tree.add(FillWidget::new().label("Child"));
1517 let parent = tree.add(
1520 StackWidget::new()
1521 .add_child(child)
1522 .access_label_literal("Parent"),
1523 );
1524 tree.layout(SizeProposal::exact(100.0, 50.0));
1525
1526 let update = tree.sync_accessibility();
1527 assert_eq!(update.nodes.len(), 3);
1528
1529 let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
1530 let parent_node = update
1531 .nodes
1532 .iter()
1533 .find(|(id, _)| *id == parent_node_id)
1534 .map(|(_, node)| node)
1535 .unwrap();
1536
1537 let child_node_id = crate::accessibility::widget_id_to_node_id(child);
1538 assert!(parent_node.children().contains(&child_node_id));
1539 assert_a11y_tree_valid(&update);
1540 }
1541
1542 #[test]
1543 fn presentational_containers_collapse_and_promote_children() {
1544 let mut tree = WidgetTree::new();
1550 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(
1554 StackWidget::new()
1555 .add_child(outer)
1556 .access_label_literal("Group"),
1557 );
1558 tree.layout(SizeProposal::exact(100.0, 50.0));
1559 let update = tree.sync_accessibility();
1560
1561 assert!(find_node(&update, inner).is_none(), "bare inner pruned");
1562 assert!(find_node(&update, outer).is_none(), "bare outer pruned");
1563
1564 let labeled_node = find_node(&update, labeled).expect("labeled group survives");
1565 let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
1566 assert!(
1567 labeled_node.children().contains(&leaf_nid),
1568 "leaf promoted past both bare containers to the labeled ancestor"
1569 );
1570 assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
1571 assert_a11y_tree_valid(&update);
1572 }
1573
1574 #[test]
1575 fn find_by_action_finds_clickable() {
1576 let mut tree = WidgetTree::new();
1577 let widget = tree.add(ClickableWidget);
1578 tree.layout(SizeProposal::exact(100.0, 40.0));
1579
1580 assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
1581 assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
1582 }
1583
1584 #[test]
1585 fn text_content_returns_accessibility_name() {
1586 let mut tree = WidgetTree::new();
1587 let widget = tree.add(FillWidget::new().label("Hello World"));
1588 tree.layout(SizeProposal::exact(100.0, 50.0));
1589
1590 assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
1591 }
1592
1593 #[test]
1594 fn text_content_returns_none_without_label() {
1595 let mut tree = WidgetTree::new();
1596 let widget = tree.add(FillWidget::new());
1597 tree.layout(SizeProposal::exact(100.0, 50.0));
1598
1599 assert_eq!(tree.text_content(widget), None);
1600 }
1601
1602 #[test]
1603 fn descendant_of_disabled_ancestor_reports_disabled() {
1604 use crate::signal::Signal;
1605
1606 let mut tree = WidgetTree::new();
1607 let child = tree.add(FillWidget::new().label("Child"));
1608 let parent = tree.add(StackWidget::new().add_child(child));
1609 tree.enabled_when(parent, Signal::new(false));
1610 tree.layout(SizeProposal::exact(100.0, 50.0));
1611
1612 assert!(
1613 tree.accessibility_node(child).is_disabled(),
1614 "descendant should report disabled when ancestor is disabled"
1615 );
1616 }
1617
1618 #[test]
1619 fn text_value_returns_accessibility_value() {
1620 #[derive(Debug)]
1621 struct ValueWidget;
1622
1623 impl Widget for ValueWidget {
1624 fn layout_response(
1625 &self,
1626 proposal: SizeProposal,
1627 _ctx: &LayoutContext,
1628 ) -> crate::widget::LayoutResponse {
1629 proposal.resolve(0.0, 0.0).into()
1630 }
1631
1632 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1633 builder.set_role(accesskit::Role::Slider);
1634 builder.set_name("Volume");
1635 builder.set_value("75%");
1636 }
1637 }
1638
1639 let mut tree = WidgetTree::new();
1640 let widget = tree.add(ValueWidget);
1641 tree.layout(SizeProposal::exact(100.0, 40.0));
1642
1643 assert_eq!(tree.text_value(widget), Some("75%".to_string()));
1644 assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
1645 }
1646
1647 #[test]
1648 fn sync_accessibility_has_no_duplicate_children() {
1649 let mut tree = WidgetTree::new();
1653 let grandchild = tree.add(FillWidget::new().label("Grandchild"));
1654 let child_a = tree.add(StackWidget::new().add_child(grandchild));
1655 let child_b = tree.add(FillWidget::new().label("Sibling"));
1656 let _root = tree.add(StackWidget::new().add_child(child_a).add_child(child_b));
1657 tree.layout(SizeProposal::exact(200.0, 100.0));
1658
1659 let update = tree.sync_accessibility();
1660
1661 let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
1662 std::collections::HashMap::new();
1663 for (parent_id, node) in &update.nodes {
1664 for &child_id in node.children() {
1665 let prev = all_children.insert(child_id, *parent_id);
1666 assert!(
1667 prev.is_none(),
1668 "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
1669 );
1670 }
1671 }
1672 assert_a11y_tree_valid(&update);
1673 }
1674
1675 #[test]
1676 fn no_dangling_relationships_in_basic_tree() {
1677 let mut tree = WidgetTree::new();
1678 let child = tree.add(FillWidget::new().label("Child"));
1679 let _parent = tree.add(StackWidget::new().add_child(child));
1680 tree.layout(SizeProposal::exact(100.0, 50.0));
1681
1682 let update = tree.sync_accessibility();
1683 assert_no_dangling_relationships(&update);
1684 assert_a11y_tree_valid(&update);
1685 }
1686
1687 use crate::widget_builder::WidgetBuilder;
1692 use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
1693
1694 fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
1696 let nid = crate::accessibility::widget_id_to_node_id(id);
1697 update
1698 .nodes
1699 .iter()
1700 .find(|(node_id, _)| *node_id == nid)
1701 .map(|(_, n)| n)
1702 }
1703
1704 #[derive(Debug)]
1707 struct AlwaysHiddenWidget;
1708 impl Widget for AlwaysHiddenWidget {
1709 fn layout_response(
1710 &self,
1711 proposal: SizeProposal,
1712 _ctx: &LayoutContext,
1713 ) -> crate::widget::LayoutResponse {
1714 proposal.resolve(0.0, 0.0).into()
1715 }
1716 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1717 builder.set_role(Role::GenericContainer);
1718 builder.set_hidden();
1719 }
1720 }
1721
1722 #[test]
1724 fn access_label_replaces_widget_label() {
1725 let mut tree = WidgetTree::new();
1726 let id = tree.add(ClickableWidget.access_label_literal("Publish"));
1727 tree.layout(SizeProposal::exact(100.0, 40.0));
1728 assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
1729 }
1730
1731 #[test]
1733 fn access_description_appears_on_bare_widget() {
1734 let mut tree = WidgetTree::new();
1735 let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
1736 tree.layout(SizeProposal::exact(50.0, 50.0));
1737 let update = tree.sync_accessibility();
1738 let node = find_node(&update, id).expect("node present");
1739 assert_eq!(node.description(), Some("Decorative"));
1740 }
1741
1742 #[test]
1744 fn access_value_replaces_widget_value() {
1745 #[derive(Debug)]
1746 struct SliderWidget;
1747 impl Widget for SliderWidget {
1748 fn layout_response(
1749 &self,
1750 proposal: SizeProposal,
1751 _ctx: &LayoutContext,
1752 ) -> crate::widget::LayoutResponse {
1753 proposal.resolve(0.0, 0.0).into()
1754 }
1755 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1756 builder.set_role(Role::Slider);
1757 builder.set_value("50");
1758 }
1759 }
1760 let mut tree = WidgetTree::new();
1761 let id = tree.add(SliderWidget.access_value_literal("Custom"));
1762 tree.layout(SizeProposal::exact(100.0, 40.0));
1763 assert_eq!(tree.text_value(id), Some("Custom".to_string()));
1764 }
1765
1766 #[test]
1768 fn access_role_overrides_widget_role() {
1769 let mut tree = WidgetTree::new();
1770 let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
1771 tree.layout(SizeProposal::exact(100.0, 40.0));
1772 assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
1773 }
1774
1775 #[test]
1776 fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
1777 let mut tree = WidgetTree::new();
1784 let anchor = tree.add(FillWidget::new().label("Save"));
1785 let tip = tree.add(FillWidget::new().label("Save the file"));
1786 tree.layout(SizeProposal::exact(200.0, 100.0));
1787 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
1788
1789 assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
1790 let update = tree.sync_accessibility();
1791 let node = find_node(&update, anchor).expect("anchor node present");
1792 assert_eq!(
1793 node.description(),
1794 Some("Save the file"),
1795 "the anchor must describe itself with its tooltip text while unshown"
1796 );
1797 }
1798
1799 #[test]
1800 fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
1801 let mut tree = WidgetTree::new();
1804 let anchor = tree.add(FillWidget::new().label("Save"));
1805 let tip = tree.add(FillWidget::new().label("Save the file"));
1806 tree.layout(SizeProposal::exact(200.0, 100.0));
1807 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
1808
1809 tree.pointer_move(tree.bounds(anchor).center());
1810 tree.advance_time(std::time::Duration::from_millis(150));
1811 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
1812
1813 let update = tree.sync_accessibility();
1814 let node = find_node(&update, anchor).expect("anchor node present");
1815 assert!(
1816 !node.described_by().is_empty(),
1817 "a shown tooltip is wired as a described_by relation"
1818 );
1819 }
1820
1821 #[test]
1823 fn access_hint_alias_writes_description_field() {
1824 let mut tree = WidgetTree::new();
1825 let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
1826 tree.layout(SizeProposal::exact(50.0, 50.0));
1827 let update = tree.sync_accessibility();
1828 let node = find_node(&update, id).unwrap();
1829 assert_eq!(node.description(), Some("Tip"));
1830 }
1831
1832 #[test]
1834 fn access_identifier_writes_author_id() {
1835 let mut tree = WidgetTree::new();
1836 let id = tree.add(FillWidget::new().access_identifier("save-button"));
1837 tree.layout(SizeProposal::exact(50.0, 50.0));
1838 let update = tree.sync_accessibility();
1839 let node = find_node(&update, id).unwrap();
1840 assert_eq!(node.author_id(), Some("save-button"));
1841 }
1842
1843 #[test]
1845 fn access_shortcut_literal_set() {
1846 let mut tree = WidgetTree::new();
1847 let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
1848 tree.layout(SizeProposal::exact(50.0, 50.0));
1849 let update = tree.sync_accessibility();
1850 let node = find_node(&update, id).unwrap();
1851 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
1852 }
1853
1854 #[test]
1857 fn access_shortcut_id_resolves_and_tracks_rebinds() {
1858 use crate::event::Key;
1859 use crate::shortcut::{KeyStroke, Shortcut};
1860
1861 let mut tree = WidgetTree::new();
1862 tree.shortcut_registry_mut().register(
1863 Shortcut::new("app.save")
1864 .name("Save")
1865 .primary(KeyStroke::command(Key::S))
1866 .build(),
1867 );
1868 let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
1869 tree.layout(SizeProposal::exact(50.0, 50.0));
1870
1871 let update = tree.sync_accessibility();
1873 let node = find_node(&update, id).unwrap();
1874 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
1875
1876 tree.shortcut_registry_mut()
1878 .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
1879 let update = tree.sync_accessibility();
1880 let node = find_node(&update, id).unwrap();
1881 assert_eq!(node.keyboard_shortcut(), Some("Ctrl+Q"));
1882 }
1883
1884 #[test]
1887 fn access_shortcut_id_unknown_id_omits_announcement() {
1888 let mut tree = WidgetTree::new();
1889 let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
1890 tree.layout(SizeProposal::exact(50.0, 50.0));
1891 let update = tree.sync_accessibility();
1892 let node = find_node(&update, id).unwrap();
1893 assert_eq!(node.keyboard_shortcut(), None);
1894 }
1895
1896 #[test]
1898 fn access_hidden_true_hides_widget() {
1899 let mut tree = WidgetTree::new();
1900 let id = tree.add(ClickableWidget.access_hidden(true));
1901 tree.layout(SizeProposal::exact(50.0, 50.0));
1902 assert!(tree.accessibility_node(id).is_hidden());
1903 }
1904
1905 #[test]
1907 fn access_hidden_false_clears_widget_set_hidden() {
1908 let mut tree = WidgetTree::new();
1909 let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
1910 tree.layout(SizeProposal::exact(50.0, 50.0));
1911 assert!(
1912 !tree.accessibility_node(id).is_hidden(),
1913 "access_hidden(false) should clear widget-emitted hidden"
1914 );
1915 }
1916
1917 #[test]
1919 fn access_disabled_true_marks_disabled() {
1920 let mut tree = WidgetTree::new();
1921 let id = tree.add(FillWidget::new().access_disabled(true));
1922 tree.layout(SizeProposal::exact(50.0, 50.0));
1923 assert!(tree.accessibility_node(id).is_disabled());
1924 }
1925
1926 #[test]
1928 fn access_disabled_false_clears_arena_driven_disabled() {
1929 use crate::signal::Signal;
1930 let mut tree = WidgetTree::new();
1931 let id = tree.add(FillWidget::new().label("X").access_disabled(false));
1932 tree.enabled_when(id, Signal::new(false));
1933 tree.layout(SizeProposal::exact(50.0, 50.0));
1934 assert!(
1935 !tree.accessibility_node(id).is_disabled(),
1936 "access_disabled(false) should clear even arena-driven disabled"
1937 );
1938 }
1939
1940 #[test]
1942 fn access_controls_appends() {
1943 let mut tree = WidgetTree::new();
1944 let target = tree.add(FillWidget::new().label("Target"));
1945 let controller = tree.add(
1946 FillWidget::new()
1947 .label("Controller")
1948 .access_controls(target),
1949 );
1950 tree.layout(SizeProposal::exact(100.0, 50.0));
1951 let update = tree.sync_accessibility();
1952 let node = find_node(&update, controller).unwrap();
1953 let target_nid = crate::accessibility::widget_id_to_node_id(target);
1954 assert!(
1955 node.controls().contains(&target_nid),
1956 "controls list should contain the target NodeId"
1957 );
1958 }
1959
1960 #[test]
1962 fn access_described_by_appends() {
1963 let mut tree = WidgetTree::new();
1964 let other = tree.add(FillWidget::new().label("Desc"));
1965 let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
1966 tree.layout(SizeProposal::exact(100.0, 50.0));
1967 let update = tree.sync_accessibility();
1968 let node = find_node(&update, id).unwrap();
1969 let other_nid = crate::accessibility::widget_id_to_node_id(other);
1970 assert!(node.described_by().contains(&other_nid));
1971 }
1972
1973 #[test]
1975 fn access_labelled_by_appends() {
1976 let mut tree = WidgetTree::new();
1977 let other = tree.add(FillWidget::new().label("Lbl"));
1978 let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
1979 tree.layout(SizeProposal::exact(100.0, 50.0));
1980 let update = tree.sync_accessibility();
1981 let node = find_node(&update, id).unwrap();
1982 let other_nid = crate::accessibility::widget_id_to_node_id(other);
1983 assert!(node.labelled_by().contains(&other_nid));
1984 }
1985
1986 #[test]
1988 fn access_live_assertive_set() {
1989 let mut tree = WidgetTree::new();
1990 let id = tree.add(FillWidget::new().access_live(Live::Assertive));
1991 tree.layout(SizeProposal::exact(50.0, 50.0));
1992 let update = tree.sync_accessibility();
1993 let node = find_node(&update, id).unwrap();
1994 assert_eq!(node.live(), Some(Live::Assertive));
1995 }
1996
1997 #[test]
1999 fn access_aria_current_set() {
2000 let mut tree = WidgetTree::new();
2001 let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
2002 tree.layout(SizeProposal::exact(50.0, 50.0));
2003 let update = tree.sync_accessibility();
2004 let node = find_node(&update, id).unwrap();
2005 assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
2006 }
2007
2008 #[test]
2010 fn access_has_popup_set() {
2011 let mut tree = WidgetTree::new();
2012 let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
2013 tree.layout(SizeProposal::exact(50.0, 50.0));
2014 let update = tree.sync_accessibility();
2015 let node = find_node(&update, id).unwrap();
2016 assert_eq!(node.has_popup(), Some(HasPopup::Menu));
2017 }
2018
2019 #[test]
2021 fn access_orientation_set() {
2022 let mut tree = WidgetTree::new();
2023 let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
2024 tree.layout(SizeProposal::exact(50.0, 50.0));
2025 let update = tree.sync_accessibility();
2026 let node = find_node(&update, id).unwrap();
2027 assert_eq!(node.orientation(), Some(Orientation::Vertical));
2028 }
2029
2030 #[test]
2032 fn access_numeric_value_and_range() {
2033 let mut tree = WidgetTree::new();
2034 let id = tree.add(
2035 FillWidget::new()
2036 .access_role(Role::Slider)
2037 .access_numeric_value(50.0)
2038 .access_numeric_range(0.0, 100.0)
2039 .access_numeric_step(5.0),
2040 );
2041 tree.layout(SizeProposal::exact(50.0, 50.0));
2042 let update = tree.sync_accessibility();
2043 let node = find_node(&update, id).unwrap();
2044 assert_eq!(node.numeric_value(), Some(50.0));
2045 assert_eq!(node.min_numeric_value(), Some(0.0));
2046 assert_eq!(node.max_numeric_value(), Some(100.0));
2047 assert_eq!(node.numeric_value_step(), Some(5.0));
2048 }
2049
2050 #[test]
2052 fn access_action_advertises_and_routes() {
2053 use crate::signal::Signal;
2054 let flag = Signal::new(false);
2055 let flag_for_cb = flag.clone();
2056 let mut tree = WidgetTree::new();
2057 let id = tree.add(
2058 FillWidget::new()
2059 .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
2060 );
2061 tree.layout(SizeProposal::exact(50.0, 50.0));
2062 let info = tree.accessibility_node(id);
2063 assert!(info.actions().contains(&Action::ShowContextMenu));
2064 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2065 action: Action::ShowContextMenu,
2066 target: Some(id),
2067 target_node: crate::accessibility::widget_id_to_node_id(id),
2068 data: None,
2069 });
2070 assert!(flag.get(), "callback should have been invoked");
2071 }
2072
2073 #[test]
2075 fn access_two_actions_both_route() {
2076 use crate::signal::Signal;
2077 let click = Signal::new(false);
2078 let increment = Signal::new(false);
2079 let click_cb = click.clone();
2080 let inc_cb = increment.clone();
2081 let mut tree = WidgetTree::new();
2082 let id = tree.add(
2083 FillWidget::new()
2084 .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
2085 .access_action(Action::Increment, move |_| inc_cb.set(true)),
2086 );
2087 tree.layout(SizeProposal::exact(50.0, 50.0));
2088 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2089 action: Action::ShowContextMenu,
2090 target: Some(id),
2091 target_node: crate::accessibility::widget_id_to_node_id(id),
2092 data: None,
2093 });
2094 assert!(click.get() && !increment.get(), "only first action fired");
2095 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2096 action: Action::Increment,
2097 target: Some(id),
2098 target_node: crate::accessibility::widget_id_to_node_id(id),
2099 data: None,
2100 });
2101 assert!(
2102 click.get() && increment.get(),
2103 "both actions fired exactly once each"
2104 );
2105 }
2106
2107 #[test]
2109 fn access_remove_action_suppresses_widget_action() {
2110 let mut tree = WidgetTree::new();
2111 let id = tree.add(ActionWidget.access_remove_action(Action::Click));
2112 tree.layout(SizeProposal::exact(50.0, 50.0));
2113 let info = tree.accessibility_node(id);
2114 assert!(!info.actions().contains(&Action::Click));
2115 assert!(info.actions().contains(&Action::Focus));
2116 }
2117
2118 #[test]
2120 fn access_custom_action_uses_localized_label() {
2121 let mut tree = WidgetTree::new();
2122 let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
2123 tree.layout(SizeProposal::exact(50.0, 50.0));
2124 let update = tree.sync_accessibility();
2125 let node = find_node(&update, id).unwrap();
2126 let actions = node.custom_actions();
2127 assert_eq!(actions.len(), 1);
2128 assert_eq!(actions[0].id, 0);
2129 assert_eq!(actions[0].description.as_str(), "Reply");
2130 }
2131
2132 #[test]
2134 fn access_custom_action_routes_by_index() {
2135 use crate::signal::Signal;
2136 let first = Signal::new(false);
2137 let second = Signal::new(false);
2138 let f = first.clone();
2139 let s = second.clone();
2140 let mut tree = WidgetTree::new();
2141 let id = tree.add(
2142 FillWidget::new()
2143 .access_custom_action_literal("First", move |_| f.set(true))
2144 .access_custom_action_literal("Second", move |_| s.set(true)),
2145 );
2146 tree.layout(SizeProposal::exact(50.0, 50.0));
2147 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2148 action: Action::CustomAction,
2149 target: Some(id),
2150 target_node: crate::accessibility::widget_id_to_node_id(id),
2151 data: Some(accesskit::ActionData::CustomAction(1)),
2152 });
2153 assert!(!first.get(), "first should not fire");
2154 assert!(second.get(), "second should fire (index 1)");
2155 }
2156
2157 #[test]
2159 fn access_action_layered_with_on_access_action() {
2160 use crate::signal::Signal;
2161 let from_override = Signal::new(false);
2162 let from_user = Signal::new(false);
2163 let ov_cb = from_override.clone();
2164 let user_cb = from_user.clone();
2165 let mut tree = WidgetTree::new();
2166 let id = tree.add(
2167 FillWidget::new()
2168 .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
2169 .on_access_action(move |_action, _ctx| {
2170 user_cb.set(true);
2171 crate::event::EventResponse::Handled
2172 }),
2173 );
2174 tree.layout(SizeProposal::exact(50.0, 50.0));
2175 tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2176 action: Action::ShowContextMenu,
2177 target: Some(id),
2178 target_node: crate::accessibility::widget_id_to_node_id(id),
2179 data: None,
2180 });
2181 assert!(from_override.get(), "override callback fired");
2182 assert!(from_user.get(), "user catch-all fired");
2183 }
2184
2185 #[test]
2191 fn access_label_accepts_resolved_string_via_into() {
2192 struct ResolvedAtCall(String);
2196 impl From<ResolvedAtCall> for crate::signal::Prop<String> {
2197 fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
2198 crate::signal::Prop::Static(v.0)
2199 }
2200 }
2201 let mut tree = WidgetTree::new();
2202 let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
2203 tree.layout(SizeProposal::exact(50.0, 50.0));
2204 assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
2205 }
2206
2207 #[test]
2209 fn access_exclude_subtree_prunes_children_from_at_tree() {
2210 let mut tree = WidgetTree::new();
2211 let inner1 = tree.add(FillWidget::new().label("A"));
2212 let inner2 = tree.add(FillWidget::new().label("B"));
2213 let outer = tree.add(
2214 StackWidget::new()
2215 .add_child(inner1)
2216 .add_child(inner2)
2217 .access_label_literal("Section")
2221 .access_exclude_subtree(),
2222 );
2223 tree.layout(SizeProposal::exact(100.0, 50.0));
2224 let update = tree.sync_accessibility();
2225 assert!(find_node(&update, outer).is_some());
2227 assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
2228 assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
2229 let outer_node = find_node(&update, outer).unwrap();
2230 assert!(
2231 outer_node.children().is_empty(),
2232 "outer should have no AT children when excluded"
2233 );
2234 }
2235
2236 #[test]
2238 fn access_merge_subtree_concatenates_descendant_labels() {
2239 let mut tree = WidgetTree::new();
2240 let title = tree.add(FillWidget::new().label("Title"));
2241 let subtitle = tree.add(FillWidget::new().label("Subtitle"));
2242 let card = tree.add(
2243 StackWidget::new()
2244 .add_child(title)
2245 .add_child(subtitle)
2246 .access_merge_subtree(),
2247 );
2248 tree.layout(SizeProposal::exact(100.0, 50.0));
2249 assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
2251 let update = tree.sync_accessibility();
2252 assert!(find_node(&update, title).is_none());
2253 assert!(find_node(&update, subtitle).is_none());
2254 }
2255
2256 #[test]
2258 fn access_merge_subtree_unions_actions() {
2259 let mut tree = WidgetTree::new();
2260 let click_a = tree.add(ClickableWidget);
2261 let click_b = tree.add(ClickableWidget);
2262 let card = tree.add(
2263 StackWidget::new()
2264 .add_child(click_a)
2265 .add_child(click_b)
2266 .access_merge_subtree(),
2267 );
2268 tree.layout(SizeProposal::exact(100.0, 50.0));
2269 let actions = tree.accessibility_node(card).actions().to_vec();
2270 let click_count = actions.iter().filter(|a| **a == Action::Click).count();
2271 assert_eq!(
2272 click_count, 1,
2273 "Click should be present exactly once after merge (deduplicated)"
2274 );
2275 }
2276
2277 #[test]
2287 fn access_merge_subtree_first_nonempty_value_wins() {
2288 #[derive(Debug)]
2289 struct ValueWidget(&'static str);
2290 impl Widget for ValueWidget {
2291 fn layout_response(
2292 &self,
2293 proposal: SizeProposal,
2294 _ctx: &LayoutContext,
2295 ) -> crate::widget::LayoutResponse {
2296 proposal.resolve(0.0, 0.0).into()
2297 }
2298 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2299 builder.set_role(Role::Slider);
2300 builder.set_value(self.0);
2301 }
2302 }
2303 let mut tree = WidgetTree::new();
2304 let v1 = tree.add(ValueWidget("first"));
2305 let v2 = tree.add(ValueWidget("second"));
2306 let card = tree.add(
2307 StackWidget::new()
2308 .add_child(v1)
2309 .add_child(v2)
2310 .access_merge_subtree(),
2311 );
2312 tree.layout(SizeProposal::exact(100.0, 50.0));
2313 assert_eq!(tree.text_value(card), Some("first".to_string()));
2314 }
2315
2316 #[test]
2318 fn access_exclude_inside_merge() {
2319 let mut tree = WidgetTree::new();
2320 let visible = tree.add(FillWidget::new().label("VISIBLE"));
2321 let pruned = tree.add(FillWidget::new().label("PRUNED"));
2322 let inner_excluded = tree.add(
2323 StackWidget::new()
2324 .add_child(pruned)
2325 .access_exclude_subtree(),
2326 );
2327 let card = tree.add(
2328 StackWidget::new()
2329 .add_child(visible)
2330 .add_child(inner_excluded)
2331 .access_merge_subtree(),
2332 );
2333 tree.layout(SizeProposal::exact(100.0, 50.0));
2334 let merged = tree.text_content(card).unwrap_or_default();
2335 assert!(merged.contains("VISIBLE"));
2338 assert!(
2339 !merged.contains("PRUNED"),
2340 "excluded subtree should not contribute to merge"
2341 );
2342 }
2343
2344 #[test]
2346 fn access_merge_inside_merge() {
2347 let mut tree = WidgetTree::new();
2348 let inner_a = tree.add(FillWidget::new().label("a"));
2349 let inner_b = tree.add(FillWidget::new().label("b"));
2350 let inner_card = tree.add(
2351 StackWidget::new()
2352 .add_child(inner_a)
2353 .add_child(inner_b)
2354 .access_merge_subtree(),
2355 );
2356 let outer_extra = tree.add(FillWidget::new().label("X"));
2357 let outer = tree.add(
2358 StackWidget::new()
2359 .add_child(inner_card)
2360 .add_child(outer_extra)
2361 .access_merge_subtree(),
2362 );
2363 tree.layout(SizeProposal::exact(200.0, 100.0));
2364 let merged = tree.text_content(outer).unwrap_or_default();
2369 assert!(merged.contains("a"));
2370 assert!(merged.contains("b"));
2371 assert!(merged.contains("X"));
2372 let update = tree.sync_accessibility();
2374 assert!(find_node(&update, inner_a).is_none());
2375 assert!(find_node(&update, inner_b).is_none());
2376 assert!(find_node(&update, inner_card).is_none());
2377 assert!(find_node(&update, outer_extra).is_none());
2378 }
2379
2380 #[test]
2382 fn access_customize_runs_last() {
2383 let mut tree = WidgetTree::new();
2384 let id = tree.add(
2385 FillWidget::new()
2386 .access_label_literal("A")
2387 .access_customize(|b| b.set_name("B")),
2388 );
2389 tree.layout(SizeProposal::exact(50.0, 50.0));
2390 assert_eq!(tree.accessibility_node(id).name(), Some("B"));
2391 }
2392
2393 #[test]
2395 fn access_customize_can_reach_inner_mut() {
2396 let mut tree = WidgetTree::new();
2397 let id = tree.add(FillWidget::new().access_customize(|b| {
2398 b.inner_mut().set_author_id("from-customize");
2399 }));
2400 tree.layout(SizeProposal::exact(50.0, 50.0));
2401 let update = tree.sync_accessibility();
2402 let node = find_node(&update, id).unwrap();
2403 assert_eq!(node.author_id(), Some("from-customize"));
2404 }
2405
2406 #[test]
2408 fn access_overrides_zero_cost_when_unused() {
2409 use std::mem::size_of;
2416 assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
2417 assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
2418 }
2419
2420 #[test]
2421 fn accessibility_children_overrides_at_reading_order() {
2422 #[derive(Debug)]
2427 struct NamedLeaf(&'static str);
2428 impl Widget for NamedLeaf {
2429 fn layout_response(
2430 &self,
2431 proposal: SizeProposal,
2432 _ctx: &LayoutContext,
2433 ) -> crate::widget::LayoutResponse {
2434 proposal.resolve(10.0, 10.0).into()
2435 }
2436 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2437 builder.set_role(accesskit::Role::Button);
2441 builder.set_name(self.0);
2442 }
2443 }
2444
2445 #[derive(Debug, Default)]
2446 struct ReorderContainer {
2447 a: Option<WidgetId>,
2448 b: Option<WidgetId>,
2449 }
2450 impl Widget for ReorderContainer {
2451 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2452 let a = ctx.add(NamedLeaf("A"));
2453 let b = ctx.add(NamedLeaf("B"));
2454 self.a = Some(a);
2455 self.b = Some(b);
2456 vec![a, b]
2457 }
2458 fn layout_response(
2459 &self,
2460 proposal: SizeProposal,
2461 _ctx: &LayoutContext,
2462 ) -> crate::widget::LayoutResponse {
2463 proposal.resolve(100.0, 100.0).into()
2464 }
2465 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2466 builder.set_role(accesskit::Role::Group);
2469 builder.set_name("Container");
2470 }
2471 fn children(&self) -> Vec<WidgetId> {
2472 [self.a, self.b].into_iter().flatten().collect()
2473 }
2474 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2475 Some([self.b, self.a].into_iter().flatten().collect())
2477 }
2478 }
2479
2480 let mut tree = WidgetTree::new();
2481 let container = tree.add(ReorderContainer::default());
2482 tree.layout(SizeProposal::exact(100.0, 100.0));
2483 let update = tree.sync_accessibility();
2484
2485 let a_nid = update
2486 .nodes
2487 .iter()
2488 .find(|(_, n)| n.label() == Some("A"))
2489 .map(|(id, _)| *id)
2490 .expect("A node present");
2491 let b_nid = update
2492 .nodes
2493 .iter()
2494 .find(|(_, n)| n.label() == Some("B"))
2495 .map(|(id, _)| *id)
2496 .expect("B node present");
2497 let cnid = crate::accessibility::widget_id_to_node_id(container);
2498 let cnode = update
2499 .nodes
2500 .iter()
2501 .find(|(id, _)| *id == cnid)
2502 .map(|(_, n)| n)
2503 .expect("container node present");
2504 let kids = cnode.children();
2505 let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
2506 let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
2507 assert!(
2508 pb < pa,
2509 "accessibility_children() must set AT order B-before-A; got {kids:?}"
2510 );
2511 }
2512}