Skip to main content

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