teksilo_core/widget_tree/accessibility_emit_impl.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! AccessKit node emission: the walker that turns the widget arena into
5//! `accesskit::Node` values — bounds, roles, properties, synthetic children,
6//! and the subtree-merge helpers it leans on.
7
8use super::*;
9
10use super::accessibility_impl::to_accesskit_rect;
11use crate::accessibility::{AccessNodeBuilder, to_accesskit_affine};
12
13/// The node an adapter tells a reader holds focus: the focused node's
14/// `active_descendant` when that names a node in the update, which is how
15/// `accesskit_consumer` resolves it (`tree.rs:537-543`, `node.rs:966-971`).
16fn focus_as_read(
17 nodes: &[(accesskit::NodeId, accesskit::Node)],
18 focus: accesskit::NodeId,
19) -> accesskit::NodeId {
20 nodes
21 .iter()
22 .find(|(id, _)| *id == focus)
23 .and_then(|(_, node)| node.active_descendant())
24 .filter(|descendant| nodes.iter().any(|(id, _)| id == descendant))
25 .unwrap_or(focus)
26}
27
28impl WidgetTree {
29 /// Walk the arena into a `TreeUpdate`.
30 ///
31 /// `speak` lets a message the framework's announcers hold ready enter the
32 /// update, which it then does unless the update moves focus; the last
33 /// element of the result says whether it did. A snapshot passes `false`
34 /// and describes the tree the adapters have. See [`crate::announcer`].
35 #[allow(clippy::type_complexity)]
36 pub(super) fn build_accessibility_tree(
37 &self,
38 speak: bool,
39 ) -> (
40 accesskit::TreeUpdate,
41 std::collections::HashMap<accesskit::NodeId, WidgetId>,
42 std::collections::HashMap<accesskit::NodeId, teksilo_canvas::Rect>,
43 super::accessibility_description_impl::DescriptionMemory,
44 bool,
45 ) {
46 use crate::accessibility::{root_node_id, widget_id_to_node_id};
47
48 let roots = self.arena.roots();
49 let mut nodes: Vec<(accesskit::NodeId, accesskit::Node)> = Vec::new();
50 let mut synthetic_parents: std::collections::HashMap<accesskit::NodeId, WidgetId> =
51 std::collections::HashMap::new();
52 // Global deduplication: AccessKit's consumer panics if the same child
53 // NodeId appears in more than one node's children list across a TreeUpdate.
54 // Track which widget first claimed each child so we can skip duplicates
55 // and emit a diagnostic pointing at the two conflicting parents.
56 let mut seen_children: std::collections::HashMap<accesskit::NodeId, WidgetId> =
57 std::collections::HashMap::new();
58 // Where each synthetic child sits inside its owner, for the ones
59 // that declared local bounds. A pure translation re-places them
60 // from here without walking again.
61 let mut local_bounds: std::collections::HashMap<accesskit::NodeId, teksilo_canvas::Rect> =
62 std::collections::HashMap::new();
63
64 let mut root = accesskit::Node::new(accesskit::Role::Window);
65 // Every rectangle below is logical; AccessKit wants physical. One
66 // transform here converts the whole tree, synthetic children included.
67 // See [`super::accessibility_impl::emit_root_transform`].
68 super::accessibility_impl::emit_root_transform(&mut root, self.device_scale_factor());
69 // Tag the root with the app's current locale (BCP-47, e.g. "fr-FR").
70 // AccessKit nodes inherit `language` from their ancestors, so setting
71 // it once on the Window node propagates to the whole tree. Without it,
72 // VoiceOver/Narrator have no language hint and fall back to a default
73 // (often English) TTS voice instead of the user's system voice. The
74 // locale is fed in by the app layer via `WidgetTree::set_locale`.
75 if let Some(locale) = self.locale_signal.get() {
76 root.set_language(locale);
77 }
78 // The window's name. On AT-SPI the root is the window, and its label
79 // is the only name `accesskit_atspi_common` gives the frame and sends
80 // with `window:activate` (`adapter.rs:551-560`): without it Orca
81 // announces every window as an unnamed "frame". Windows and macOS read
82 // the title from the native window instead (`accesskit_windows`
83 // `node.rs:1061-1068`; `accesskit_macos` `node.rs:316-321` drops a
84 // root window's title on purpose), so this changes nothing there.
85 if let Some(title) = self.window_title_for_accessibility() {
86 root.set_label(title);
87 }
88 for &root_id in &roots {
89 if self.arena.is_active(root_id) {
90 let child_nid = widget_id_to_node_id(root_id);
91 if seen_children.insert(child_nid, root_id).is_none() {
92 root.push_child(child_nid);
93 } else {
94 eprintln!(
95 "Teksilo bug: duplicate accessibility child {:?} in Window root — \
96 already claimed by another parent. Please file a bug report.",
97 root_id
98 );
99 }
100 }
101 }
102 nodes.push((root_node_id(), root));
103
104 // One pass, one set: a tooltip is placed once, on the first node that
105 // may have it, and the walk order makes that the owner rather than the
106 // anchor.
107 let mut handled_tooltips: std::collections::HashSet<WidgetId> =
108 std::collections::HashSet::new();
109 let mut proxies = ProxyLedger::default();
110 for &root_id in &roots {
111 self.build_accessibility_recursive(
112 root_id,
113 teksilo_canvas::Transform2D::IDENTITY,
114 &mut nodes,
115 &mut synthetic_parents,
116 &mut local_bounds,
117 &mut seen_children,
118 &mut handled_tooltips,
119 &mut proxies,
120 );
121 }
122
123 // A relation that names a composite names the proxy that stands for
124 // it: the composite's own node is structure, and the presentational
125 // pass below may drop it. Run before that pass, which keeps any node a
126 // relation still names.
127 proxies.redirect_relations(&mut nodes);
128
129 // The focused node, resolved to something this walk actually emitted.
130 //
131 // Active is necessary and not sufficient. A widget can be alive and
132 // still absent from the tree — inside an `access_exclude_subtree`, or
133 // left out of an ancestor's `accessibility_children` (which is how
134 // `SceneView` publishes fewer cards than it keeps warm). Naming it as
135 // the focus is not a missing link but a broken update: the consumer
136 // every platform adapter is built on resolves `focus` against the
137 // nodes it was handed and rejects a tree where it cannot.
138 //
139 // So walk up to the nearest ancestor that did emit, which is the node
140 // a screen reader would have put the user in anyway, and fall back to
141 // the window root only if nothing on the chain emitted. The set costs
142 // one pass over the nodes already built.
143 let emitted: std::collections::HashSet<accesskit::NodeId> =
144 nodes.iter().map(|(id, _)| *id).collect();
145 let focus = self
146 .focused
147 .filter(|id| self.arena.is_active(*id))
148 .and_then(|id| {
149 let mut current = Some(id);
150 while let Some(curr) = current {
151 let nid = widget_id_to_node_id(curr);
152 if emitted.contains(&nid) {
153 return Some(nid);
154 }
155 current = self.arena.parent(curr);
156 }
157 None
158 })
159 .unwrap_or_else(root_node_id);
160 // A composite that hands its node to a proxy hands it the focus too.
161 // An editor takes the keys on a wrapper whose node is structure and
162 // keeps its text on a body the keyboard never lands on; a reader reads
163 // the node named here when focus arrives, and `accesskit_atspi_common`
164 // sends caret and selection events only for that node
165 // (`adapter.rs:215-218`). Published on the wrapper, focus arrives on a
166 // node with no name and no text, and no caret move is ever reported.
167 let focus = Some(proxies.stand_in(focus))
168 .filter(|nid| emitted.contains(nid))
169 .unwrap_or(focus);
170
171 // ── Name-from-content for row nodes ───────────────────────────
172 // A virtualized row is two nodes: a thin structural wrapper
173 // (`ListItemWrapper` / `TreeItemWrapper`) carrying the role, level
174 // and selected/expanded state, and the app's delegate widget below
175 // it carrying the visible label. ARIA computes an `option`'s /
176 // `treeitem`'s name from its contents; AccessKit does not. Without
177 // this pass the platform adapters announce a nameless "tree item",
178 // and any client that matches a role AND a label — a screen-reader
179 // search, the automation bridge's `find_node` — can never match a
180 // row, because the two live on different nodes.
181 //
182 // Fill each nameless row node's name in from its first named
183 // descendant, in the order they read. The descendant keeps its own
184 // name (as in a browser's accessibility tree, where the text that
185 // contributes to a computed name stays in the tree).
186 {
187 use accesskit::Role;
188 use std::collections::HashMap;
189
190 // Row roles that take their name from content. A row inside a
191 // row (a `TreeView` nests each row under its parent row on
192 // Windows and Linux) owns its own name: the search stops there
193 // and does not descend, which is what keeps a parent row's name
194 // its own content's rather than its first child row's.
195 fn names_from_content(role: Role) -> bool {
196 matches!(role, Role::TreeItem | Role::ListBoxOption)
197 }
198
199 let index: HashMap<accesskit::NodeId, usize> = nodes
200 .iter()
201 .enumerate()
202 .map(|(i, (nid, _))| (*nid, i))
203 .collect();
204
205 let mut hoisted: Vec<(usize, String)> = Vec::new();
206 for (i, (_, node)) in nodes.iter().enumerate() {
207 if !names_from_content(node.role()) || node.label().is_some() {
208 continue;
209 }
210 // Depth-first, children in document order.
211 let mut stack: Vec<accesskit::NodeId> =
212 node.children().iter().rev().copied().collect();
213 while let Some(nid) = stack.pop() {
214 let Some(&j) = index.get(&nid) else { continue };
215 let descendant = &nodes[j].1;
216 if names_from_content(descendant.role()) {
217 continue;
218 }
219 match descendant.label() {
220 Some(label) if !label.trim().is_empty() => {
221 hoisted.push((i, label.to_string()));
222 break;
223 }
224 _ => stack.extend(descendant.children().iter().rev().copied()),
225 }
226 }
227 }
228 for (i, label) in hoisted {
229 nodes[i].1.set_label(label);
230 }
231 }
232
233 // ── Presentational-node pruning ───────────────────────────────
234 // Layout primitives (HStack/VStack/ZStack/Center/Padding/Expand/…)
235 // emit empty `GenericContainer` / `Unknown` AT nodes purely to
236 // carry visual structure. VoiceOver announces a `GenericContainer`
237 // as "group", so a Button whose chrome is composed from these
238 // primitives reads as "<label>, button, group". Browsers collapse
239 // such semantically-empty nodes out of the platform tree
240 // ("ignored" / "presentational" nodes); do the same — drop each
241 // empty container and PROMOTE its children to its parent (bounds
242 // are absolute, so promotion is structural only).
243 //
244 // A node is prunable only if it is a visible, content-free
245 // `GenericContainer`/`Unknown`: no name, value, live region,
246 // popup, relation, or focus/click action. The Window root, the
247 // focused node, and any relationship target are always kept.
248 {
249 use std::collections::{HashMap, HashSet};
250
251 // Nodes referenced by another node's relations must survive so
252 // the reference can't dangle.
253 let mut relation_targets: HashSet<accesskit::NodeId> = HashSet::new();
254 for (_, node) in &nodes {
255 relation_targets.extend(node.controls());
256 relation_targets.extend(node.described_by());
257 relation_targets.extend(node.labelled_by());
258 }
259
260 let prunable: HashSet<accesskit::NodeId> = nodes
261 .iter()
262 .filter(|(nid, node)| {
263 *nid != root_node_id()
264 && *nid != focus
265 && !relation_targets.contains(nid)
266 && is_presentational_container(node)
267 })
268 .map(|(nid, _)| *nid)
269 .collect();
270
271 if !prunable.is_empty() {
272 // Pre-pruning children lists, for chain resolution.
273 let children_map: HashMap<accesskit::NodeId, Vec<accesskit::NodeId>> = nodes
274 .iter()
275 .map(|(nid, node)| (*nid, node.children().to_vec()))
276 .collect();
277
278 // A kept node's effective children: each prunable child is
279 // replaced by its own (recursively resolved) kept children,
280 // so chains of empty containers collapse in one pass. The
281 // AT tree is acyclic, so the memo is the only guard needed.
282 fn resolve(
283 nid: accesskit::NodeId,
284 children_map: &HashMap<accesskit::NodeId, Vec<accesskit::NodeId>>,
285 prunable: &HashSet<accesskit::NodeId>,
286 memo: &mut HashMap<accesskit::NodeId, Vec<accesskit::NodeId>>,
287 ) -> Vec<accesskit::NodeId> {
288 if let Some(cached) = memo.get(&nid) {
289 return cached.clone();
290 }
291 let mut out = Vec::new();
292 if let Some(kids) = children_map.get(&nid) {
293 for &c in kids {
294 if prunable.contains(&c) {
295 out.extend(resolve(c, children_map, prunable, memo));
296 } else {
297 out.push(c);
298 }
299 }
300 }
301 memo.insert(nid, out.clone());
302 out
303 }
304
305 let mut memo: HashMap<accesskit::NodeId, Vec<accesskit::NodeId>> = HashMap::new();
306 for (nid, node) in &mut nodes {
307 if prunable.contains(nid) {
308 continue;
309 }
310 let resolved = resolve(*nid, &children_map, &prunable, &mut memo);
311 if children_map.get(nid) != Some(&resolved) {
312 node.set_children(resolved);
313 }
314 }
315 nodes.retain(|(nid, _)| !prunable.contains(nid));
316 }
317 }
318
319 // ── Strip dangling children ───────────────────────────────────
320 // A node's children list may name a NodeId that never reached this
321 // update. The walker's own pushes cannot do that (it pushes a child
322 // only after `arena.is_active`), but `attach_scene_child_under` can:
323 // it is called from a widget's `accessibility()` — which has no arena
324 // — to graft a *sibling* widget's node under a synthetic parent, and
325 // that widget may have gone dormant, in which case the walker emitted
326 // nothing for it.
327 //
328 // A dangling child is not a lost link, it is a broken tree: the
329 // consumer walks children to build the filtered tree every platform
330 // adapter reads, and a child it cannot resolve is an unreachable
331 // parent for everything below it. This is the structural twin of the
332 // relation strip immediately below, and it is enforced HERE rather
333 // than at each caller because a caller that could check would not
334 // need to ask: `Widget::accessibility` sees no arena by design, and
335 // a visibility gate written during layout settles after the widget
336 // that wrote it has already been asked for its node.
337 //
338 // Runs before the relation strip so a child removed here cannot be
339 // resurrected as a relation target by a later pass.
340 //
341 // Each emitted node's position in `nodes`, built once here and read by
342 // the two strips and the cell naming pass below: none of them adds,
343 // removes or reorders a node (the announcer nodes appended after the
344 // strips are absent from it, and no strip or cell looks for them).
345 let emitted: std::collections::HashMap<accesskit::NodeId, usize> = nodes
346 .iter()
347 .enumerate()
348 .map(|(i, (id, _))| (*id, i))
349 .collect();
350 for (_, node) in &mut nodes {
351 if node.children().iter().all(|c| emitted.contains_key(c)) {
352 continue;
353 }
354 let kept: Vec<_> = node
355 .children()
356 .iter()
357 .filter(|c| emitted.contains_key(*c))
358 .copied()
359 .collect();
360 node.set_children(kept);
361 }
362
363 // Strip relationship targets (controls, described_by, labelled_by) that
364 // reference NodeIds absent from the emitted tree. Dormant widgets (e.g.
365 // inactive tab panels) are excluded from the TreeUpdate; if a node still
366 // holds a `push_controlled` / `push_described_by` / `push_labelled_by`
367 // reference to one of them, accesskit_macos will unwrap() it and panic
368 // when VoiceOver follows the linked_ui_elements attribute.
369 //
370 // `labelled_by` is the one that costs a name rather than a link: the
371 // consumer concatenates its targets' values to build the node's name
372 // (`accesskit_consumer::node::write_label`), and a dangling target
373 // panics the iterator before it gets there.
374 for (_, node) in &mut nodes {
375 let controlled: Vec<_> = node
376 .controls()
377 .iter()
378 .filter(|id| emitted.contains_key(*id))
379 .copied()
380 .collect();
381 if controlled.len() != node.controls().len() {
382 node.set_controls(controlled);
383 }
384 let described: Vec<_> = node
385 .described_by()
386 .iter()
387 .filter(|id| emitted.contains_key(*id))
388 .copied()
389 .collect();
390 if described.len() != node.described_by().len() {
391 node.set_described_by(described);
392 }
393 let labelled: Vec<_> = node
394 .labelled_by()
395 .iter()
396 .filter(|id| emitted.contains_key(*id))
397 .copied()
398 .collect();
399 if labelled.len() != node.labelled_by().len() {
400 node.set_labelled_by(labelled);
401 }
402 }
403
404 // The framework's own live regions, last in the root's child list so
405 // they sit after the application's content in reading order: one node
406 // for each message spoken in the last few seconds, each from an id the
407 // tree has never used. See [`crate::announcer`] for why an
408 // announcement is a node that was never in the tree rather than a
409 // label edited in place or a node shown again.
410 //
411 // A message ready to enter does so only in an update that leaves
412 // focus where the last one put it: the adapters raise an update's
413 // announcements before its focus event, and a reader that stops to
414 // read the new focus cuts the message. Focus is compared as the
415 // consumer resolves it, so a list's new current row is a move.
416 let ready = speak
417 && (self.announcer_polite.entering().is_some()
418 || self.announcer_assertive.entering().is_some());
419 let moves_focus = ready
420 && self.cached_a11y.as_ref().is_some_and(|last| {
421 focus_as_read(&last.nodes, last.focus) != focus_as_read(&nodes, focus)
422 });
423 let admitted = ready && !moves_focus;
424 // What waits is heard after the arrival, by every reader.
425 let held: Vec<&str> = if moves_focus {
426 [
427 self.announcer_polite.entering(),
428 self.announcer_assertive.entering(),
429 ]
430 .into_iter()
431 .flatten()
432 .collect()
433 } else {
434 Vec::new()
435 };
436 let announcer_nodes: Vec<(accesskit::NodeId, accesskit::Node)> = self
437 .announcer_polite
438 .nodes(admitted)
439 .chain(self.announcer_assertive.nodes(admitted))
440 .collect();
441 if let Some((_, root)) = nodes.iter_mut().find(|(id, _)| *id == root_node_id()) {
442 for (id, _) in &announcer_nodes {
443 root.push_child(*id);
444 }
445 }
446 nodes.extend(announcer_nodes);
447
448 // A table cell is named after the text it shows, as ARIA names it
449 // from its content and the consumer does not. Before the description
450 // pass, which reads names; after the relation strip, so every target
451 // it adds is present.
452 super::accessibility_cell_name_impl::name_cells_from_content(&mut nodes, &emitted);
453 // A placeholder that repeats the field's name is read after it, once
454 // more. After the cells are named and before the description pass,
455 // with every `labelled_by` target present.
456 super::accessibility_placeholder_impl::drop_placeholders_repeating_the_name(&mut nodes);
457
458 // Last, on the nodes this update carries: a `described_by` relation
459 // reaches no screen reader through AccessKit, so its targets' text is
460 // written onto the node's `description`, which every adapter exposes.
461 // After the relation strip, so every target named is present.
462 let descriptions = self.describe_from_relations(&mut nodes, focus, &held);
463
464 (
465 accesskit::TreeUpdate {
466 nodes,
467 tree: Some(accesskit::TreeInfo::new(root_node_id())),
468 tree_id: accesskit::TreeId::ROOT,
469 focus,
470 },
471 synthetic_parents,
472 local_bounds,
473 descriptions,
474 admitted,
475 )
476 }
477
478 #[allow(clippy::too_many_arguments)]
479 pub(super) fn build_accessibility_recursive(
480 &self,
481 id: WidgetId,
482 // The transform carrying this node's bounds space into the space its
483 // AccessKit parent is described in. Identity for all but the direct
484 // children of a content-transform node; see the `set_transform` call
485 // below for why it is emitted there and nowhere else.
486 content_to_parent: teksilo_canvas::Transform2D,
487 nodes: &mut Vec<(accesskit::NodeId, accesskit::Node)>,
488 synthetic_parents: &mut std::collections::HashMap<accesskit::NodeId, WidgetId>,
489 local_bounds: &mut std::collections::HashMap<accesskit::NodeId, teksilo_canvas::Rect>,
490 seen_children: &mut std::collections::HashMap<accesskit::NodeId, WidgetId>,
491 // Tooltips whose text has already been placed on a node this pass.
492 // One tooltip describes one control, so once an owner has taken it the
493 // anchor further down must not take it again -- a description announced
494 // twice on the way into a control is worse than one announced in the
495 // wrong place.
496 handled_tooltips: &mut std::collections::HashSet<WidgetId>,
497 // What composites above have handed down to the descendants that
498 // stand for them (`Widget::accessibility_proxy`).
499 proxies: &mut ProxyLedger,
500 ) {
501 use crate::accessibility::widget_id_to_node_id;
502 use crate::widget_builder::AccessSubtreeMode;
503
504 if !self.arena.is_active(id) {
505 return;
506 }
507
508 let node = self
509 .arena
510 .get(id)
511 .expect("widget id is not in the arena (destroyed?)");
512 let mut builder = AccessNodeBuilder::for_widget(id);
513 node.widget.accessibility(&mut builder);
514 // Same call, same place, as `build_overridden_builder` — the two builder
515 // paths are parallel by construction and a derivation added to one of them
516 // only is a node that reads differently to a test than to a screen reader.
517 self.announce_context_menu(id, &mut builder);
518 self.announce_focusable(id, &mut builder);
519
520 // Whatever the composites this node stands for handed down (see
521 // `Widget::accessibility_proxy`). Taken out of the ledger here, so a
522 // composite below that names a proxy of its own passes them on rather
523 // than leaving a second copy behind.
524 let standing_for = proxies.standing_for.remove(&id).unwrap_or_default();
525 let mut reserved_tooltip = proxies.tooltips.remove(&id);
526 let proxy = self.live_accessibility_proxy(id);
527
528 // Apply builder-level overrides AFTER the inner widget has
529 // emitted its defaults, so the overrides win for scalar fields
530 // and append on relationship lists.
531 //
532 // A composite with a proxy keeps none of them. Its node is structure,
533 // and what an application attached to it (the only id it can reach)
534 // belongs on the node that holds focus: its overrides, those it was
535 // handed from further up, and the tooltip it owns all move down to the
536 // proxy, which applies them after its own so the application still
537 // has the last word. The tooltip is marked handled now, because the
538 // walk passes the composite's chrome, where the tooltip is anchored,
539 // before it reaches the proxy.
540 match proxy {
541 Some(proxy_id) => {
542 let mut chain = Vec::with_capacity(standing_for.len() + 1);
543 chain.push(id);
544 chain.extend(standing_for.iter().copied());
545 // Two composites can name the same proxy: a `DateEdit` names
546 // its `TextInput`'s field, and so does the `TextInput`. The
547 // outer one was walked first and left its share already; it
548 // goes after this one's, since the order is innermost first.
549 if let Some(outer) = proxies.standing_for.remove(&proxy_id) {
550 chain.extend(outer);
551 }
552 proxies.standing_for.insert(proxy_id, chain);
553 if let Some(content) = reserved_tooltip
554 .take()
555 .or_else(|| self.tooltip_claim_for_proxy(id, handled_tooltips))
556 {
557 handled_tooltips.insert(content);
558 // An outer composite that names the same proxy reserved
559 // its tooltip first; keep it rather than overwrite it.
560 proxies.tooltips.entry(proxy_id).or_insert(content);
561 }
562 proxies
563 .nodes
564 .insert(widget_id_to_node_id(id), widget_id_to_node_id(proxy_id));
565 }
566 None => {
567 // The menu a composite owns is offered where its node went:
568 // focus arrives here now, and the dispatcher already finds the
569 // composite's factory from here by walking up.
570 for &composite in &standing_for {
571 self.announce_context_menu(composite, &mut builder);
572 }
573 // Before the overrides, so an application's own name still
574 // wins over the row label that replaces a default here.
575 self.yield_default_name(id, &standing_for, &mut builder);
576 self.apply_access_overrides(id, &mut builder);
577 for &composite in &standing_for {
578 self.apply_access_overrides(composite, &mut builder);
579 }
580 }
581 }
582
583 let subtree_mode = node.access_subtree;
584 // G17: a widget may present a different child ORDER to assistive tech
585 // than its paint / z-order child order — e.g. TableView / TreeTableView
586 // build body rows before the header for correct z-stacking, but the
587 // header must read first in the AT (and Tab) linear order. Honour the
588 // widget's `accessibility_children()` override when it provides one;
589 // otherwise use the arena's paint-order child list. Geometry and paint
590 // are untouched (they keep reading `arena.children`).
591 let at_children_owned;
592 let children: &[WidgetId] = match node.widget.accessibility_children() {
593 Some(v) => {
594 at_children_owned = v;
595 &at_children_owned
596 }
597 None => self.arena.children(id),
598 };
599
600 // Subtree dispatch:
601 // Inherit — push child NodeIds onto the parent and recurse normally
602 // Exclude — neither push nor recurse: descendants vanish from AT
603 // Merge — collect descendants' label/description/value/actions
604 // into THIS node, then prune (no push, no recurse)
605 // The children this node will recurse into: every active child it
606 // pushed or a redirect claimed (a claimed child is still built, under
607 // the node the claimer attaches it to), and never a duplicate. Since
608 // `accessibility_children` may name any descendant, two parents can
609 // claim one id; building it twice would emit its node twice.
610 let mut to_recurse: Vec<WidgetId> = Vec::new();
611 match subtree_mode {
612 AccessSubtreeMode::Inherit => {
613 for &child_id in children {
614 if self.arena.is_active(child_id) {
615 let child_nid = widget_id_to_node_id(child_id);
616 // Checked before the redirect, so a child claimed
617 // twice is reported and built once whichever way
618 // either claim came.
619 if let Some(&prior_parent) = seen_children.get(&child_nid) {
620 eprintln!(
621 "Teksilo bug: duplicate accessibility child {:?}: \
622 first claimed by parent {:?}, now also claimed by {:?}. \
623 Please file a bug report.",
624 child_id, prior_parent, id
625 );
626 continue;
627 }
628 // AT-redirect hook (scene logical-tree auto-graft):
629 // walk up the arena from `id` asking every
630 // opted-in ancestor whether it claims this
631 // descendant. First `Some(_)` wins, scanned
632 // bottom-up so closest ancestor takes
633 // priority. The immediate parent is queried
634 // first if it opts in, direct-child
635 // relocation being the special case of an
636 // ancestor walk of length zero.
637 //
638 // Performance: most widgets default
639 // `wants_descendant_redirects = false` and
640 // are skipped without calling the hook, so
641 // the walk is O(opted-in ancestors) per
642 // child push, typically 0 or 1 for a
643 // SceneView-rooted subtree.
644 seen_children.insert(child_nid, id);
645 to_recurse.push(child_id);
646 if self.ancestor_chain_redirects(id, child_id) {
647 continue;
648 }
649 builder.inner_mut().push_child(child_nid);
650 }
651 }
652 }
653 AccessSubtreeMode::Exclude => {
654 // No children pushed, no descendants recursed-into.
655 }
656 AccessSubtreeMode::Merge => {
657 merge_descendants_into(&mut builder, id, &self.arena);
658 }
659 }
660
661 let bounds = self.arena.bounds(id);
662 builder.inner_mut().set_bounds(to_accesskit_rect(bounds));
663 // …and, where those bounds are not already in window space, the
664 // transform that puts them there.
665 //
666 // A **content** transform node — `SceneView` is the only one today —
667 // declares that its children are laid out in a coordinate system of
668 // its own: a card at scene (100, 100) has arena bounds of (100, 100)
669 // whatever the camera is doing. Writing that rectangle out untouched
670 // told assistive technology the card was at window (100, 100), which
671 // is where it is only at pan zero: an explore-by-touch probe, a
672 // braille routing key and a magnifier following focus all resolved
673 // against a rectangle the card is not in.
674 //
675 // The fix is the mechanism AccessKit already has for exactly this.
676 // `Node::transform` applies "to any coordinates within this node and
677 // its descendants, including the `bounds` property of this node", and
678 // `bounds` are read "in the coordinate space of the nearest ancestor
679 // with a non-`None` transform". So the whole scene subtree needs ONE
680 // declaration, on each child at the boundary: every rectangle below it
681 // — a card, a label inside the card, a text run's per-character boxes
682 // — stays in scene coordinates and is projected by the consumer, which
683 // is also what makes `node_at_point` descend correctly (it feeds each
684 // child `inv(child.direct_transform()) * point`).
685 //
686 // Emitted at the boundary and only there: a grandchild that re-emitted
687 // it would compose it twice. And emitted for *content* transforms only
688 // — a `Scale` or `Rotate` scope is a visual effect applied to a widget
689 // whose bounds are already in window space, so its subtree's
690 // coordinates are unchanged and a screen reader is not asked to chase
691 // a 200 ms tween.
692 if !content_to_parent.is_identity() {
693 builder
694 .inner_mut()
695 .set_transform(to_accesskit_affine(content_to_parent));
696 }
697
698 // Framework-driven disabled gate. Respects an
699 // `access_disabled(false)` override that wants to clear
700 // arena-driven disabled state too — without this short-circuit,
701 // `clear_disabled()` in the override layer would be re-set here.
702 let force_clear_disabled =
703 self.access_disabled_override(id, proxy.is_some(), &standing_for) == Some(false);
704 if !self.arena.is_enabled(id) && !force_clear_disabled {
705 builder.set_disabled();
706 }
707
708 // ── the tooltip's text, onto the node that will be read ───────────
709 //
710 // Not necessarily the node the overlay hangs off. A composing control
711 // anchors the tooltip on an inner chrome node -- the thing with the
712 // right bounds to open against -- and keeps its role, its name and its
713 // focusability on its own outer node. Emitting on the anchor put the
714 // description on an unnamed box beside the control: present in the
715 // tree, attached to nothing anyone reads. Since a plain tooltip is
716 // never auto-shown on focus (see `docs/tooltips.md`), that description
717 // IS the whole non-pointer path for the tier, and it reached nobody.
718 //
719 // So a tooltip names an owner, and the owner is honoured here -- but
720 // only where exactly ONE tooltip claims it. `BuildContext` records the
721 // widget that was building, and one build can attach many tooltips: a
722 // list body pane attaches one per visible row, all of them naming the
723 // pane. Granting that would put one row's text on the pane and lose
724 // every other row's entirely. A contested claim is no claim, and each
725 // of those tooltips falls back to its own anchor, which is where they
726 // already were.
727 //
728 // A composite that handed its node to a proxy handed its tooltip with
729 // it, above; the proxy takes it here on the owner's terms (a node a
730 // reader stops on, with no description of its own), or falls back to
731 // the usual search.
732 let tooltip = if proxy.is_some() {
733 None
734 } else {
735 reserved_tooltip
736 .filter(|_| {
737 !is_presentational_container(builder.inner_mut())
738 && builder.inner_mut().description().is_none()
739 })
740 .or_else(|| self.tooltip_description_target(id, handled_tooltips, &mut builder))
741 };
742 if let Some(content_id) = tooltip {
743 if self
744 .tooltips
745 .iter()
746 .any(|t| t.content_id == content_id && t.overlay_id.is_some())
747 {
748 // Shown: the content node is live in the AT tree, so the
749 // richer `described_by` relation can point straight at it.
750 // Its text still reaches a reader as this node's description,
751 // which `describe_from_relations` writes from the relation at
752 // the end of the build: no adapter reads the relation itself,
753 // and showing the tooltip used to take away the only
754 // description the control had.
755 builder
756 .inner_mut()
757 .push_described_by(widget_id_to_node_id(self.tooltip_content_node(content_id)));
758 handled_tooltips.insert(content_id);
759 } else if let Some(text) = self.tooltip_access_description(content_id) {
760 // Not shown. `described_by` cannot be used — the content is a
761 // dormant node and absent from the AT tree — and a *plain*
762 // tooltip is never auto-shown on focus, so gating the relation
763 // on `overlay_id` left the whole tier (the majority of call
764 // sites) with no screen-reader path at all. Copy the text onto
765 // the control as a static description instead, which is what a
766 // keyboard-only or screen-reader user actually reaches.
767 builder.set_description(text);
768 handled_tooltips.insert(content_id);
769 }
770 }
771
772 // The fourth element — the widget-local rects of any synthetic
773 // children — is what lets a pure translation re-place the runs
774 // without a full walk. Recorded by `sync_accessibility`.
775 let (node_id, ak_node, synthetic_children, synthetic_local_bounds) = builder.build(id);
776 nodes.push((node_id, ak_node));
777 local_bounds.extend(synthetic_local_bounds);
778 // Merge the widget's emitted synthetic children into the
779 // tree update and record their parent-widget mapping so
780 // `handle_accessibility_actions` can route incoming
781 // `ActionRequest`s targeting these child NodeIds back to
782 // the owning widget.
783 for (syn_id, syn_node) in synthetic_children {
784 nodes.push((syn_id, syn_node));
785 synthetic_parents.insert(syn_id, id);
786 }
787
788 // Recurse only for `Inherit` — `Exclude` and `Merge` prune
789 // descendants from the AT tree.
790 if matches!(subtree_mode, AccessSubtreeMode::Inherit) {
791 // What space this node hands its children. A content transform is
792 // the one kind that changes it; everything else — including a
793 // `Scale` / `Rotate` self transform — leaves its children in the
794 // space it was itself described in.
795 let child_space = if node.content_transform {
796 node.transform_prop
797 .as_ref()
798 .map(|p| p.get())
799 .unwrap_or(teksilo_canvas::Transform2D::IDENTITY)
800 } else {
801 teksilo_canvas::Transform2D::IDENTITY
802 };
803 for &child_id in &to_recurse {
804 self.build_accessibility_recursive(
805 child_id,
806 child_space,
807 nodes,
808 synthetic_parents,
809 local_bounds,
810 seen_children,
811 handled_tooltips,
812 proxies,
813 );
814 }
815 }
816 }
817
818 /// Walk the arena from `parent_id` up through ancestors, asking
819 /// each opted-in widget whether it claims `descendant` via
820 /// [`Widget::a11y_redirect_descendant`]. First widget that
821 /// returns `Some(_)` wins (closest-ancestor-first scan). Returns
822 /// `true` if any ancestor claimed the descendant — the caller
823 /// then skips the default child-list push.
824 ///
825 /// Cost: bounded by arena depth, but most widgets default
826 /// `wants_descendant_redirects = false` and short-circuit
827 /// without invoking the redirect hook itself. Trees with no
828 /// opted-in ancestors pay one `is_active` + one `Widget::
829 /// wants_descendant_redirects` call per ancestor — both
830 /// trivial — and walk to root.
831 ///
832 /// `parent_id` is queried *first* — direct-child relocation is
833 /// just the special case of an ancestor walk of length one.
834 fn ancestor_chain_redirects(&self, parent_id: WidgetId, descendant: WidgetId) -> bool {
835 let mut current = Some(parent_id);
836 while let Some(curr) = current {
837 let Some(curr_node) = self.arena.get(curr) else {
838 break;
839 };
840 if curr_node.widget.wants_descendant_redirects()
841 && curr_node
842 .widget
843 .a11y_redirect_descendant(curr, descendant)
844 .is_some()
845 {
846 return true;
847 }
848 current = self.arena.parent(curr);
849 }
850 false
851 }
852
853 /// Which tooltip's text this node should carry, if any.
854 ///
855 /// Two ways a node can come to own a tooltip's description, tried in that
856 /// order:
857 ///
858 /// * **as the claimed owner** -- the widget that was building when the
859 /// tooltip was attached. This is the case the whole mechanism exists
860 /// for: `Button` and the two dozen controls shaped like it hang the
861 /// overlay off an inner chrome node while their role and name live
862 /// out here. Granted only when this node is *unambiguously* the
863 /// owner: exactly one tooltip may claim it, and the node must be
864 /// something an assistive technology would actually stop on rather
865 /// than an anonymous box.
866 ///
867 /// * **as the anchor** -- the historic behaviour, and still correct for
868 /// a widget that anchors its tooltip on itself. Also the fallback for
869 /// everything the first case declines, so declining is always safe:
870 /// the worst it can do is leave the description exactly where it was
871 /// before any of this existed.
872 ///
873 /// The order matters and only works because of the walk's order. An
874 /// anchor is always a descendant of the widget that built it, and parents
875 /// are visited first, so an owner has already taken its tooltip (and said
876 /// so in `handled`) by the time the anchor is reached. Reverse the walk
877 /// and both would take it.
878 fn tooltip_description_target(
879 &self,
880 id: WidgetId,
881 handled: &std::collections::HashSet<WidgetId>,
882 builder: &mut AccessNodeBuilder,
883 ) -> Option<WidgetId> {
884 let mut claims = self
885 .tooltips
886 .iter()
887 .filter(|t| t.description_owner_id == id && !handled.contains(&t.content_id));
888 // `next()` twice rather than `count()`: one claim is the answer, two is
889 // a refusal, and there is nothing to learn from a third.
890 if let (Some(only), None) = (claims.next(), claims.next())
891 && only.anchor_id != id
892 // An anonymous container is not a place a description can be read
893 // from, and a widget whose own `accessibility()` leaves it one
894 // (`TextInput` says so out loud, keeping the real role on an inner
895 // field) is not the control being described either. Nothing is
896 // lost by declining: the anchor still takes it below.
897 && !is_presentational_container(builder.inner_mut())
898 // A description the widget wrote itself is the widget's own words
899 // about itself; a tooltip's is supplementary. Both land in the one
900 // scalar field, so the specific one wins. `MenuItem::trailing_hint`
901 // is the case that made this reachable.
902 && builder.inner_mut().description().is_none()
903 {
904 return Some(only.content_id);
905 }
906 self.tooltips
907 .iter()
908 .find(|t| t.anchor_id == id && !handled.contains(&t.content_id))
909 .map(|t| t.content_id)
910 }
911
912 /// Announce the context menu a widget owns, before any override runs.
913 ///
914 /// The dispatcher has always *serviced* `Action::ShowContextMenu` by falling
915 /// through to the node's `.context_menu(..)` factory, so an assistive
916 /// technology that tried the action got a menu. Nothing ever told it the
917 /// action was there, and an AT offers what a node advertises: the menu was
918 /// reachable and undiscoverable at the same time.
919 ///
920 /// That is not a cosmetic gap wherever a menu is the accessible route to
921 /// something else. A row that puts its actions on hover buttons has to hide
922 /// those buttons from the AT -- a control that exists only under a pointer
923 /// does not exist for a keyboard -- and offer the same actions on its context
924 /// menu instead. Unannounced, that leaves the actions with no route at all.
925 ///
926 /// Only where the node owns the factory ITSELF, not wherever the ancestor walk
927 /// would eventually find one: every descendant of a widget with a menu would
928 /// otherwise advertise it, and an AT would offer the same menu on a dozen
929 /// nested boxes. Applied BEFORE the overrides so `access_remove_action` still
930 /// takes it away, and so a widget wiring its own `on_access_action` handler is
931 /// not given a second one.
932 /// Announce that a focusable node can be focused, before any override runs.
933 ///
934 /// The sibling of [`Self::announce_context_menu`], for the same reason. The
935 /// dispatcher has always *serviced* `Action::Focus` for any node -- the
936 /// `AccessAction` arm calls `focus_with_origin_ops` itself rather than
937 /// handing the action to the widget -- so an assistive technology that tried
938 /// it got focus. Nothing told it the action was there, and an AT offers what
939 /// a node advertises: focus was reachable and undiscoverable at the same
940 /// time.
941 ///
942 /// Leaving it to each widget made it a rule that had to be remembered ~40
943 /// times and was not: every stock button remembered, while `ListView`,
944 /// `TreeView`, `TableView`, `TreeTableView`, `GridView`, `MenuList` and
945 /// `OverlayTrigger` did not -- so a screen reader could not put focus in a
946 /// list, a tree, a table, a grid or an open menu. Deriving it from the
947 /// arena's own focusable flag makes the advertisement true by construction
948 /// and keeps it true for widgets not yet written.
949 ///
950 /// Gated on the *arena* flag rather than on the widget's opinion, because
951 /// that flag is what `focus_with_origin_ops` will actually honour -- a node
952 /// that is not focusable would advertise an action that then declines to
953 /// land. Applied BEFORE the overrides, so `access_remove_action` can still
954 /// take it away.
955 fn announce_focusable(&self, id: WidgetId, builder: &mut AccessNodeBuilder) {
956 if self
957 .arena
958 .get(id)
959 .is_some_and(|node| self.is_node_focusable(node))
960 && !builder.actions().contains(&accesskit::Action::Focus)
961 {
962 builder.add_action(accesskit::Action::Focus);
963 }
964 }
965
966 fn announce_context_menu(&self, id: WidgetId, builder: &mut AccessNodeBuilder) {
967 let offers = |id: WidgetId| {
968 self.arena.get(id).is_some_and(|node| {
969 node.context_menu_factory.is_some() || node.context_menu_contribution.is_some()
970 })
971 };
972 // A data view's own node offers the menu its current row would open
973 // (the row, or the delegate or cell inside it, carries it).
974 let reaches_one = || {
975 let mut current = Some(self.resolve_context_menu_target(id));
976 while let Some(at) = current {
977 if offers(at) {
978 return true;
979 }
980 if at == id {
981 break;
982 }
983 current = self.arena.get(at).and_then(|node| node.parent);
984 }
985 false
986 };
987 if (offers(id) || reaches_one())
988 && !builder
989 .actions()
990 .contains(&accesskit::Action::ShowContextMenu)
991 {
992 builder.add_action(accesskit::Action::ShowContextMenu);
993 }
994 }
995
996 /// Whether the node `id` publishes to assistive technology offers
997 /// `Action::Focus`.
998 ///
999 /// Answered off the same builder the AT tree is emitted from — widget
1000 /// emission, then `announce_focusable`, then the app's overrides (which can
1001 /// both add an action and `access_remove_action` one) — so it is exactly
1002 /// what the assistive technology was told.
1003 ///
1004 /// This is the gate on the `Action::Focus` walk into a subtree. A focusable
1005 /// leaf gets the action from `announce_focusable` and resolves to itself; a
1006 /// composite that keeps focus on an inner leaf and publishes its own node
1007 /// (`ComboBox`, `DateEdit` and its siblings) adds the action in its own
1008 /// `accessibility()` and means the walk. `SpinBox` publishes through its
1009 /// field instead, which is focusable and resolves to itself. Everything
1010 /// else (a `Panel`, a `GroupBox`, a landmark, a label) offers no `Focus`,
1011 /// and moving focus into it would land the keyboard on a descendant the
1012 /// assistive technology could have named itself and did not.
1013 pub(crate) fn advertises_focus_action(&self, id: WidgetId) -> bool {
1014 self.arena.get(id).is_some()
1015 && self
1016 .build_overridden_builder(id)
1017 .actions()
1018 .contains(&accesskit::Action::Focus)
1019 }
1020
1021 pub(super) fn build_overridden_builder(&self, id: WidgetId) -> AccessNodeBuilder {
1022 use crate::widget_builder::AccessSubtreeMode;
1023 let node = self
1024 .arena
1025 .get(id)
1026 .expect("widget id is not in the arena (destroyed?)");
1027 // Every consumer of this builder reads scalars off it — role, name,
1028 // actions, the tri-state flags — and discards it. A name probe so a
1029 // text-bearing widget does not shape and emit runs nobody collects.
1030 let mut builder = AccessNodeBuilder::for_name_probe(id);
1031 node.widget.accessibility(&mut builder);
1032 self.announce_context_menu(id, &mut builder);
1033 self.announce_focusable(id, &mut builder);
1034 // The walker's rule, reached from the other end: it carries the
1035 // composites down to their proxy, this climbs from the node to them.
1036 if self.live_accessibility_proxy(id).is_none() {
1037 let behind = self.composites_standing_behind(id);
1038 for &composite in &behind {
1039 self.announce_context_menu(composite, &mut builder);
1040 }
1041 self.yield_default_name(id, &behind, &mut builder);
1042 self.apply_access_overrides(id, &mut builder);
1043 for composite in behind {
1044 self.apply_access_overrides(composite, &mut builder);
1045 }
1046 }
1047 if node.access_subtree == AccessSubtreeMode::Merge {
1048 merge_descendants_into(&mut builder, id, &self.arena);
1049 }
1050 builder
1051 }
1052
1053 /// Drop a name the widget set with
1054 /// [`AccessNodeBuilder::set_default_name`] when a `labelled_by` relation
1055 /// on the node, or on a composite it stands for, names it instead.
1056 ///
1057 /// The naming order is the one Qt and WinUI follow: a name the
1058 /// application gave (`access_label`, `access_customize`, applied after
1059 /// this) first, then the label the node is linked to, then the control's
1060 /// built-in default. `accesskit_consumer` reads a node's own name before
1061 /// its `labelled_by` targets, so a default left in place would hide the
1062 /// row label of a `FormLayout`: a date field in a "Birthday" row read
1063 /// "Date". A relation whose targets are all gone or nameless names
1064 /// nothing, and then the default stays.
1065 fn yield_default_name(
1066 &self,
1067 id: WidgetId,
1068 standing_for: &[WidgetId],
1069 builder: &mut AccessNodeBuilder,
1070 ) {
1071 if !builder.name_is_default() {
1072 return;
1073 }
1074 let named_by_relation = std::iter::once(id)
1075 .chain(standing_for.iter().copied())
1076 .filter_map(|owner| self.arena.get(owner)?.access_overrides.as_deref())
1077 .flat_map(|ov| ov.labelled_by.iter().copied())
1078 .any(|target| self.has_name_to_give(target));
1079 if named_by_relation {
1080 builder.clear_default_name();
1081 }
1082 }
1083
1084 /// Whether `target`, as the end of a `labelled_by` relation, gives the
1085 /// node pointing at it a name, read as `accesskit_consumer` reads it
1086 /// (`Node::write_label`): a `Role::Label` by its text, any other node by
1087 /// its own name alone, never by its own `labelled_by`.
1088 ///
1089 /// So a target that is not in the update (inactive, or under an excluded
1090 /// or merged ancestor) gives nothing, and neither does one whose own name
1091 /// is a default that yields to a relation of its own: the walk clears it,
1092 /// and the consumer does not follow that target's relation on. A relation
1093 /// that names a composite names the proxy standing for it (see
1094 /// `ProxyLedger::redirect_relations`), so the probe follows the proxy
1095 /// chain and applies the overrides the composites hand down.
1096 pub(crate) fn has_name_to_give(&self, target: WidgetId) -> bool {
1097 self.gives_name(target, &mut Vec::new())
1098 }
1099
1100 fn gives_name(&self, target: WidgetId, visited: &mut Vec<WidgetId>) -> bool {
1101 use crate::widget_builder::AccessSubtreeMode;
1102 if !self.arena.is_active(target) || visited.contains(&target) {
1103 return false;
1104 }
1105 visited.push(target);
1106 // An excluded or merged subtree emits no node for the target.
1107 let mut ancestor = self.arena.parent(target);
1108 while let Some(a) = ancestor {
1109 if self
1110 .arena
1111 .get(a)
1112 .is_some_and(|n| n.access_subtree != AccessSubtreeMode::Inherit)
1113 {
1114 return false;
1115 }
1116 ancestor = self.arena.parent(a);
1117 }
1118 let mut node_id = target;
1119 // A proxy is a strict descendant, so the chain is finite; the bound
1120 // is a guard, not a limit anyone reaches.
1121 for _ in 0..16 {
1122 match self.live_accessibility_proxy(node_id) {
1123 Some(proxy) => node_id = proxy,
1124 None => break,
1125 }
1126 }
1127 let Some(node) = self.arena.get(node_id) else {
1128 return false;
1129 };
1130 let mut builder = AccessNodeBuilder::for_name_probe(node_id);
1131 node.widget.accessibility(&mut builder);
1132 let behind = self.composites_standing_behind(node_id);
1133 if builder.name_is_default() {
1134 let yields = std::iter::once(node_id)
1135 .chain(behind.iter().copied())
1136 .filter_map(|owner| self.arena.get(owner)?.access_overrides.as_deref())
1137 .flat_map(|ov| ov.labelled_by.clone())
1138 .any(|t| self.gives_name(t, visited));
1139 if yields {
1140 builder.clear_default_name();
1141 }
1142 }
1143 self.apply_access_overrides(node_id, &mut builder);
1144 for composite in behind {
1145 self.apply_access_overrides(composite, &mut builder);
1146 }
1147 let non_empty = |s: Option<&str>| s.is_some_and(|s| !s.trim().is_empty());
1148 if builder.role() == accesskit::Role::Label {
1149 // The walker writes a label's text as its value, which is what
1150 // the consumer reads from it.
1151 non_empty(builder.name()) || non_empty(builder.value())
1152 } else {
1153 non_empty(builder.name())
1154 }
1155 }
1156
1157 /// Apply the builder-level overrides attached to `id` onto `builder`.
1158 ///
1159 /// `access_shortcut_id` is resolved here rather than in
1160 /// `AccessibilityOverrides::apply` because the override struct cannot reach
1161 /// the tree's `ShortcutRegistry`. Same mechanism as
1162 /// `MenuItem::for_shortcut(...)`: look up the effective primary keystroke
1163 /// and announce it through `KeyStroke`'s `Display`, or nothing when the id
1164 /// has no registered default.
1165 fn apply_access_overrides(&self, id: WidgetId, builder: &mut AccessNodeBuilder) {
1166 let Some(ov) = self
1167 .arena
1168 .get(id)
1169 .and_then(|node| node.access_overrides.as_deref())
1170 else {
1171 return;
1172 };
1173 ov.apply(builder);
1174 if let Some(ref sid) = ov.shortcut_id
1175 && let Some(eff) = self.shortcut_registry.effective(sid)
1176 && let Some(ks) = eff.primary
1177 {
1178 builder.set_keyboard_shortcut(ks.to_string());
1179 }
1180 }
1181
1182 /// The descendant `id` hands its accessibility node to, if it names one
1183 /// the walk will honour: see [`Widget::accessibility_proxy`].
1184 ///
1185 /// A strict, live descendant that the walk reaches: `id` and every widget
1186 /// between the two walked normally. An excluded or merged subtree emits no
1187 /// node for the proxy to carry anything on, wherever it sits on the way
1188 /// down, so the overrides stay where they were attached.
1189 pub(crate) fn live_accessibility_proxy(&self, id: WidgetId) -> Option<WidgetId> {
1190 use crate::widget_builder::AccessSubtreeMode;
1191 let node = self.arena.get(id)?;
1192 if node.access_subtree != AccessSubtreeMode::Inherit {
1193 return None;
1194 }
1195 let proxy = node.widget.accessibility_proxy()?;
1196 if proxy == id || !self.arena.is_active(proxy) {
1197 return None;
1198 }
1199 // Climb from the proxy to `id`, which is the descendant check, and
1200 // refuse at the first widget in between that stops the walk.
1201 let mut current = self.arena.parent(proxy);
1202 while let Some(ancestor) = current {
1203 if ancestor == id {
1204 return Some(proxy);
1205 }
1206 if self.arena.get(ancestor)?.access_subtree != AccessSubtreeMode::Inherit {
1207 return None;
1208 }
1209 current = self.arena.parent(ancestor);
1210 }
1211 None
1212 }
1213
1214 /// The composites whose node `id` stands for, innermost first: the order
1215 /// the walker applies their overrides in, so the outermost, which is the
1216 /// one an application attached last, wins.
1217 ///
1218 /// A composite can name another composite as its proxy, which names a
1219 /// field in turn, so the climb follows the chain rather than stopping at
1220 /// the first match.
1221 ///
1222 /// Two composites can also name the same proxy (a `DateEdit` and the
1223 /// `TextInput` inside it both name the field), so an ancestor counts when
1224 /// its proxy is `id` or any composite already found.
1225 pub(crate) fn composites_standing_behind(&self, id: WidgetId) -> Vec<WidgetId> {
1226 let mut found = Vec::new();
1227 let mut current = self.arena.parent(id);
1228 while let Some(ancestor) = current {
1229 if let Some(proxy) = self.live_accessibility_proxy(ancestor)
1230 && (proxy == id || found.contains(&proxy))
1231 {
1232 found.push(ancestor);
1233 }
1234 current = self.arena.parent(ancestor);
1235 }
1236 found
1237 }
1238
1239 /// The composite an assistive `Focus` on `id` gives the keyboard to: the
1240 /// innermost focusable, enabled composite `id` stands for, when `id` is not
1241 /// focusable itself.
1242 ///
1243 /// An editor publishes its text on a body the keyboard never lands on, and
1244 /// that body is the node a screen reader sees, and the one it asks to
1245 /// focus. Parking the keyboard there would pass the keys up to the editor
1246 /// by bubbling but run none of its focus handlers, so no caret, no input
1247 /// method and no focus ring; and asking again while the editor holds focus
1248 /// would take focus away from it.
1249 ///
1250 /// Anything that gives the keyboard to a widget it found through the
1251 /// accessibility tree asks this first: the router does for an assistive
1252 /// `Focus`, and the automation toolkit does before it types.
1253 pub fn focusable_composite_behind(&self, id: WidgetId) -> Option<WidgetId> {
1254 if self
1255 .arena
1256 .get(id)
1257 .is_none_or(|node| self.is_node_focusable(node))
1258 {
1259 return None;
1260 }
1261 self.composites_standing_behind(id)
1262 .into_iter()
1263 .find(|&composite| {
1264 self.arena.is_enabled(composite)
1265 && self
1266 .arena
1267 .get(composite)
1268 .is_some_and(|node| self.is_node_focusable(node))
1269 })
1270 }
1271
1272 /// The `access_disabled` override in force on `id`'s node: its own, unless
1273 /// it handed its node to a proxy, then those of the composites it stands
1274 /// for, the last one set winning as it does for every scalar override.
1275 pub(crate) fn access_disabled_override(
1276 &self,
1277 id: WidgetId,
1278 handed_on: bool,
1279 standing_for: &[WidgetId],
1280 ) -> Option<bool> {
1281 if handed_on {
1282 return None;
1283 }
1284 std::iter::once(id)
1285 .chain(standing_for.iter().copied())
1286 .filter_map(|w| {
1287 self.arena
1288 .get(w)
1289 .and_then(|node| node.access_overrides.as_deref())
1290 .and_then(|ov| ov.disabled)
1291 })
1292 .last()
1293 }
1294
1295 /// The tooltip a composite that hands its node to a proxy should hand on
1296 /// with it: the one it owns, when it owns exactly one, or else one
1297 /// anchored on it. The same claim `tooltip_description_target` grants an
1298 /// owner, without the checks on the node, which are the proxy's to pass.
1299 fn tooltip_claim_for_proxy(
1300 &self,
1301 id: WidgetId,
1302 handled: &std::collections::HashSet<WidgetId>,
1303 ) -> Option<WidgetId> {
1304 let mut claims = self
1305 .tooltips
1306 .iter()
1307 .filter(|t| t.description_owner_id == id && !handled.contains(&t.content_id));
1308 if let (Some(only), None) = (claims.next(), claims.next()) {
1309 return Some(only.content_id);
1310 }
1311 self.tooltips
1312 .iter()
1313 .find(|t| t.anchor_id == id && !handled.contains(&t.content_id))
1314 .map(|t| t.content_id)
1315 }
1316}
1317
1318/// What composites hand down, during one walk, to the descendants that stand
1319/// for them in the accessibility tree ([`Widget::accessibility_proxy`]).
1320///
1321/// The walk visits a composite before its proxy, so the composite leaves its
1322/// share here and the proxy collects it when the walk arrives.
1323#[derive(Default)]
1324pub(super) struct ProxyLedger {
1325 /// For each proxy, the composites it stands for, innermost first.
1326 standing_for: std::collections::HashMap<WidgetId, Vec<WidgetId>>,
1327 /// The tooltip a composite owned, kept for its proxy.
1328 tooltips: std::collections::HashMap<WidgetId, WidgetId>,
1329 /// Each composite's node, and the node of the proxy standing for it.
1330 nodes: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId>,
1331}
1332
1333impl ProxyLedger {
1334 /// The node standing for `nid`, through any chain of composites: `nid`
1335 /// itself when no composite handed it on.
1336 fn stand_in(&self, mut nid: accesskit::NodeId) -> accesskit::NodeId {
1337 // A proxy is a strict descendant, so a chain never loops; the bound is
1338 // there so that cannot be assumed.
1339 for _ in 0..self.nodes.len() {
1340 match self.nodes.get(&nid) {
1341 Some(&next) => nid = next,
1342 None => break,
1343 }
1344 }
1345 nid
1346 }
1347
1348 /// Point every `controls`, `described_by` and `labelled_by` target that
1349 /// names a composite at the node standing for it, through any chain of
1350 /// composites.
1351 fn redirect_relations(&self, nodes: &mut [(accesskit::NodeId, accesskit::Node)]) {
1352 if self.nodes.is_empty() {
1353 return;
1354 }
1355 let redirected = |ids: &[accesskit::NodeId]| {
1356 ids.iter()
1357 .any(|id| self.nodes.contains_key(id))
1358 .then(|| ids.iter().map(|&id| self.stand_in(id)).collect::<Vec<_>>())
1359 };
1360 for (_, node) in nodes.iter_mut() {
1361 if let Some(ids) = redirected(node.controls()) {
1362 node.set_controls(ids);
1363 }
1364 if let Some(ids) = redirected(node.described_by()) {
1365 node.set_described_by(ids);
1366 }
1367 if let Some(ids) = redirected(node.labelled_by()) {
1368 node.set_labelled_by(ids);
1369 }
1370 }
1371 }
1372}
1373
1374/// Whether an AT node is a purely-structural container that should be
1375/// collapsed out of the tree (its children promoted to its parent).
1376///
1377/// True only for a content-free `GenericContainer` / `Unknown` node — the
1378/// empty boxes layout primitives (HStack/VStack/Padding/Expand/…) emit.
1379/// The check is exhaustive by construction: set aside the framework-applied
1380/// children, bounds, and disabled flag, then compare against a fresh
1381/// default node of the same role. Any author- or widget-set property —
1382/// name, value, description, orientation, aria-current, identifier, live
1383/// region, popup, action, relation, hidden flag, … — makes the node differ
1384/// from the default and keeps it, so no semantic property can be missed.
1385/// Callers additionally exempt the Window root, the focused node, and
1386/// relationship targets.
1387fn is_presentational_container(node: &accesskit::Node) -> bool {
1388 use accesskit::Role;
1389 if !matches!(node.role(), Role::GenericContainer | Role::Unknown) {
1390 return false;
1391 }
1392 // Set aside the framework-applied structural bits (children, bounds,
1393 // arena-driven disabled flag), then check the node carries no semantic
1394 // content by comparing against a bare node of the same role.
1395 //
1396 // We compare the *Debug* form, not `==`: AccessKit's `clear_*` leaves
1397 // residue in the private property-value vec (the index is unset but the
1398 // value stays), so `PartialEq` never matches a fresh node. `Node`'s
1399 // Debug renders only logically-set properties, so it reflects true
1400 // content and is exhaustive — any author/widget property keeps the node.
1401 let mut probe = node.clone();
1402 probe.clear_children();
1403 probe.clear_bounds();
1404 probe.clear_disabled();
1405 format!("{probe:?}") == format!("{:?}", accesskit::Node::new(node.role()))
1406}
1407
1408// ─────────────────────────────────────────────────────────────────────
1409// Subtree-merge helpers
1410// ─────────────────────────────────────────────────────────────────────
1411//
1412// These run when a widget's `access_subtree` is `Merge`. The walker
1413// recurses through the descendants, applies each descendant's own
1414// `accessibility() + override apply()` into a temp builder, and absorbs
1415// the resulting label / value / actions into a `MergeAccumulator`.
1416// After the walk finishes the accumulator flushes its accumulated state
1417// onto the parent's builder.
1418//
1419// The accumulator deliberately discards descendant role and numeric
1420// fields (parent's role wins for the merged element) and discards
1421// disabled (parent's state governs the whole merged subtree). A hidden
1422// descendant is not merged at all, and neither is its subtree.
1423// Action lists union with deduplication so two child Buttons each
1424// emitting `Click` don't pollute the merged parent with two copies.
1425
1426/// Walk the descendants of `parent_id` and absorb their label / value /
1427/// actions into `parent_builder`. Per-descendant subtree-mode handling
1428/// lives in [`merge_collect_recursive`].
1429fn merge_descendants_into(
1430 parent_builder: &mut AccessNodeBuilder,
1431 parent_id: WidgetId,
1432 arena: &crate::arena::WidgetArena,
1433) {
1434 let mut acc = MergeAccumulator::default();
1435 for &child in arena.children(parent_id) {
1436 merge_collect_recursive(child, arena, &mut acc);
1437 }
1438 acc.flush_into(parent_builder);
1439}
1440
1441fn merge_collect_recursive(
1442 id: WidgetId,
1443 arena: &crate::arena::WidgetArena,
1444 acc: &mut MergeAccumulator,
1445) {
1446 use crate::widget_builder::AccessSubtreeMode;
1447
1448 if !arena.is_active(id) {
1449 return;
1450 }
1451 let Some(node) = arena.get(id) else {
1452 return;
1453 };
1454
1455 // Build a temp builder for this descendant the same way the walker
1456 // would: widget.accessibility() then override apply(). This means
1457 // a descendant's `.access_label(...)` contributes its resolved
1458 // override string, not its raw widget label.
1459 // A name probe: `absorb` takes the descendant's name, value, actions and
1460 // relations, never its synthetic children — and the runs a text widget
1461 // would emit here would be discarded with `tmp` while the widget's own
1462 // node keeps its copies.
1463 let mut tmp = AccessNodeBuilder::for_name_probe(id);
1464 node.widget.accessibility(&mut tmp);
1465 if let Some(ov) = node.access_overrides.as_deref() {
1466 ov.apply(&mut tmp);
1467 }
1468 // Nested-merge: the descendant itself has access_subtree=Merge.
1469 // Run its own merge into `tmp` BEFORE absorbing — otherwise we'd
1470 // absorb the descendant's empty container state and lose its
1471 // subtree-merged label.
1472 if matches!(node.access_subtree, AccessSubtreeMode::Merge) {
1473 merge_descendants_into(&mut tmp, id, arena);
1474 }
1475 // Skip nodes that opted out of AT entirely: a child marked
1476 // `access_hidden(true)` (or whose widget called `set_hidden()`)
1477 // contributes nothing to the merge, and neither does anything under it.
1478 // The consumer the adapters read through treats a node as hidden when
1479 // any ancestor is, so text below a hidden node is text the widget hid;
1480 // merging it would say it aloud on the merged node.
1481 if tmp.is_hidden() {
1482 return;
1483 }
1484 acc.absorb(&tmp);
1485
1486 match node.access_subtree {
1487 AccessSubtreeMode::Exclude => {
1488 // Prune — don't recurse into descendants of an excluded subtree.
1489 }
1490 AccessSubtreeMode::Merge => {
1491 // Nested Merge: descendant's own subtree was absorbed into
1492 // `tmp` above; don't re-walk its children at this level
1493 // (would double-count). The descendant reads as one
1494 // AT element from the outer merge's perspective.
1495 }
1496 AccessSubtreeMode::Inherit => {
1497 for &grandchild in arena.children(id) {
1498 merge_collect_recursive(grandchild, arena, acc);
1499 }
1500 }
1501 }
1502}
1503
1504/// Per-merge-walk accumulator. Collects descendant state across the
1505/// recursive walk, then `flush_into` writes the unioned values onto
1506/// the parent's builder.
1507///
1508/// Fields that the absorb path can read from `AccessNodeBuilder`
1509/// (label, value, advertised actions) participate in the merge.
1510/// Description and relationship lists (`controls` / `described_by` /
1511/// `labelled_by`) live inside `accesskit::Node` and have no public
1512/// getter on `AccessNodeBuilder`; merging them would require
1513/// reflecting builder mutations into a parallel field, which we
1514/// haven't found a real-world use case for. App authors who need
1515/// description / relationship merge can use the `access_customize`
1516/// escape hatch on the parent.
1517#[derive(Default)]
1518struct MergeAccumulator {
1519 label_parts: Vec<String>,
1520 /// First non-empty value wins.
1521 value: Option<String>,
1522 actions: Vec<accesskit::Action>,
1523}
1524
1525impl MergeAccumulator {
1526 fn absorb(&mut self, src: &AccessNodeBuilder) {
1527 if let Some(name) = src.name()
1528 && !name.is_empty()
1529 {
1530 self.label_parts.push(name.to_string());
1531 }
1532 if let Some(value) = src.value()
1533 && self.value.is_none()
1534 && !value.is_empty()
1535 {
1536 self.value = Some(value.to_string());
1537 }
1538 for &action in src.actions() {
1539 if !self.actions.contains(&action) {
1540 self.actions.push(action);
1541 }
1542 }
1543 }
1544
1545 fn flush_into(self, dst: &mut AccessNodeBuilder) {
1546 // Concatenate new label parts onto whatever the parent's
1547 // builder already carried. Existing parent name kept first.
1548 if !self.label_parts.is_empty() {
1549 let existing = dst.name().map(|s| s.to_string());
1550 let merged = match existing {
1551 Some(e) if !e.is_empty() => {
1552 let mut s = e;
1553 for part in self.label_parts {
1554 s.push(' ');
1555 s.push_str(&part);
1556 }
1557 s
1558 }
1559 _ => self.label_parts.join(" "),
1560 };
1561 dst.set_name(merged);
1562 }
1563 if let Some(v) = self.value {
1564 // Only overwrite parent value if it's currently None — the
1565 // parent's own value (from the inner widget or override
1566 // `access_value`) takes precedence.
1567 if dst.value().is_none() {
1568 dst.set_value(v);
1569 }
1570 }
1571 for action in self.actions {
1572 // Union: skip actions already advertised on the parent
1573 // (avoid duplicate Click/Focus when parent is itself a Button).
1574 if !dst.actions().contains(&action) {
1575 dst.add_action(action);
1576 }
1577 }
1578 }
1579}