Skip to main content

teksilo_core/widget_tree/
accessibility_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use super::*;
5
6use crate::accessibility::{AccessNodeBuilder, AccessibilityInfo};
7
8impl WidgetTree {
9    /// Build an AccessKit `TreeUpdate` from the current state of all active
10    /// widgets. Call this once per frame, between layout and paint, and push
11    /// the result to the `accesskit_winit::Adapter`.
12    /// Caches the result and only rebuilds when layout has changed.
13    pub fn sync_accessibility(&mut self) -> accesskit::TreeUpdate {
14        // Explicit re-walk request (e.g. `SceneView` materialised / destroyed a
15        // scene widget, or an a11y-only scene mutation). A relayout no longer
16        // sets `a11y_dirty` on its own, so this is the lever; drain it before
17        // the cache check below.
18        if self.a11y_update_requested.replace(false) {
19            self.a11y_dirty = true;
20        }
21        // Shortcut registry rebinds bump
22        // `ShortcutRegistry::version()`. The `access_shortcut_id`
23        // resolution in the walker reads the live registry, so any
24        // rebind must invalidate the AT-tree cache too — otherwise
25        // a rebind would not surface in the announced shortcut
26        // until something else dirties the tree (a layout, a focus
27        // change, …). Cheap: one u64 compare per `sync_accessibility`.
28        let current_shortcut_version = self.shortcut_registry.version().get();
29        if current_shortcut_version != self.last_synced_shortcut_version {
30            self.a11y_dirty = true;
31            self.last_synced_shortcut_version = current_shortcut_version;
32        }
33
34        // Locale switches make `access_label(tr!(...))` (stored as a
35        // locale-bound `Prop<String>`) resolve to a new value. The
36        // override props are read in `apply()` during the walk, so the
37        // tree must re-walk for the new announcement to surface —
38        // otherwise the screen reader keeps the old-locale string until
39        // something else dirties the tree. Mirror the shortcut-version
40        // guard above. (Same-direction switches don't rebuild the
41        // composite, so this is the only thing that refreshes AT labels.)
42        let current_locale = self.locale_signal.get();
43        if current_locale != self.last_synced_locale {
44            self.a11y_dirty = true;
45            self.last_synced_locale = current_locale;
46        }
47
48        if !self.a11y_dirty
49            && let Some(cached) = &self.cached_a11y
50        {
51            return cached.clone();
52        }
53
54        let (update, parents) = self.build_accessibility_tree();
55        // A `&mut self` post-pass: diff the freshly-built live nodes and
56        // record any changed text into the announcement ring buffer. Must
57        // run here (not in the `&self` `build_accessibility_tree`) and
58        // before the cache store, so it sees exactly the update that is
59        // about to become canonical.
60        self.collect_announcements(&update);
61        // Bump the AT version only when the tree's *content* actually changed.
62        // A rebuild can be triggered by a shortcut-rebind / locale switch that
63        // produces a byte-for-byte identical `TreeUpdate`; bumping then would
64        // make `WaitCondition::AtVersionAtLeast` wake on no real change.
65        // `TreeUpdate: PartialEq`, so this is a precise (if O(n)) comparison —
66        // acceptable since it only runs on a real rebuild, not a cache hit.
67        let content_changed = self.cached_a11y.as_ref() != Some(&update);
68        self.cached_a11y = Some(update.clone());
69        self.synthetic_parent_map = parents;
70        self.a11y_dirty = false;
71        if content_changed {
72            // Mirror of the shortcut-registry version signal; saturating so the
73            // documented "monotonic" contract holds even past `u64::MAX`.
74            self.at_version.set(self.at_version.get().saturating_add(1));
75        }
76        update
77    }
78
79    /// Diff the live-region nodes of a freshly-built `TreeUpdate` against
80    /// the per-node last-announced-text map and push any changes into the
81    /// capped announcement ring buffer. See
82    /// [`crate::accessibility::Announcement`] and
83    /// [`Self::announcements_since`].
84    fn collect_announcements(&mut self, update: &accesskit::TreeUpdate) {
85        use accesskit::Live;
86        let mut seen: std::collections::HashSet<accesskit::NodeId> =
87            std::collections::HashSet::with_capacity(self.automation_last_text.len());
88        for (node_id, node) in &update.nodes {
89            let assertive = match node.live() {
90                Some(Live::Polite) => false,
91                Some(Live::Assertive) => true,
92                // `Live::Off` or unset: not a live region.
93                _ => continue,
94            };
95            // A live region announces its `value` if it has one, else its
96            // `label` (matching how the AT layer voices it).
97            let text = node
98                .value()
99                .or_else(|| node.label())
100                .unwrap_or("")
101                .trim()
102                .to_string();
103            if text.is_empty() {
104                // A cleared live region: forget its last text (and leave it out
105                // of `seen`, so `retain` drops it too) so the SAME text
106                // reappearing on a later sync is announced as novel rather than
107                // deduped against the stale entry.
108                self.automation_last_text.remove(node_id);
109                continue;
110            }
111            seen.insert(*node_id);
112            let changed = self
113                .automation_last_text
114                .get(node_id)
115                .map(|prev| prev != &text)
116                .unwrap_or(true);
117            if !changed {
118                continue;
119            }
120            self.automation_last_text.insert(*node_id, text.clone());
121            self.automation_announce_seq = self.automation_announce_seq.saturating_add(1);
122            if self.automation_announcements.len() >= super::AUTOMATION_ANNOUNCE_CAP {
123                self.automation_announcements.pop_front();
124            }
125            self.automation_announcements
126                .push_back(crate::accessibility::Announcement {
127                    seq: self.automation_announce_seq,
128                    text,
129                    assertive,
130                });
131        }
132        // Forget nodes that are no longer present (or no longer live), so a
133        // node that reappears with identical text re-announces.
134        self.automation_last_text.retain(|k, _| seen.contains(k));
135    }
136
137    /// Dispatch a synthetic AccessKit action to the node identified by
138    /// `node_id` (which may be a *synthetic*, widget-emitted child node —
139    /// e.g. a rich-text `TextRun`). Resolves the owning widget exactly the
140    /// way the platform AT adapter does
141    /// ([`node_id_to_widget_id_maybe`](crate::accessibility::node_id_to_widget_id_maybe)
142    /// then [`widget_for_synthetic`](Self::widget_for_synthetic)), builds a
143    /// [`crate::event::WidgetEvent::AccessAction`],
144    /// and dispatches it through `ops` so actions that open windows /
145    /// dialogs work.
146    ///
147    /// Returns `true` when the action was **consumed** — a handler claimed it,
148    /// focus moved, or the context-menu fallback opened a menu — and `false`
149    /// when nothing acted on it, including when the target node resolves to no
150    /// live widget. Callers are expected to surface that: an action a node
151    /// never handles is a caller error, and reporting it as success (which is
152    /// what "a widget existed at the target" amounted to) leaves an automation
153    /// client chasing timing and coordinates for a UI that was never going to
154    /// move.
155    ///
156    /// This is the in-process equivalent of an OS screen reader invoking an
157    /// action — the channel an [automation](crate::WidgetTree) harness uses
158    /// to *drive* the UI without the OS AT layer.
159    pub fn dispatch_access_action(
160        &mut self,
161        node_id: accesskit::NodeId,
162        action: accesskit::Action,
163        data: Option<accesskit::ActionData>,
164        ops: &mut dyn crate::window::WindowOps,
165    ) -> bool {
166        let target = crate::accessibility::node_id_to_widget_id_maybe(node_id)
167            .or_else(|| self.widget_for_synthetic(node_id));
168        let event = crate::event::WidgetEvent::AccessAction {
169            action,
170            target,
171            target_node: node_id,
172            data,
173        };
174        self.access_action_handled = false;
175        self.dispatch_event_with_ops(event, ops);
176        self.access_action_handled
177    }
178
179    fn build_accessibility_tree(
180        &self,
181    ) -> (
182        accesskit::TreeUpdate,
183        std::collections::HashMap<accesskit::NodeId, WidgetId>,
184    ) {
185        use crate::accessibility::{root_node_id, widget_id_to_node_id};
186
187        let roots = self.arena.roots();
188        let mut nodes: Vec<(accesskit::NodeId, accesskit::Node)> = Vec::new();
189        let mut synthetic_parents: std::collections::HashMap<accesskit::NodeId, WidgetId> =
190            std::collections::HashMap::new();
191        // Global deduplication: AccessKit's consumer panics if the same child
192        // NodeId appears in more than one node's children list across a TreeUpdate.
193        // Track which widget first claimed each child so we can skip duplicates
194        // and emit a diagnostic pointing at the two conflicting parents.
195        let mut seen_children: std::collections::HashMap<accesskit::NodeId, WidgetId> =
196            std::collections::HashMap::new();
197
198        let mut root = accesskit::Node::new(accesskit::Role::Window);
199        // Tag the root with the app's current locale (BCP-47, e.g. "fr-FR").
200        // AccessKit nodes inherit `language` from their ancestors, so setting
201        // it once on the Window node propagates to the whole tree. Without it,
202        // VoiceOver/Narrator have no language hint and fall back to a default
203        // (often English) TTS voice instead of the user's system voice. The
204        // locale is fed in by the app layer via `WidgetTree::set_locale`.
205        if let Some(locale) = self.locale_signal.get() {
206            root.set_language(locale);
207        }
208        for &root_id in &roots {
209            if self.arena.is_active(root_id) {
210                let child_nid = widget_id_to_node_id(root_id);
211                if seen_children.insert(child_nid, root_id).is_none() {
212                    root.push_child(child_nid);
213                } else {
214                    eprintln!(
215                        "Teksilo bug: duplicate accessibility child {:?} in Window root — \
216                         already claimed by another parent. Please file a bug report.",
217                        root_id
218                    );
219                }
220            }
221        }
222        nodes.push((root_node_id(), root));
223
224        // One pass, one set: a tooltip is placed once, on the first node that
225        // may have it, and the walk order makes that the owner rather than the
226        // anchor.
227        let mut handled_tooltips: std::collections::HashSet<WidgetId> =
228            std::collections::HashSet::new();
229        for &root_id in &roots {
230            self.build_accessibility_recursive(
231                root_id,
232                &mut nodes,
233                &mut synthetic_parents,
234                &mut seen_children,
235                &mut handled_tooltips,
236            );
237        }
238
239        let focus = self
240            .focused
241            .filter(|id| self.arena.is_active(*id))
242            .map(widget_id_to_node_id)
243            .unwrap_or_else(root_node_id);
244
245        // ── Name-from-content for row nodes ───────────────────────────
246        // A virtualized row is two nodes: a thin structural wrapper
247        // (`ListItemWrapper` / `TreeItemWrapper`) carrying the role, level
248        // and selected/expanded state, and the app's delegate widget below
249        // it carrying the visible label. ARIA computes an `option`'s /
250        // `treeitem`'s name from its contents; AccessKit does not. Without
251        // this pass the platform adapters announce a nameless "tree item",
252        // and any client that matches a role AND a label — a screen-reader
253        // search, the automation bridge's `find_node` — can never match a
254        // row, because the two live on different nodes.
255        //
256        // Fill each nameless row node's name in from its first named
257        // descendant, in the order they read. The descendant keeps its own
258        // name (as in a browser's accessibility tree, where the text that
259        // contributes to a computed name stays in the tree).
260        {
261            use accesskit::Role;
262            use std::collections::HashMap;
263
264            // Row roles that take their name from content. A row inside a
265            // row (never emitted today, but cheap to be correct about) owns
266            // its own name and terminates the search.
267            fn names_from_content(role: Role) -> bool {
268                matches!(role, Role::TreeItem | Role::ListBoxOption)
269            }
270
271            let index: HashMap<accesskit::NodeId, usize> = nodes
272                .iter()
273                .enumerate()
274                .map(|(i, (nid, _))| (*nid, i))
275                .collect();
276
277            let mut hoisted: Vec<(usize, String)> = Vec::new();
278            for (i, (_, node)) in nodes.iter().enumerate() {
279                if !names_from_content(node.role()) || node.label().is_some() {
280                    continue;
281                }
282                // Depth-first, children in document order.
283                let mut stack: Vec<accesskit::NodeId> =
284                    node.children().iter().rev().copied().collect();
285                while let Some(nid) = stack.pop() {
286                    let Some(&j) = index.get(&nid) else { continue };
287                    let descendant = &nodes[j].1;
288                    if names_from_content(descendant.role()) {
289                        continue;
290                    }
291                    match descendant.label() {
292                        Some(label) if !label.trim().is_empty() => {
293                            hoisted.push((i, label.to_string()));
294                            break;
295                        }
296                        _ => stack.extend(descendant.children().iter().rev().copied()),
297                    }
298                }
299            }
300            for (i, label) in hoisted {
301                nodes[i].1.set_label(label);
302            }
303        }
304
305        // ── Presentational-node pruning ───────────────────────────────
306        // Layout primitives (HStack/VStack/ZStack/Center/Padding/Expand/…)
307        // emit empty `GenericContainer` / `Unknown` AT nodes purely to
308        // carry visual structure. VoiceOver announces a `GenericContainer`
309        // as "group", so a Button whose chrome is composed from these
310        // primitives reads as "<label>, button, group". Browsers collapse
311        // such semantically-empty nodes out of the platform tree
312        // ("ignored" / "presentational" nodes); do the same — drop each
313        // empty container and PROMOTE its children to its parent (bounds
314        // are absolute, so promotion is structural only).
315        //
316        // A node is prunable only if it is a visible, content-free
317        // `GenericContainer`/`Unknown`: no name, value, live region,
318        // popup, relation, or focus/click action. The Window root, the
319        // focused node, and any relationship target are always kept.
320        {
321            use std::collections::{HashMap, HashSet};
322
323            // Nodes referenced by another node's relations must survive so
324            // the reference can't dangle.
325            let mut relation_targets: HashSet<accesskit::NodeId> = HashSet::new();
326            for (_, node) in &nodes {
327                relation_targets.extend(node.controls());
328                relation_targets.extend(node.described_by());
329                relation_targets.extend(node.labelled_by());
330            }
331
332            let prunable: HashSet<accesskit::NodeId> = nodes
333                .iter()
334                .filter(|(nid, node)| {
335                    *nid != root_node_id()
336                        && *nid != focus
337                        && !relation_targets.contains(nid)
338                        && is_presentational_container(node)
339                })
340                .map(|(nid, _)| *nid)
341                .collect();
342
343            if !prunable.is_empty() {
344                // Pre-pruning children lists, for chain resolution.
345                let children_map: HashMap<accesskit::NodeId, Vec<accesskit::NodeId>> = nodes
346                    .iter()
347                    .map(|(nid, node)| (*nid, node.children().to_vec()))
348                    .collect();
349
350                // A kept node's effective children: each prunable child is
351                // replaced by its own (recursively resolved) kept children,
352                // so chains of empty containers collapse in one pass. The
353                // AT tree is acyclic, so the memo is the only guard needed.
354                fn resolve(
355                    nid: accesskit::NodeId,
356                    children_map: &HashMap<accesskit::NodeId, Vec<accesskit::NodeId>>,
357                    prunable: &HashSet<accesskit::NodeId>,
358                    memo: &mut HashMap<accesskit::NodeId, Vec<accesskit::NodeId>>,
359                ) -> Vec<accesskit::NodeId> {
360                    if let Some(cached) = memo.get(&nid) {
361                        return cached.clone();
362                    }
363                    let mut out = Vec::new();
364                    if let Some(kids) = children_map.get(&nid) {
365                        for &c in kids {
366                            if prunable.contains(&c) {
367                                out.extend(resolve(c, children_map, prunable, memo));
368                            } else {
369                                out.push(c);
370                            }
371                        }
372                    }
373                    memo.insert(nid, out.clone());
374                    out
375                }
376
377                let mut memo: HashMap<accesskit::NodeId, Vec<accesskit::NodeId>> = HashMap::new();
378                for (nid, node) in &mut nodes {
379                    if prunable.contains(nid) {
380                        continue;
381                    }
382                    let resolved = resolve(*nid, &children_map, &prunable, &mut memo);
383                    if children_map.get(nid) != Some(&resolved) {
384                        node.set_children(resolved);
385                    }
386                }
387                nodes.retain(|(nid, _)| !prunable.contains(nid));
388            }
389        }
390
391        // Strip relationship targets (controls, described_by) that reference
392        // NodeIds absent from the emitted tree. Dormant widgets (e.g. inactive
393        // tab panels) are excluded from the TreeUpdate; if a node still holds a
394        // `push_controlled` or `push_described_by` reference to one of them,
395        // accesskit_macos will unwrap() it and panic when VoiceOver follows the
396        // linked_ui_elements attribute.
397        let emitted: std::collections::HashSet<accesskit::NodeId> =
398            nodes.iter().map(|(id, _)| *id).collect();
399        for (_, node) in &mut nodes {
400            let controlled: Vec<_> = node
401                .controls()
402                .iter()
403                .filter(|id| emitted.contains(*id))
404                .copied()
405                .collect();
406            if controlled.len() != node.controls().len() {
407                node.set_controls(controlled);
408            }
409            let described: Vec<_> = node
410                .described_by()
411                .iter()
412                .filter(|id| emitted.contains(*id))
413                .copied()
414                .collect();
415            if described.len() != node.described_by().len() {
416                node.set_described_by(described);
417            }
418        }
419
420        (
421            accesskit::TreeUpdate {
422                nodes,
423                tree: Some(accesskit::TreeInfo::new(root_node_id())),
424                tree_id: accesskit::TreeId::ROOT,
425                focus,
426            },
427            synthetic_parents,
428        )
429    }
430
431    // (helper `is_presentational_container` is a module-level free fn below)
432
433    /// Look up the owning widget for a synthetic AccessKit `NodeId`
434    /// emitted by `push_text_run_child` / `push_paragraph_child`.
435    /// Used by `handle_accessibility_actions` to route an
436    /// `ActionRequest` targeting a TextRun child back to the
437    /// editor that owns it.
438    pub fn widget_for_synthetic(&self, node_id: accesskit::NodeId) -> Option<WidgetId> {
439        self.synthetic_parent_map.get(&node_id).copied()
440    }
441
442    fn build_accessibility_recursive(
443        &self,
444        id: WidgetId,
445        nodes: &mut Vec<(accesskit::NodeId, accesskit::Node)>,
446        synthetic_parents: &mut std::collections::HashMap<accesskit::NodeId, WidgetId>,
447        seen_children: &mut std::collections::HashMap<accesskit::NodeId, WidgetId>,
448        // Tooltips whose text has already been placed on a node this pass.
449        // One tooltip describes one control, so once an owner has taken it the
450        // anchor further down must not take it again -- a description announced
451        // twice on the way into a control is worse than one announced in the
452        // wrong place.
453        handled_tooltips: &mut std::collections::HashSet<WidgetId>,
454    ) {
455        use crate::accessibility::widget_id_to_node_id;
456        use crate::widget_builder::AccessSubtreeMode;
457
458        if !self.arena.is_active(id) {
459            return;
460        }
461
462        let node = self.arena.get(id).expect("widget id is active in arena");
463        let mut builder = AccessNodeBuilder::for_widget(id);
464        node.widget.accessibility(&mut builder);
465        // Same call, same place, as `build_overridden_builder` — the two builder
466        // paths are parallel by construction and a derivation added to one of them
467        // only is a node that reads differently to a test than to a screen reader.
468        self.announce_context_menu(id, &mut builder);
469
470        // Apply builder-level overrides AFTER the inner widget has
471        // emitted its defaults, so the overrides win for scalar fields
472        // and append on relationship lists.
473        if let Some(ov) = node.access_overrides.as_deref() {
474            ov.apply(&mut builder);
475            // `access_shortcut_id` resolution happens here in the
476            // walker (not in `apply()`) because the override struct
477            // can't reach the tree's `ShortcutRegistry`. Same
478            // mechanism as `MenuItem::for_shortcut(...)` — look up
479            // the effective primary keystroke and announce it via
480            // `KeyStroke`'s `Display` impl. Falls back silently if
481            // the id has no registered default.
482            if let Some(ref id) = ov.shortcut_id
483                && let Some(eff) = self.shortcut_registry.effective(id)
484                && let Some(ks) = eff.primary
485            {
486                builder.set_keyboard_shortcut(ks.to_string());
487            }
488        }
489
490        let subtree_mode = node.access_subtree;
491        // G17: a widget may present a different child ORDER to assistive tech
492        // than its paint / z-order child order — e.g. TableView / TreeTableView
493        // build body rows before the header for correct z-stacking, but the
494        // header must read first in the AT (and Tab) linear order. Honour the
495        // widget's `accessibility_children()` override when it provides one;
496        // otherwise use the arena's paint-order child list. Geometry and paint
497        // are untouched (they keep reading `arena.children`).
498        let at_children_owned;
499        let children: &[WidgetId] = match node.widget.accessibility_children() {
500            Some(v) => {
501                at_children_owned = v;
502                &at_children_owned
503            }
504            None => self.arena.children(id),
505        };
506
507        // Subtree dispatch:
508        //   Inherit  — push child NodeIds onto the parent and recurse normally
509        //   Exclude  — neither push nor recurse: descendants vanish from AT
510        //   Merge    — collect descendants' label/description/value/actions
511        //              into THIS node, then prune (no push, no recurse)
512        match subtree_mode {
513            AccessSubtreeMode::Inherit => {
514                for &child_id in children {
515                    if self.arena.is_active(child_id) {
516                        let child_nid = widget_id_to_node_id(child_id);
517                        // AT-redirect hook (scene logical-tree auto-graft):
518                        // walk up the arena from `id` asking every
519                        // opted-in ancestor whether it claims this
520                        // descendant. First `Some(_)` wins, scanned
521                        // bottom-up so closest ancestor takes
522                        // priority. The immediate parent is queried
523                        // first if it opts in — direct-child
524                        // relocation is the special case of an
525                        // ancestor walk of length zero.
526                        //
527                        // Performance: most widgets default
528                        // `wants_descendant_redirects = false` and
529                        // are skipped without calling the hook, so
530                        // the walk is O(opted-in ancestors) per
531                        // child push, typically 0 or 1 for a
532                        // SceneView-rooted subtree.
533                        if self.ancestor_chain_redirects(id, child_id) {
534                            // Still record so a sibling can't
535                            // double-claim the same descendant.
536                            seen_children.insert(child_nid, id);
537                            continue;
538                        }
539                        if let Some(&prior_parent) = seen_children.get(&child_nid) {
540                            eprintln!(
541                                "Teksilo bug: duplicate accessibility child {:?}: \
542                                 first claimed by parent {:?}, now also claimed by {:?}. \
543                                 Please file a bug report.",
544                                child_id, prior_parent, id
545                            );
546                            continue;
547                        }
548                        seen_children.insert(child_nid, id);
549                        builder.inner_mut().push_child(child_nid);
550                    }
551                }
552            }
553            AccessSubtreeMode::Exclude => {
554                // No children pushed, no descendants recursed-into.
555            }
556            AccessSubtreeMode::Merge => {
557                merge_descendants_into(&mut builder, id, &self.arena);
558            }
559        }
560
561        let bounds = self.arena.bounds(id);
562        builder.inner_mut().set_bounds(accesskit::Rect {
563            x0: bounds.x as f64,
564            y0: bounds.y as f64,
565            x1: (bounds.x + bounds.width) as f64,
566            y1: (bounds.y + bounds.height) as f64,
567        });
568
569        // Framework-driven disabled gate. Respects an
570        // `access_disabled(false)` override that wants to clear
571        // arena-driven disabled state too — without this short-circuit,
572        // `clear_disabled()` in the override layer would be re-set here.
573        let force_clear_disabled =
574            node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
575        if !self.arena.is_enabled(id) && !force_clear_disabled {
576            builder.set_disabled();
577        }
578
579        // ── the tooltip's text, onto the node that will be read ───────────
580        //
581        // Not necessarily the node the overlay hangs off. A composing control
582        // anchors the tooltip on an inner chrome node -- the thing with the
583        // right bounds to open against -- and keeps its role, its name and its
584        // focusability on its own outer node. Emitting on the anchor put the
585        // description on an unnamed box beside the control: present in the
586        // tree, attached to nothing anyone reads. Since a plain tooltip is
587        // never auto-shown on focus (see `docs/tooltips.md`), that description
588        // IS the whole non-pointer path for the tier, and it reached nobody.
589        //
590        // So a tooltip names an owner, and the owner is honoured here -- but
591        // only where exactly ONE tooltip claims it. `BuildContext` records the
592        // widget that was building, and one build can attach many tooltips: a
593        // list body pane attaches one per visible row, all of them naming the
594        // pane. Granting that would put one row's text on the pane and lose
595        // every other row's entirely. A contested claim is no claim, and each
596        // of those tooltips falls back to its own anchor, which is where they
597        // already were.
598        if let Some(content_id) =
599            self.tooltip_description_target(id, handled_tooltips, &mut builder)
600        {
601            if self
602                .tooltips
603                .iter()
604                .any(|t| t.content_id == content_id && t.overlay_id.is_some())
605            {
606                // Shown: the content node is live in the AT tree, so the
607                // richer `described_by` relation can point straight at it.
608                builder
609                    .inner_mut()
610                    .push_described_by(widget_id_to_node_id(self.tooltip_content_node(content_id)));
611                handled_tooltips.insert(content_id);
612            } else if let Some(text) = self.tooltip_access_description(content_id) {
613                // Not shown. `described_by` cannot be used — the content is a
614                // dormant node and absent from the AT tree — and a *plain*
615                // tooltip is never auto-shown on focus, so gating the relation
616                // on `overlay_id` left the whole tier (the majority of call
617                // sites) with no screen-reader path at all. Copy the text onto
618                // the control as a static description instead, which is what a
619                // keyboard-only or screen-reader user actually reaches.
620                builder.set_description(text);
621                handled_tooltips.insert(content_id);
622            }
623        }
624
625        let (node_id, ak_node, synthetic_children) = builder.build(id);
626        nodes.push((node_id, ak_node));
627        // Merge the widget's emitted synthetic children into the
628        // tree update and record their parent-widget mapping so
629        // `handle_accessibility_actions` can route incoming
630        // `ActionRequest`s targeting these child NodeIds back to
631        // the owning widget.
632        for (syn_id, syn_node) in synthetic_children {
633            nodes.push((syn_id, syn_node));
634            synthetic_parents.insert(syn_id, id);
635        }
636
637        // Recurse only for `Inherit` — `Exclude` and `Merge` prune
638        // descendants from the AT tree.
639        if matches!(subtree_mode, AccessSubtreeMode::Inherit) {
640            for &child_id in children {
641                self.build_accessibility_recursive(
642                    child_id,
643                    nodes,
644                    synthetic_parents,
645                    seen_children,
646                    handled_tooltips,
647                );
648            }
649        }
650    }
651
652    /// Walk the arena from `parent_id` up through ancestors, asking
653    /// each opted-in widget whether it claims `descendant` via
654    /// [`Widget::a11y_redirect_descendant`]. First widget that
655    /// returns `Some(_)` wins (closest-ancestor-first scan). Returns
656    /// `true` if any ancestor claimed the descendant — the caller
657    /// then skips the default child-list push.
658    ///
659    /// Cost: bounded by arena depth, but most widgets default
660    /// `wants_descendant_redirects = false` and short-circuit
661    /// without invoking the redirect hook itself. Trees with no
662    /// opted-in ancestors pay one `is_active` + one `Widget::
663    /// wants_descendant_redirects` call per ancestor — both
664    /// trivial — and walk to root.
665    ///
666    /// `parent_id` is queried *first* — direct-child relocation is
667    /// just the special case of an ancestor walk of length one.
668    fn ancestor_chain_redirects(&self, parent_id: WidgetId, descendant: WidgetId) -> bool {
669        let mut current = Some(parent_id);
670        while let Some(curr) = current {
671            let Some(curr_node) = self.arena.get(curr) else {
672                break;
673            };
674            if curr_node.widget.wants_descendant_redirects()
675                && curr_node
676                    .widget
677                    .a11y_redirect_descendant(curr, descendant)
678                    .is_some()
679            {
680                return true;
681            }
682            current = self.arena.parent(curr);
683        }
684        false
685    }
686
687    /// Build a builder representing the widget's full a11y state at this
688    /// instant — the inner widget's `accessibility(builder)` plus any
689    /// builder-level overrides (`access_label`, `access_role`, …) and,
690    /// when the widget has `access_subtree(Merge)`, the merged
691    /// descendant state. Centralized so `accessibility_node`,
692    /// `text_content`, and the recursive walker stay in sync.
693    /// Which tooltip's text this node should carry, if any.
694    ///
695    /// Two ways a node can come to own a tooltip's description, tried in that
696    /// order:
697    ///
698    ///   * **as the claimed owner** -- the widget that was building when the
699    ///     tooltip was attached. This is the case the whole mechanism exists
700    ///     for: `Button` and the two dozen controls shaped like it hang the
701    ///     overlay off an inner chrome node while their role and name live
702    ///     out here. Granted only when this node is *unambiguously* the
703    ///     owner: exactly one tooltip may claim it, and the node must be
704    ///     something an assistive technology would actually stop on rather
705    ///     than an anonymous box.
706    ///
707    ///   * **as the anchor** -- the historic behaviour, and still correct for
708    ///     a widget that anchors its tooltip on itself. Also the fallback for
709    ///     everything the first case declines, so declining is always safe:
710    ///     the worst it can do is leave the description exactly where it was
711    ///     before any of this existed.
712    ///
713    /// The order matters and only works because of the walk's order. An
714    /// anchor is always a descendant of the widget that built it, and parents
715    /// are visited first, so an owner has already taken its tooltip (and said
716    /// so in `handled`) by the time the anchor is reached. Reverse the walk
717    /// and both would take it.
718    fn tooltip_description_target(
719        &self,
720        id: WidgetId,
721        handled: &std::collections::HashSet<WidgetId>,
722        builder: &mut AccessNodeBuilder,
723    ) -> Option<WidgetId> {
724        let mut claims = self
725            .tooltips
726            .iter()
727            .filter(|t| t.description_owner_id == id && !handled.contains(&t.content_id));
728        // `next()` twice rather than `count()`: one claim is the answer, two is
729        // a refusal, and there is nothing to learn from a third.
730        if let (Some(only), None) = (claims.next(), claims.next())
731            && only.anchor_id != id
732            // An anonymous container is not a place a description can be read
733            // from, and a widget whose own `accessibility()` leaves it one
734            // (`TextInput` says so out loud, keeping the real role on an inner
735            // field) is not the control being described either. Nothing is
736            // lost by declining: the anchor still takes it below.
737            && !is_presentational_container(builder.inner_mut())
738            // A description the widget wrote itself is the widget's own words
739            // about itself; a tooltip's is supplementary. Both land in the one
740            // scalar field, so the specific one wins. `MenuItem::trailing_hint`
741            // is the case that made this reachable.
742            && builder.inner_mut().description().is_none()
743        {
744            return Some(only.content_id);
745        }
746        self.tooltips
747            .iter()
748            .find(|t| t.anchor_id == id && !handled.contains(&t.content_id))
749            .map(|t| t.content_id)
750    }
751
752    /// Announce the context menu a widget owns, before any override runs.
753    ///
754    /// The dispatcher has always *serviced* `Action::ShowContextMenu` by falling
755    /// through to the node's `.context_menu(..)` factory, so an assistive
756    /// technology that tried the action got a menu. Nothing ever told it the
757    /// action was there, and an AT offers what a node advertises: the menu was
758    /// reachable and undiscoverable at the same time.
759    ///
760    /// That is not a cosmetic gap wherever a menu is the accessible route to
761    /// something else. A row that puts its actions on hover buttons has to hide
762    /// those buttons from the AT -- a control that exists only under a pointer
763    /// does not exist for a keyboard -- and offer the same actions on its context
764    /// menu instead. Unannounced, that leaves the actions with no route at all.
765    ///
766    /// Only where the node owns the factory ITSELF, not wherever the ancestor walk
767    /// would eventually find one: every descendant of a widget with a menu would
768    /// otherwise advertise it, and an AT would offer the same menu on a dozen
769    /// nested boxes. Applied BEFORE the overrides so `access_remove_action` still
770    /// takes it away, and so a widget wiring its own `on_access_action` handler is
771    /// not given a second one.
772    fn announce_context_menu(&self, id: WidgetId, builder: &mut AccessNodeBuilder) {
773        if self
774            .arena
775            .get(id)
776            .is_some_and(|node| node.context_menu_factory.is_some())
777            && !builder
778                .actions()
779                .contains(&accesskit::Action::ShowContextMenu)
780        {
781            builder.add_action(accesskit::Action::ShowContextMenu);
782        }
783    }
784
785    fn build_overridden_builder(&self, id: WidgetId) -> AccessNodeBuilder {
786        use crate::widget_builder::AccessSubtreeMode;
787        let node = self.arena.get(id).expect("widget id is active in arena");
788        let mut builder = AccessNodeBuilder::for_widget(id);
789        node.widget.accessibility(&mut builder);
790        self.announce_context_menu(id, &mut builder);
791        if let Some(ov) = node.access_overrides.as_deref() {
792            ov.apply(&mut builder);
793            // Resolve `access_shortcut_id` against the live registry —
794            // see the matching block in `build_accessibility_recursive`.
795            if let Some(ref sid) = ov.shortcut_id
796                && let Some(eff) = self.shortcut_registry.effective(sid)
797                && let Some(ks) = eff.primary
798            {
799                builder.set_keyboard_shortcut(ks.to_string());
800            }
801        }
802        if node.access_subtree == AccessSubtreeMode::Merge {
803            merge_descendants_into(&mut builder, id, &self.arena);
804        }
805        builder
806    }
807
808    /// The text a tooltip would announce, harvested from its (dormant)
809    /// content widget so an anchor can carry it as a static AT description
810    /// while the tooltip is not shown.
811    ///
812    /// All three tiers publish their body through `accessibility()` as the
813    /// node *name* (`TooltipWidget::accessibility` → `set_name(text)`), so
814    /// probing the widget is enough — no parallel copy of the text has to be
815    /// threaded through `attach_tooltip*` and kept in sync. Reads at walk
816    /// time, so a locale change or a `Signal<String>` swap is picked up by the
817    /// same AT re-walk that already tracks them.
818    /// Resolve a tooltip's content node past a deferred host.
819    ///
820    /// A deferred tooltip body answers for itself while un-built (see
821    /// `DeferredSubtree::accessibility`), but once the user has hovered it the
822    /// host has handed its widget value to a real child and has nothing left to
823    /// say. Follow it, or a tooltip's description would be readable exactly
824    /// until the first time it was shown.
825    pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
826        self.arena
827            .get(content_id)
828            .and_then(|node| node.widget.as_any())
829            .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
830            .and_then(|d| d.materialized_child())
831            .unwrap_or(content_id)
832    }
833
834    pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
835        let content_id = self.tooltip_content_node(content_id);
836        let node = self.arena.get(content_id)?;
837        let mut probe = AccessNodeBuilder::for_widget(content_id);
838        node.widget.accessibility(&mut probe);
839        if let Some(ov) = node.access_overrides.as_deref() {
840            ov.apply(&mut probe);
841        }
842        probe
843            .name()
844            .map(str::trim)
845            .filter(|name| !name.is_empty())
846            .map(str::to_owned)
847    }
848
849    pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
850        let node = self.arena.get(id).expect("widget id is active in arena");
851        let builder = self.build_overridden_builder(id);
852        let role = builder.role();
853        let name = builder.name().map(|s| s.to_string());
854        let actions = builder.actions().to_vec();
855        let mut info = AccessibilityInfo::new(role, name, actions);
856        if let Some(toggled) = builder.toggled() {
857            info = info.with_toggled(toggled);
858        }
859        if let Some(expanded) = builder.expanded() {
860            info = info.with_expanded(expanded);
861        }
862        if let Some(selected) = builder.selected() {
863            info = info.with_selected(selected);
864        }
865        // Mirror the framework gate at `build_accessibility_recursive`:
866        // arena-driven disabled wins unless the override explicitly
867        // asks for `access_disabled(false)`.
868        let force_clear_disabled =
869            node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
870        let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
871        // Override `Some(true)` already called `set_disabled()` inside
872        // the override apply; we only need to surface it here as a
873        // separate signal because `AccessNodeBuilder` doesn't expose a
874        // `is_disabled()` getter on the builder side.
875        let disabled_override =
876            node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(true);
877        if disabled_arena || disabled_override {
878            info = info.with_disabled(true);
879        }
880        if builder.is_hidden() {
881            info = info.with_hidden(true);
882        }
883        info
884    }
885
886    pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
887        self.arena
888            .active_ids_iter()
889            .find(|&id| self.build_overridden_builder(id).role() == role)
890    }
891
892    pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
893        self.arena
894            .active_ids_iter()
895            .find(|&id| self.build_overridden_builder(id).name() == Some(label))
896    }
897
898    pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
899        self.arena.active_ids_iter().find(|&id| {
900            self.build_overridden_builder(id)
901                .actions()
902                .contains(&action)
903        })
904    }
905
906    /// Get the text content of a widget from its accessibility name.
907    /// Equivalent to the label set via `AccessNodeBuilder::set_name`,
908    /// after override application.
909    pub fn text_content(&self, id: WidgetId) -> Option<String> {
910        self.arena.get(id)?;
911        self.build_overridden_builder(id)
912            .name()
913            .map(|s| s.to_string())
914    }
915
916    /// Get the text value of a widget from its accessibility value.
917    /// Equivalent to the value set via `AccessNodeBuilder::set_value`,
918    /// after override application.
919    pub fn text_value(&self, id: WidgetId) -> Option<String> {
920        self.arena.get(id)?;
921        self.build_overridden_builder(id)
922            .value()
923            .map(|s| s.to_string())
924    }
925}
926
927/// Whether an AT node is a purely-structural container that should be
928/// collapsed out of the tree (its children promoted to its parent).
929///
930/// True only for a content-free `GenericContainer` / `Unknown` node — the
931/// empty boxes layout primitives (HStack/VStack/Padding/Expand/…) emit.
932/// The check is exhaustive by construction: set aside the framework-applied
933/// children, bounds, and disabled flag, then compare against a fresh
934/// default node of the same role. Any author- or widget-set property —
935/// name, value, description, orientation, aria-current, identifier, live
936/// region, popup, action, relation, hidden flag, … — makes the node differ
937/// from the default and keeps it, so no semantic property can be missed.
938/// Callers additionally exempt the Window root, the focused node, and
939/// relationship targets.
940fn is_presentational_container(node: &accesskit::Node) -> bool {
941    use accesskit::Role;
942    if !matches!(node.role(), Role::GenericContainer | Role::Unknown) {
943        return false;
944    }
945    // Set aside the framework-applied structural bits (children, bounds,
946    // arena-driven disabled flag), then check the node carries no semantic
947    // content by comparing against a bare node of the same role.
948    //
949    // We compare the *Debug* form, not `==`: AccessKit's `clear_*` leaves
950    // residue in the private property-value vec (the index is unset but the
951    // value stays), so `PartialEq` never matches a fresh node. `Node`'s
952    // Debug renders only logically-set properties, so it reflects true
953    // content and is exhaustive — any author/widget property keeps the node.
954    let mut probe = node.clone();
955    probe.clear_children();
956    probe.clear_bounds();
957    probe.clear_disabled();
958    format!("{probe:?}") == format!("{:?}", accesskit::Node::new(node.role()))
959}
960
961// ─────────────────────────────────────────────────────────────────────
962// Subtree-merge helpers
963// ─────────────────────────────────────────────────────────────────────
964//
965// These run when a widget's `access_subtree` is `Merge`. The walker
966// recurses through the descendants, applies each descendant's own
967// `accessibility() + override apply()` into a temp builder, and absorbs
968// the resulting label / description / value / actions / relationships
969// into a `MergeAccumulator`. After the walk finishes the accumulator
970// flushes its accumulated state onto the parent's builder.
971//
972// The accumulator deliberately discards descendant role and numeric
973// fields (parent's role wins for the merged element) and discards
974// hidden / disabled (parent's state governs the whole merged subtree).
975// Action lists union with deduplication so two child Buttons each
976// emitting `Click` don't pollute the merged parent with two copies.
977
978/// Walk the descendants of `parent_id` and absorb their label /
979/// description / value / actions / relationships into `parent_builder`.
980/// Per-descendant subtree-mode handling lives in
981/// [`merge_collect_recursive`].
982fn merge_descendants_into(
983    parent_builder: &mut AccessNodeBuilder,
984    parent_id: WidgetId,
985    arena: &crate::arena::WidgetArena,
986) {
987    let mut acc = MergeAccumulator::default();
988    for &child in arena.children(parent_id) {
989        merge_collect_recursive(child, arena, &mut acc);
990    }
991    acc.flush_into(parent_builder);
992}
993
994fn merge_collect_recursive(
995    id: WidgetId,
996    arena: &crate::arena::WidgetArena,
997    acc: &mut MergeAccumulator,
998) {
999    use crate::widget_builder::AccessSubtreeMode;
1000
1001    if !arena.is_active(id) {
1002        return;
1003    }
1004    let Some(node) = arena.get(id) else {
1005        return;
1006    };
1007
1008    // Build a temp builder for this descendant the same way the walker
1009    // would: widget.accessibility() then override apply(). This means
1010    // a descendant's `.access_label(...)` contributes its resolved
1011    // override string, not its raw widget label.
1012    let mut tmp = AccessNodeBuilder::for_widget(id);
1013    node.widget.accessibility(&mut tmp);
1014    if let Some(ov) = node.access_overrides.as_deref() {
1015        ov.apply(&mut tmp);
1016    }
1017    // Nested-merge: the descendant itself has access_subtree=Merge.
1018    // Run its own merge into `tmp` BEFORE absorbing — otherwise we'd
1019    // absorb the descendant's empty container state and lose its
1020    // subtree-merged label.
1021    if matches!(node.access_subtree, AccessSubtreeMode::Merge) {
1022        merge_descendants_into(&mut tmp, id, arena);
1023    }
1024    // Skip nodes that opted out of AT entirely: a child marked
1025    // `access_hidden(true)` (or whose widget called `set_hidden()`)
1026    // contributes nothing to the merge.
1027    if !tmp.is_hidden() {
1028        acc.absorb(&tmp);
1029    }
1030
1031    match node.access_subtree {
1032        AccessSubtreeMode::Exclude => {
1033            // Prune — don't recurse into descendants of an excluded subtree.
1034        }
1035        AccessSubtreeMode::Merge => {
1036            // Nested Merge: descendant's own subtree was absorbed into
1037            // `tmp` above; don't re-walk its children at this level
1038            // (would double-count). The descendant reads as one
1039            // AT element from the outer merge's perspective.
1040        }
1041        AccessSubtreeMode::Inherit => {
1042            for &grandchild in arena.children(id) {
1043                merge_collect_recursive(grandchild, arena, acc);
1044            }
1045        }
1046    }
1047}
1048
1049/// Per-merge-walk accumulator. Collects descendant state across the
1050/// recursive walk, then `flush_into` writes the unioned values onto
1051/// the parent's builder.
1052///
1053/// Fields that the absorb path can read from `AccessNodeBuilder`
1054/// (label, value, advertised actions) participate in the merge.
1055/// Description and relationship lists (`controls` / `described_by` /
1056/// `labelled_by`) live inside `accesskit::Node` and have no public
1057/// getter on `AccessNodeBuilder`; merging them would require
1058/// reflecting builder mutations into a parallel field, which we
1059/// haven't found a real-world use case for. App authors who need
1060/// description / relationship merge can use the `access_customize`
1061/// escape hatch on the parent.
1062#[derive(Default)]
1063struct MergeAccumulator {
1064    label_parts: Vec<String>,
1065    /// First non-empty value wins.
1066    value: Option<String>,
1067    actions: Vec<accesskit::Action>,
1068}
1069
1070impl MergeAccumulator {
1071    fn absorb(&mut self, src: &AccessNodeBuilder) {
1072        if let Some(name) = src.name()
1073            && !name.is_empty()
1074        {
1075            self.label_parts.push(name.to_string());
1076        }
1077        if let Some(value) = src.value()
1078            && self.value.is_none()
1079            && !value.is_empty()
1080        {
1081            self.value = Some(value.to_string());
1082        }
1083        for &action in src.actions() {
1084            if !self.actions.contains(&action) {
1085                self.actions.push(action);
1086            }
1087        }
1088    }
1089
1090    fn flush_into(self, dst: &mut AccessNodeBuilder) {
1091        // Concatenate new label parts onto whatever the parent's
1092        // builder already carried. Existing parent name kept first.
1093        if !self.label_parts.is_empty() {
1094            let existing = dst.name().map(|s| s.to_string());
1095            let merged = match existing {
1096                Some(e) if !e.is_empty() => {
1097                    let mut s = e;
1098                    for part in self.label_parts {
1099                        s.push(' ');
1100                        s.push_str(&part);
1101                    }
1102                    s
1103                }
1104                _ => self.label_parts.join(" "),
1105            };
1106            dst.set_name(merged);
1107        }
1108        if let Some(v) = self.value {
1109            // Only overwrite parent value if it's currently None — the
1110            // parent's own value (from the inner widget or override
1111            // `access_value`) takes precedence.
1112            if dst.value().is_none() {
1113                dst.set_value(v);
1114            }
1115        }
1116        for action in self.actions {
1117            // Union: skip actions already advertised on the parent
1118            // (avoid duplicate Click/Focus when parent is itself a Button).
1119            if !dst.actions().contains(&action) {
1120                dst.add_action(action);
1121            }
1122        }
1123    }
1124}
1125
1126#[cfg(test)]
1127pub(crate) mod test_helpers {
1128    /// Feed a `TreeUpdate` into `accesskit_consumer::Tree`, which runs the
1129    /// same validation that every platform AT (VoiceOver, NVDA, …) runs on
1130    /// activation. Panics on duplicate children, dangling relationship
1131    /// targets, orphaned nodes, and invalid focus — turning those runtime
1132    /// crashes into CI failures.
1133    pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
1134        accesskit_consumer::Tree::new(update.clone(), false);
1135    }
1136
1137    /// Assert that every NodeId referenced in `controls()` or
1138    /// `described_by()` of any node is present in the tree. This is the
1139    /// invariant our post-processing pass enforces; having a test here means
1140    /// a future refactor can't silently drop the pass and regress it.
1141    pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
1142        let emitted: std::collections::HashSet<accesskit::NodeId> =
1143            update.nodes.iter().map(|(id, _)| *id).collect();
1144        for (parent_id, node) in &update.nodes {
1145            for &target in node.controls() {
1146                assert!(
1147                    emitted.contains(&target),
1148                    "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
1149                );
1150            }
1151            for &target in node.described_by() {
1152                assert!(
1153                    emitted.contains(&target),
1154                    "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
1155                );
1156            }
1157        }
1158    }
1159
1160    /// Return all NodeIds whose role matches `role`.
1161    #[allow(dead_code)]
1162    pub(crate) fn nodes_with_role(
1163        update: &accesskit::TreeUpdate,
1164        role: accesskit::Role,
1165    ) -> Vec<accesskit::NodeId> {
1166        update
1167            .nodes
1168            .iter()
1169            .filter(|(_, node)| node.role() == role)
1170            .map(|(id, _)| *id)
1171            .collect()
1172    }
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177    use super::test_helpers::*;
1178    use super::*;
1179    use crate::test_widgets::{FillWidget, StackWidget};
1180
1181    #[derive(Debug)]
1182    struct ActionWidget;
1183
1184    impl Widget for ActionWidget {
1185        fn layout_response(
1186            &self,
1187            proposal: SizeProposal,
1188            _ctx: &LayoutContext,
1189        ) -> crate::widget::LayoutResponse {
1190            proposal.resolve(0.0, 0.0).into()
1191        }
1192
1193        fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1194            builder.set_role(accesskit::Role::Button);
1195            builder.set_name("Save");
1196            builder.add_action(accesskit::Action::Click);
1197            builder.add_action(accesskit::Action::Focus);
1198        }
1199    }
1200
1201    #[derive(Debug)]
1202    struct ClickableWidget;
1203
1204    impl Widget for ClickableWidget {
1205        fn layout_response(
1206            &self,
1207            proposal: SizeProposal,
1208            _ctx: &LayoutContext,
1209        ) -> crate::widget::LayoutResponse {
1210            proposal.resolve(0.0, 0.0).into()
1211        }
1212
1213        fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1214            builder.set_role(accesskit::Role::Button);
1215            builder.set_name("Click Me");
1216            builder.add_action(accesskit::Action::Click);
1217        }
1218    }
1219
1220    #[test]
1221    fn labeled_widget_has_accessibility() {
1222        let mut tree = WidgetTree::new();
1223        let widget = tree.add(FillWidget::new().label("Hello"));
1224        tree.layout(SizeProposal::exact(100.0, 20.0));
1225        let info = tree.accessibility_node(widget);
1226        assert_eq!(info.role(), accesskit::Role::Label);
1227        assert_eq!(info.name(), Some("Hello"));
1228    }
1229
1230    #[test]
1231    fn find_by_label_works() {
1232        let mut tree = WidgetTree::new();
1233        let widget = tree.add(FillWidget::new().label("Save"));
1234        tree.layout(SizeProposal::exact(100.0, 20.0));
1235        assert_eq!(tree.find_by_label("Save"), Some(widget));
1236    }
1237
1238    #[test]
1239    fn label_role_emits_text_as_value_not_label() {
1240        // accesskit contract: a `Role::Label` node carries its text in the
1241        // `value` property, not `label`. Widgets set the accessible name via
1242        // `set_name` (-> `label`); the builder re-serializes it to `value`
1243        // when the role is `Role::Label`, otherwise the Name is empty under
1244        // Windows UIA and stray under macOS AXStaticText. The logical
1245        // introspection name (used by `find_by_label` etc.) is unchanged.
1246        let mut tree = WidgetTree::new();
1247        let widget = tree.add(FillWidget::new().label("Section")); // Role::Label
1248        tree.layout(SizeProposal::exact(100.0, 20.0));
1249
1250        // Logical view still reports the accessible name.
1251        assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1252
1253        // The *emitted* node carries the text in `value`, with `label` cleared.
1254        let update = tree.sync_accessibility();
1255        let node = &update
1256            .nodes
1257            .iter()
1258            .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1259            .expect("label node must be present in the tree update")
1260            .1;
1261        assert_eq!(node.role(), accesskit::Role::Label);
1262        assert_eq!(
1263            node.value(),
1264            Some("Section"),
1265            "Role::Label text must be exposed via `value`"
1266        );
1267        assert_eq!(
1268            node.label(),
1269            None,
1270            "Role::Label must not leave text in the `label` property"
1271        );
1272    }
1273
1274    #[test]
1275    fn find_by_role_works() {
1276        let mut tree = WidgetTree::new();
1277        tree.add(FillWidget::new().label("Text"));
1278        tree.layout(SizeProposal::exact(100.0, 20.0));
1279        assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1280    }
1281
1282    #[test]
1283    fn access_hidden_bound_to_signal_toggles_reactively() {
1284        use crate::signal::Signal;
1285        use crate::widget_builder::WidgetBuilder;
1286        let hidden = Signal::new(false);
1287        let mut tree = WidgetTree::new();
1288        let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1289        tree.layout(SizeProposal::exact(100.0, 30.0));
1290        assert!(
1291            !tree.accessibility_node(w).is_hidden(),
1292            "node should be visible to AT while the signal is false"
1293        );
1294        hidden.set(true);
1295        // `apply()` reads the prop fresh, so the pulled node reflects the flip.
1296        assert!(
1297            tree.accessibility_node(w).is_hidden(),
1298            "node should hide from AT when the bound signal flips to true"
1299        );
1300        hidden.set(false);
1301        assert!(!tree.accessibility_node(w).is_hidden());
1302    }
1303
1304    /// **A widget with a context menu says so.** The dispatcher already opened
1305    /// the menu for `Action::ShowContextMenu`; nothing advertised the action, and
1306    /// an assistive technology offers what a node advertises.
1307    #[test]
1308    fn a_widget_with_a_context_menu_advertises_the_action() {
1309        let mut tree = WidgetTree::new();
1310        let plain = tree.add(FillWidget::new().label("no menu"));
1311        let with_menu = tree.add(
1312            FillWidget::new()
1313                .label("has menu")
1314                .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1315        );
1316        tree.layout(SizeProposal::exact(100.0, 20.0));
1317
1318        assert!(
1319            tree.accessibility_node(with_menu)
1320                .actions()
1321                .contains(&Action::ShowContextMenu),
1322            "a node owning a context-menu factory must announce it"
1323        );
1324        assert!(
1325            !tree
1326                .accessibility_node(plain)
1327                .actions()
1328                .contains(&Action::ShowContextMenu),
1329            "and a node without one must not"
1330        );
1331    }
1332
1333    /// The action is derived BEFORE the overrides, so an author who explicitly
1334    /// takes it away still has it taken away.
1335    #[test]
1336    fn a_removed_context_menu_action_stays_removed() {
1337        let mut tree = WidgetTree::new();
1338        let id = tree.add(
1339            FillWidget::new()
1340                .label("suppressed")
1341                .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1342                .access_remove_action(Action::ShowContextMenu),
1343        );
1344        tree.layout(SizeProposal::exact(100.0, 20.0));
1345        assert!(
1346            !tree
1347                .accessibility_node(id)
1348                .actions()
1349                .contains(&Action::ShowContextMenu)
1350        );
1351    }
1352
1353    /// A descendant of a widget with a menu does **not** advertise it, even
1354    /// though the ancestor walk would open one there. Otherwise every nested box
1355    /// under a row with a menu would offer that menu, and an AT would read a
1356    /// dozen of them.
1357    #[test]
1358    fn a_child_does_not_advertise_its_parents_context_menu() {
1359        let mut tree = WidgetTree::new();
1360        let child = tree.add(FillWidget::new().label("inner"));
1361        let parent = tree.add(
1362            StackWidget::new()
1363                .add_child(child)
1364                .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1365        );
1366        tree.layout(SizeProposal::exact(100.0, 20.0));
1367
1368        assert!(
1369            tree.accessibility_node(parent)
1370                .actions()
1371                .contains(&Action::ShowContextMenu)
1372        );
1373        assert!(
1374            !tree
1375                .accessibility_node(child)
1376                .actions()
1377                .contains(&Action::ShowContextMenu),
1378            "the menu belongs to the widget that declared it"
1379        );
1380    }
1381
1382    #[test]
1383    fn accessibility_node_collects_actions() {
1384        let mut tree = WidgetTree::new();
1385        let widget = tree.add(ActionWidget);
1386        tree.layout(SizeProposal::exact(100.0, 40.0));
1387
1388        let info = tree.accessibility_node(widget);
1389        assert_eq!(info.role(), accesskit::Role::Button);
1390        assert_eq!(info.name(), Some("Save"));
1391        assert_eq!(info.actions().len(), 2);
1392        assert!(info.actions().contains(&accesskit::Action::Click));
1393        assert!(info.actions().contains(&accesskit::Action::Focus));
1394    }
1395
1396    #[test]
1397    fn sync_accessibility_produces_tree_update() {
1398        let mut tree = WidgetTree::new();
1399        tree.add(FillWidget::new().label("First"));
1400        tree.add(FillWidget::new().label("Second"));
1401        tree.layout(SizeProposal::exact(200.0, 100.0));
1402
1403        let update = tree.sync_accessibility();
1404        assert_eq!(update.nodes.len(), 3);
1405        assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1406        assert!(update.tree.is_some());
1407        assert_a11y_tree_valid(&update);
1408    }
1409
1410    #[test]
1411    fn root_node_carries_locale_as_language() {
1412        let mut tree = WidgetTree::new();
1413        tree.add(FillWidget::new().label("Bonjour"));
1414
1415        // No locale set yet → no language hint (VoiceOver uses its default).
1416        tree.layout(SizeProposal::exact(200.0, 100.0));
1417        let update = tree.sync_accessibility();
1418        assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1419        assert_eq!(
1420            update.nodes[0].1.language(),
1421            None,
1422            "no language before a locale is set"
1423        );
1424
1425        // Once the app sets the locale, the root Window node carries it as a
1426        // BCP-47 language tag, which AccessKit propagates to the whole subtree.
1427        tree.set_locale("fr-FR".to_string());
1428        let update = tree.sync_accessibility();
1429        assert_eq!(
1430            update.nodes[0].1.language(),
1431            Some("fr-FR"),
1432            "root node must advertise the active locale as its language"
1433        );
1434    }
1435
1436    #[test]
1437    fn sync_accessibility_excludes_dormant_widgets() {
1438        let mut tree = WidgetTree::new();
1439        tree.add(FillWidget::new().label("Active"));
1440        let dormant = tree.add(FillWidget::new().label("Dormant"));
1441        tree.layout(SizeProposal::exact(200.0, 100.0));
1442
1443        tree.set_dormant(dormant);
1444
1445        let update = tree.sync_accessibility();
1446        assert_eq!(update.nodes.len(), 2);
1447        assert_a11y_tree_valid(&update);
1448    }
1449
1450    /// Regression test: a Relayout-only signal flip (no activation
1451    /// change, no role/label/value change, no focus change, no overlay
1452    /// activation) must NOT dirty the AccessKit cache. Previously
1453    /// `layout()` set `a11y_dirty = true` unconditionally on every
1454    /// layout pass, which fired ~60 Hz on any scene with a Pulse /
1455    /// Cycle animation.
1456    #[test]
1457    fn relayout_without_activation_change_does_not_dirty_a11y() {
1458        let mut tree = WidgetTree::new();
1459        let id = tree.add(FillWidget::new().label("Static"));
1460        tree.layout(SizeProposal::exact(200.0, 100.0));
1461
1462        // First sync clears `a11y_dirty` and populates the cache.
1463        let _ = tree.sync_accessibility();
1464        assert!(
1465            !tree.a11y_dirty,
1466            "sync_accessibility must clear the dirty flag"
1467        );
1468
1469        // Simulate a Relayout-binding flip: the widget needs
1470        // re-layout, but its accessibility shape (active set, focus,
1471        // role, label, value) is unchanged.
1472        tree.arena.mark_needs_layout(id);
1473        tree.layout(SizeProposal::exact(200.0, 100.0));
1474
1475        assert!(
1476            !tree.a11y_dirty,
1477            "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1478        );
1479    }
1480
1481    /// Companion to the regression above: the dormant→active path
1482    /// MUST still dirty the AT cache, because the accessibility walk
1483    /// skips dormant nodes.
1484    #[test]
1485    fn activation_transition_does_dirty_a11y() {
1486        let mut tree = WidgetTree::new();
1487        let id = tree.add(FillWidget::new().label("Toggle"));
1488        tree.layout(SizeProposal::exact(200.0, 100.0));
1489        let _ = tree.sync_accessibility();
1490        assert!(!tree.a11y_dirty);
1491
1492        tree.set_dormant(id);
1493        tree.layout(SizeProposal::exact(200.0, 100.0));
1494        assert!(
1495            tree.a11y_dirty,
1496            "active→dormant transition must dirty the AT cache so the dormant node is removed"
1497        );
1498    }
1499
1500    #[test]
1501    fn sync_accessibility_includes_focus() {
1502        let mut tree = WidgetTree::new();
1503        let widget = tree.add(FillWidget::new().focusable().label("Focused"));
1504        tree.layout(SizeProposal::exact(100.0, 50.0));
1505        tree.focus(widget);
1506
1507        let update = tree.sync_accessibility();
1508        let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
1509        assert_eq!(update.focus, expected_focus);
1510        assert_a11y_tree_valid(&update);
1511    }
1512
1513    #[test]
1514    fn sync_accessibility_parent_child_relationship() {
1515        let mut tree = WidgetTree::new();
1516        let child = tree.add(FillWidget::new().label("Child"));
1517        // A label keeps the parent from being collapsed as a presentational
1518        // container, so this exercises the parent→child push (not pruning).
1519        let parent = tree.add(
1520            StackWidget::new()
1521                .add_child(child)
1522                .access_label_literal("Parent"),
1523        );
1524        tree.layout(SizeProposal::exact(100.0, 50.0));
1525
1526        let update = tree.sync_accessibility();
1527        assert_eq!(update.nodes.len(), 3);
1528
1529        let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
1530        let parent_node = update
1531            .nodes
1532            .iter()
1533            .find(|(id, _)| *id == parent_node_id)
1534            .map(|(_, node)| node)
1535            .unwrap();
1536
1537        let child_node_id = crate::accessibility::widget_id_to_node_id(child);
1538        assert!(parent_node.children().contains(&child_node_id));
1539        assert_a11y_tree_valid(&update);
1540    }
1541
1542    #[test]
1543    fn presentational_containers_collapse_and_promote_children() {
1544        // A chain of bare presentational containers (StackWidget → empty
1545        // `Role::Unknown`) wrapping a labeled leaf collapses entirely: the
1546        // leaf is promoted to its nearest semantic ancestor, and no empty
1547        // grouping node remains (VoiceOver would announce one as "group").
1548        // A *labeled* container is semantic and survives.
1549        let mut tree = WidgetTree::new();
1550        let leaf = tree.add(FillWidget::new().label("Leaf")); // Role::Label
1551        let inner = tree.add(StackWidget::new().add_child(leaf)); // bare → pruned
1552        let outer = tree.add(StackWidget::new().add_child(inner)); // bare → pruned
1553        let labeled = tree.add(
1554            StackWidget::new()
1555                .add_child(outer)
1556                .access_label_literal("Group"),
1557        );
1558        tree.layout(SizeProposal::exact(100.0, 50.0));
1559        let update = tree.sync_accessibility();
1560
1561        assert!(find_node(&update, inner).is_none(), "bare inner pruned");
1562        assert!(find_node(&update, outer).is_none(), "bare outer pruned");
1563
1564        let labeled_node = find_node(&update, labeled).expect("labeled group survives");
1565        let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
1566        assert!(
1567            labeled_node.children().contains(&leaf_nid),
1568            "leaf promoted past both bare containers to the labeled ancestor"
1569        );
1570        assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
1571        assert_a11y_tree_valid(&update);
1572    }
1573
1574    #[test]
1575    fn find_by_action_finds_clickable() {
1576        let mut tree = WidgetTree::new();
1577        let widget = tree.add(ClickableWidget);
1578        tree.layout(SizeProposal::exact(100.0, 40.0));
1579
1580        assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
1581        assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
1582    }
1583
1584    #[test]
1585    fn text_content_returns_accessibility_name() {
1586        let mut tree = WidgetTree::new();
1587        let widget = tree.add(FillWidget::new().label("Hello World"));
1588        tree.layout(SizeProposal::exact(100.0, 50.0));
1589
1590        assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
1591    }
1592
1593    #[test]
1594    fn text_content_returns_none_without_label() {
1595        let mut tree = WidgetTree::new();
1596        let widget = tree.add(FillWidget::new());
1597        tree.layout(SizeProposal::exact(100.0, 50.0));
1598
1599        assert_eq!(tree.text_content(widget), None);
1600    }
1601
1602    #[test]
1603    fn descendant_of_disabled_ancestor_reports_disabled() {
1604        use crate::signal::Signal;
1605
1606        let mut tree = WidgetTree::new();
1607        let child = tree.add(FillWidget::new().label("Child"));
1608        let parent = tree.add(StackWidget::new().add_child(child));
1609        tree.enabled_when(parent, Signal::new(false));
1610        tree.layout(SizeProposal::exact(100.0, 50.0));
1611
1612        assert!(
1613            tree.accessibility_node(child).is_disabled(),
1614            "descendant should report disabled when ancestor is disabled"
1615        );
1616    }
1617
1618    #[test]
1619    fn text_value_returns_accessibility_value() {
1620        #[derive(Debug)]
1621        struct ValueWidget;
1622
1623        impl Widget for ValueWidget {
1624            fn layout_response(
1625                &self,
1626                proposal: SizeProposal,
1627                _ctx: &LayoutContext,
1628            ) -> crate::widget::LayoutResponse {
1629                proposal.resolve(0.0, 0.0).into()
1630            }
1631
1632            fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1633                builder.set_role(accesskit::Role::Slider);
1634                builder.set_name("Volume");
1635                builder.set_value("75%");
1636            }
1637        }
1638
1639        let mut tree = WidgetTree::new();
1640        let widget = tree.add(ValueWidget);
1641        tree.layout(SizeProposal::exact(100.0, 40.0));
1642
1643        assert_eq!(tree.text_value(widget), Some("75%".to_string()));
1644        assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
1645    }
1646
1647    #[test]
1648    fn sync_accessibility_has_no_duplicate_children() {
1649        // Regression test for the AccessKit "duplicate child" crash (VoiceOver/NVDA).
1650        // assert_a11y_tree_valid already catches this via the consumer, but the
1651        // manual check here provides a more actionable failure message.
1652        let mut tree = WidgetTree::new();
1653        let grandchild = tree.add(FillWidget::new().label("Grandchild"));
1654        let child_a = tree.add(StackWidget::new().add_child(grandchild));
1655        let child_b = tree.add(FillWidget::new().label("Sibling"));
1656        let _root = tree.add(StackWidget::new().add_child(child_a).add_child(child_b));
1657        tree.layout(SizeProposal::exact(200.0, 100.0));
1658
1659        let update = tree.sync_accessibility();
1660
1661        let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
1662            std::collections::HashMap::new();
1663        for (parent_id, node) in &update.nodes {
1664            for &child_id in node.children() {
1665                let prev = all_children.insert(child_id, *parent_id);
1666                assert!(
1667                    prev.is_none(),
1668                    "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
1669                );
1670            }
1671        }
1672        assert_a11y_tree_valid(&update);
1673    }
1674
1675    #[test]
1676    fn no_dangling_relationships_in_basic_tree() {
1677        let mut tree = WidgetTree::new();
1678        let child = tree.add(FillWidget::new().label("Child"));
1679        let _parent = tree.add(StackWidget::new().add_child(child));
1680        tree.layout(SizeProposal::exact(100.0, 50.0));
1681
1682        let update = tree.sync_accessibility();
1683        assert_no_dangling_relationships(&update);
1684        assert_a11y_tree_valid(&update);
1685    }
1686
1687    // ── Builder-level accessibility override tests ───────────────────
1688    //
1689    // Tests for `WidgetBuilder::access_*` methods.
1690
1691    use crate::widget_builder::WidgetBuilder;
1692    use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
1693
1694    /// Find a node in a TreeUpdate by WidgetId.
1695    fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
1696        let nid = crate::accessibility::widget_id_to_node_id(id);
1697        update
1698            .nodes
1699            .iter()
1700            .find(|(node_id, _)| *node_id == nid)
1701            .map(|(_, n)| n)
1702    }
1703
1704    /// A widget that calls set_hidden() unconditionally — used to test
1705    /// `access_hidden(false)` clears widget-emitted hidden state.
1706    #[derive(Debug)]
1707    struct AlwaysHiddenWidget;
1708    impl Widget for AlwaysHiddenWidget {
1709        fn layout_response(
1710            &self,
1711            proposal: SizeProposal,
1712            _ctx: &LayoutContext,
1713        ) -> crate::widget::LayoutResponse {
1714            proposal.resolve(0.0, 0.0).into()
1715        }
1716        fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1717            builder.set_role(Role::GenericContainer);
1718            builder.set_hidden();
1719        }
1720    }
1721
1722    // Test 1
1723    #[test]
1724    fn access_label_replaces_widget_label() {
1725        let mut tree = WidgetTree::new();
1726        let id = tree.add(ClickableWidget.access_label_literal("Publish"));
1727        tree.layout(SizeProposal::exact(100.0, 40.0));
1728        assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
1729    }
1730
1731    // Test 2
1732    #[test]
1733    fn access_description_appears_on_bare_widget() {
1734        let mut tree = WidgetTree::new();
1735        let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
1736        tree.layout(SizeProposal::exact(50.0, 50.0));
1737        let update = tree.sync_accessibility();
1738        let node = find_node(&update, id).expect("node present");
1739        assert_eq!(node.description(), Some("Decorative"));
1740    }
1741
1742    // Test 3
1743    #[test]
1744    fn access_value_replaces_widget_value() {
1745        #[derive(Debug)]
1746        struct SliderWidget;
1747        impl Widget for SliderWidget {
1748            fn layout_response(
1749                &self,
1750                proposal: SizeProposal,
1751                _ctx: &LayoutContext,
1752            ) -> crate::widget::LayoutResponse {
1753                proposal.resolve(0.0, 0.0).into()
1754            }
1755            fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1756                builder.set_role(Role::Slider);
1757                builder.set_value("50");
1758            }
1759        }
1760        let mut tree = WidgetTree::new();
1761        let id = tree.add(SliderWidget.access_value_literal("Custom"));
1762        tree.layout(SizeProposal::exact(100.0, 40.0));
1763        assert_eq!(tree.text_value(id), Some("Custom".to_string()));
1764    }
1765
1766    // Test 4
1767    #[test]
1768    fn access_role_overrides_widget_role() {
1769        let mut tree = WidgetTree::new();
1770        let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
1771        tree.layout(SizeProposal::exact(100.0, 40.0));
1772        assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
1773    }
1774
1775    #[test]
1776    fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
1777        // The pass used to wire `described_by` only while the tooltip's
1778        // overlay was live. A plain tooltip is never auto-shown on focus, and
1779        // a dormant content node is excluded from the emitted tree (so it
1780        // cannot be a relation target anyway) — which left the whole plain
1781        // tier with no screen-reader path at all. With no hover, the anchor
1782        // must still carry the text as a static description.
1783        let mut tree = WidgetTree::new();
1784        let anchor = tree.add(FillWidget::new().label("Save"));
1785        let tip = tree.add(FillWidget::new().label("Save the file"));
1786        tree.layout(SizeProposal::exact(200.0, 100.0));
1787        tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
1788
1789        assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
1790        let update = tree.sync_accessibility();
1791        let node = find_node(&update, anchor).expect("anchor node present");
1792        assert_eq!(
1793            node.description(),
1794            Some("Save the file"),
1795            "the anchor must describe itself with its tooltip text while unshown"
1796        );
1797    }
1798
1799    #[test]
1800    fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
1801        // Once the content is genuinely in the tree, the richer relation is
1802        // used instead of the copied string.
1803        let mut tree = WidgetTree::new();
1804        let anchor = tree.add(FillWidget::new().label("Save"));
1805        let tip = tree.add(FillWidget::new().label("Save the file"));
1806        tree.layout(SizeProposal::exact(200.0, 100.0));
1807        tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
1808
1809        tree.pointer_move(tree.bounds(anchor).center());
1810        tree.advance_time(std::time::Duration::from_millis(150));
1811        assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
1812
1813        let update = tree.sync_accessibility();
1814        let node = find_node(&update, anchor).expect("anchor node present");
1815        assert!(
1816            !node.described_by().is_empty(),
1817            "a shown tooltip is wired as a described_by relation"
1818        );
1819    }
1820
1821    // Test 5
1822    #[test]
1823    fn access_hint_alias_writes_description_field() {
1824        let mut tree = WidgetTree::new();
1825        let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
1826        tree.layout(SizeProposal::exact(50.0, 50.0));
1827        let update = tree.sync_accessibility();
1828        let node = find_node(&update, id).unwrap();
1829        assert_eq!(node.description(), Some("Tip"));
1830    }
1831
1832    // Test 6
1833    #[test]
1834    fn access_identifier_writes_author_id() {
1835        let mut tree = WidgetTree::new();
1836        let id = tree.add(FillWidget::new().access_identifier("save-button"));
1837        tree.layout(SizeProposal::exact(50.0, 50.0));
1838        let update = tree.sync_accessibility();
1839        let node = find_node(&update, id).unwrap();
1840        assert_eq!(node.author_id(), Some("save-button"));
1841    }
1842
1843    // Test 7a — literal shortcut variant
1844    #[test]
1845    fn access_shortcut_literal_set() {
1846        let mut tree = WidgetTree::new();
1847        let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
1848        tree.layout(SizeProposal::exact(50.0, 50.0));
1849        let update = tree.sync_accessibility();
1850        let node = find_node(&update, id).unwrap();
1851        assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
1852    }
1853
1854    // Test 7b — id-based shortcut resolves through ShortcutRegistry,
1855    // including auto-refresh on rebind.
1856    #[test]
1857    fn access_shortcut_id_resolves_and_tracks_rebinds() {
1858        use crate::event::Key;
1859        use crate::shortcut::{KeyStroke, Shortcut};
1860
1861        let mut tree = WidgetTree::new();
1862        tree.shortcut_registry_mut().register(
1863            Shortcut::new("app.save")
1864                .name("Save")
1865                .primary(KeyStroke::command(Key::S))
1866                .build(),
1867        );
1868        let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
1869        tree.layout(SizeProposal::exact(50.0, 50.0));
1870
1871        // Initial registration: AT announces the default keystroke.
1872        let update = tree.sync_accessibility();
1873        let node = find_node(&update, id).unwrap();
1874        assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
1875
1876        // Simulate a user rebind: the AT announcement should track it.
1877        tree.shortcut_registry_mut()
1878            .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
1879        let update = tree.sync_accessibility();
1880        let node = find_node(&update, id).unwrap();
1881        assert_eq!(node.keyboard_shortcut(), Some("Ctrl+Q"));
1882    }
1883
1884    // Test 7c — silently omits the announcement when the id has no
1885    // registered default. Same fallback behavior as `MenuItem::for_shortcut`.
1886    #[test]
1887    fn access_shortcut_id_unknown_id_omits_announcement() {
1888        let mut tree = WidgetTree::new();
1889        let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
1890        tree.layout(SizeProposal::exact(50.0, 50.0));
1891        let update = tree.sync_accessibility();
1892        let node = find_node(&update, id).unwrap();
1893        assert_eq!(node.keyboard_shortcut(), None);
1894    }
1895
1896    // Test 8
1897    #[test]
1898    fn access_hidden_true_hides_widget() {
1899        let mut tree = WidgetTree::new();
1900        let id = tree.add(ClickableWidget.access_hidden(true));
1901        tree.layout(SizeProposal::exact(50.0, 50.0));
1902        assert!(tree.accessibility_node(id).is_hidden());
1903    }
1904
1905    // Test 9
1906    #[test]
1907    fn access_hidden_false_clears_widget_set_hidden() {
1908        let mut tree = WidgetTree::new();
1909        let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
1910        tree.layout(SizeProposal::exact(50.0, 50.0));
1911        assert!(
1912            !tree.accessibility_node(id).is_hidden(),
1913            "access_hidden(false) should clear widget-emitted hidden"
1914        );
1915    }
1916
1917    // Test 10
1918    #[test]
1919    fn access_disabled_true_marks_disabled() {
1920        let mut tree = WidgetTree::new();
1921        let id = tree.add(FillWidget::new().access_disabled(true));
1922        tree.layout(SizeProposal::exact(50.0, 50.0));
1923        assert!(tree.accessibility_node(id).is_disabled());
1924    }
1925
1926    // Test 11
1927    #[test]
1928    fn access_disabled_false_clears_arena_driven_disabled() {
1929        use crate::signal::Signal;
1930        let mut tree = WidgetTree::new();
1931        let id = tree.add(FillWidget::new().label("X").access_disabled(false));
1932        tree.enabled_when(id, Signal::new(false));
1933        tree.layout(SizeProposal::exact(50.0, 50.0));
1934        assert!(
1935            !tree.accessibility_node(id).is_disabled(),
1936            "access_disabled(false) should clear even arena-driven disabled"
1937        );
1938    }
1939
1940    // Test 12
1941    #[test]
1942    fn access_controls_appends() {
1943        let mut tree = WidgetTree::new();
1944        let target = tree.add(FillWidget::new().label("Target"));
1945        let controller = tree.add(
1946            FillWidget::new()
1947                .label("Controller")
1948                .access_controls(target),
1949        );
1950        tree.layout(SizeProposal::exact(100.0, 50.0));
1951        let update = tree.sync_accessibility();
1952        let node = find_node(&update, controller).unwrap();
1953        let target_nid = crate::accessibility::widget_id_to_node_id(target);
1954        assert!(
1955            node.controls().contains(&target_nid),
1956            "controls list should contain the target NodeId"
1957        );
1958    }
1959
1960    // Test 13
1961    #[test]
1962    fn access_described_by_appends() {
1963        let mut tree = WidgetTree::new();
1964        let other = tree.add(FillWidget::new().label("Desc"));
1965        let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
1966        tree.layout(SizeProposal::exact(100.0, 50.0));
1967        let update = tree.sync_accessibility();
1968        let node = find_node(&update, id).unwrap();
1969        let other_nid = crate::accessibility::widget_id_to_node_id(other);
1970        assert!(node.described_by().contains(&other_nid));
1971    }
1972
1973    // Test 14
1974    #[test]
1975    fn access_labelled_by_appends() {
1976        let mut tree = WidgetTree::new();
1977        let other = tree.add(FillWidget::new().label("Lbl"));
1978        let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
1979        tree.layout(SizeProposal::exact(100.0, 50.0));
1980        let update = tree.sync_accessibility();
1981        let node = find_node(&update, id).unwrap();
1982        let other_nid = crate::accessibility::widget_id_to_node_id(other);
1983        assert!(node.labelled_by().contains(&other_nid));
1984    }
1985
1986    // Test 15
1987    #[test]
1988    fn access_live_assertive_set() {
1989        let mut tree = WidgetTree::new();
1990        let id = tree.add(FillWidget::new().access_live(Live::Assertive));
1991        tree.layout(SizeProposal::exact(50.0, 50.0));
1992        let update = tree.sync_accessibility();
1993        let node = find_node(&update, id).unwrap();
1994        assert_eq!(node.live(), Some(Live::Assertive));
1995    }
1996
1997    // Test 16
1998    #[test]
1999    fn access_aria_current_set() {
2000        let mut tree = WidgetTree::new();
2001        let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
2002        tree.layout(SizeProposal::exact(50.0, 50.0));
2003        let update = tree.sync_accessibility();
2004        let node = find_node(&update, id).unwrap();
2005        assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
2006    }
2007
2008    // Test 17
2009    #[test]
2010    fn access_has_popup_set() {
2011        let mut tree = WidgetTree::new();
2012        let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
2013        tree.layout(SizeProposal::exact(50.0, 50.0));
2014        let update = tree.sync_accessibility();
2015        let node = find_node(&update, id).unwrap();
2016        assert_eq!(node.has_popup(), Some(HasPopup::Menu));
2017    }
2018
2019    // Test 18
2020    #[test]
2021    fn access_orientation_set() {
2022        let mut tree = WidgetTree::new();
2023        let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
2024        tree.layout(SizeProposal::exact(50.0, 50.0));
2025        let update = tree.sync_accessibility();
2026        let node = find_node(&update, id).unwrap();
2027        assert_eq!(node.orientation(), Some(Orientation::Vertical));
2028    }
2029
2030    // Test 19
2031    #[test]
2032    fn access_numeric_value_and_range() {
2033        let mut tree = WidgetTree::new();
2034        let id = tree.add(
2035            FillWidget::new()
2036                .access_role(Role::Slider)
2037                .access_numeric_value(50.0)
2038                .access_numeric_range(0.0, 100.0)
2039                .access_numeric_step(5.0),
2040        );
2041        tree.layout(SizeProposal::exact(50.0, 50.0));
2042        let update = tree.sync_accessibility();
2043        let node = find_node(&update, id).unwrap();
2044        assert_eq!(node.numeric_value(), Some(50.0));
2045        assert_eq!(node.min_numeric_value(), Some(0.0));
2046        assert_eq!(node.max_numeric_value(), Some(100.0));
2047        assert_eq!(node.numeric_value_step(), Some(5.0));
2048    }
2049
2050    // Test 20
2051    #[test]
2052    fn access_action_advertises_and_routes() {
2053        use crate::signal::Signal;
2054        let flag = Signal::new(false);
2055        let flag_for_cb = flag.clone();
2056        let mut tree = WidgetTree::new();
2057        let id = tree.add(
2058            FillWidget::new()
2059                .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
2060        );
2061        tree.layout(SizeProposal::exact(50.0, 50.0));
2062        let info = tree.accessibility_node(id);
2063        assert!(info.actions().contains(&Action::ShowContextMenu));
2064        tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2065            action: Action::ShowContextMenu,
2066            target: Some(id),
2067            target_node: crate::accessibility::widget_id_to_node_id(id),
2068            data: None,
2069        });
2070        assert!(flag.get(), "callback should have been invoked");
2071    }
2072
2073    // Test 21
2074    #[test]
2075    fn access_two_actions_both_route() {
2076        use crate::signal::Signal;
2077        let click = Signal::new(false);
2078        let increment = Signal::new(false);
2079        let click_cb = click.clone();
2080        let inc_cb = increment.clone();
2081        let mut tree = WidgetTree::new();
2082        let id = tree.add(
2083            FillWidget::new()
2084                .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
2085                .access_action(Action::Increment, move |_| inc_cb.set(true)),
2086        );
2087        tree.layout(SizeProposal::exact(50.0, 50.0));
2088        tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2089            action: Action::ShowContextMenu,
2090            target: Some(id),
2091            target_node: crate::accessibility::widget_id_to_node_id(id),
2092            data: None,
2093        });
2094        assert!(click.get() && !increment.get(), "only first action fired");
2095        tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2096            action: Action::Increment,
2097            target: Some(id),
2098            target_node: crate::accessibility::widget_id_to_node_id(id),
2099            data: None,
2100        });
2101        assert!(
2102            click.get() && increment.get(),
2103            "both actions fired exactly once each"
2104        );
2105    }
2106
2107    // Test 22
2108    #[test]
2109    fn access_remove_action_suppresses_widget_action() {
2110        let mut tree = WidgetTree::new();
2111        let id = tree.add(ActionWidget.access_remove_action(Action::Click));
2112        tree.layout(SizeProposal::exact(50.0, 50.0));
2113        let info = tree.accessibility_node(id);
2114        assert!(!info.actions().contains(&Action::Click));
2115        assert!(info.actions().contains(&Action::Focus));
2116    }
2117
2118    // Test 23
2119    #[test]
2120    fn access_custom_action_uses_localized_label() {
2121        let mut tree = WidgetTree::new();
2122        let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
2123        tree.layout(SizeProposal::exact(50.0, 50.0));
2124        let update = tree.sync_accessibility();
2125        let node = find_node(&update, id).unwrap();
2126        let actions = node.custom_actions();
2127        assert_eq!(actions.len(), 1);
2128        assert_eq!(actions[0].id, 0);
2129        assert_eq!(actions[0].description.as_str(), "Reply");
2130    }
2131
2132    // Test 24
2133    #[test]
2134    fn access_custom_action_routes_by_index() {
2135        use crate::signal::Signal;
2136        let first = Signal::new(false);
2137        let second = Signal::new(false);
2138        let f = first.clone();
2139        let s = second.clone();
2140        let mut tree = WidgetTree::new();
2141        let id = tree.add(
2142            FillWidget::new()
2143                .access_custom_action_literal("First", move |_| f.set(true))
2144                .access_custom_action_literal("Second", move |_| s.set(true)),
2145        );
2146        tree.layout(SizeProposal::exact(50.0, 50.0));
2147        tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2148            action: Action::CustomAction,
2149            target: Some(id),
2150            target_node: crate::accessibility::widget_id_to_node_id(id),
2151            data: Some(accesskit::ActionData::CustomAction(1)),
2152        });
2153        assert!(!first.get(), "first should not fire");
2154        assert!(second.get(), "second should fire (index 1)");
2155    }
2156
2157    // Test 25
2158    #[test]
2159    fn access_action_layered_with_on_access_action() {
2160        use crate::signal::Signal;
2161        let from_override = Signal::new(false);
2162        let from_user = Signal::new(false);
2163        let ov_cb = from_override.clone();
2164        let user_cb = from_user.clone();
2165        let mut tree = WidgetTree::new();
2166        let id = tree.add(
2167            FillWidget::new()
2168                .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
2169                .on_access_action(move |_action, _ctx| {
2170                    user_cb.set(true);
2171                    crate::event::EventResponse::Handled
2172                }),
2173        );
2174        tree.layout(SizeProposal::exact(50.0, 50.0));
2175        tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2176            action: Action::ShowContextMenu,
2177            target: Some(id),
2178            target_node: crate::accessibility::widget_id_to_node_id(id),
2179            data: None,
2180        });
2181        assert!(from_override.get(), "override callback fired");
2182        assert!(from_user.get(), "user catch-all fired");
2183    }
2184
2185    // Test 26 — i18n integration via teksilo_i18n's
2186    // `From<LocalizedString> for Prop<String>` impl. We don't import
2187    // teksilo-i18n here (it depends on teksilo-core), but the conversion
2188    // works the same way for any `Into<Prop<String>>`. This stand-in
2189    // covers the same code path the FTL-bundle case takes.
2190    #[test]
2191    fn access_label_accepts_resolved_string_via_into() {
2192        // Simulate a `LocalizedString`-like wrapper: any type that
2193        // `impl Into<Prop<String>>`. The override surface stores the
2194        // prop and the walker reads its current value.
2195        struct ResolvedAtCall(String);
2196        impl From<ResolvedAtCall> for crate::signal::Prop<String> {
2197            fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
2198                crate::signal::Prop::Static(v.0)
2199            }
2200        }
2201        let mut tree = WidgetTree::new();
2202        let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
2203        tree.layout(SizeProposal::exact(50.0, 50.0));
2204        assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
2205    }
2206
2207    // Test 27
2208    #[test]
2209    fn access_exclude_subtree_prunes_children_from_at_tree() {
2210        let mut tree = WidgetTree::new();
2211        let inner1 = tree.add(FillWidget::new().label("A"));
2212        let inner2 = tree.add(FillWidget::new().label("B"));
2213        let outer = tree.add(
2214            StackWidget::new()
2215                .add_child(inner1)
2216                .add_child(inner2)
2217                // A label keeps `outer` from being collapsed as a
2218                // presentational container, so the test exercises Exclude
2219                // (not the new presentational-pruning pass).
2220                .access_label_literal("Section")
2221                .access_exclude_subtree(),
2222        );
2223        tree.layout(SizeProposal::exact(100.0, 50.0));
2224        let update = tree.sync_accessibility();
2225        // Outer is present; inner1 and inner2 are pruned.
2226        assert!(find_node(&update, outer).is_some());
2227        assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
2228        assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
2229        let outer_node = find_node(&update, outer).unwrap();
2230        assert!(
2231            outer_node.children().is_empty(),
2232            "outer should have no AT children when excluded"
2233        );
2234    }
2235
2236    // Test 28
2237    #[test]
2238    fn access_merge_subtree_concatenates_descendant_labels() {
2239        let mut tree = WidgetTree::new();
2240        let title = tree.add(FillWidget::new().label("Title"));
2241        let subtitle = tree.add(FillWidget::new().label("Subtitle"));
2242        let card = tree.add(
2243            StackWidget::new()
2244                .add_child(title)
2245                .add_child(subtitle)
2246                .access_merge_subtree(),
2247        );
2248        tree.layout(SizeProposal::exact(100.0, 50.0));
2249        // After merge: card's name is "Title Subtitle", children pruned.
2250        assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
2251        let update = tree.sync_accessibility();
2252        assert!(find_node(&update, title).is_none());
2253        assert!(find_node(&update, subtitle).is_none());
2254    }
2255
2256    // Test 29
2257    #[test]
2258    fn access_merge_subtree_unions_actions() {
2259        let mut tree = WidgetTree::new();
2260        let click_a = tree.add(ClickableWidget);
2261        let click_b = tree.add(ClickableWidget);
2262        let card = tree.add(
2263            StackWidget::new()
2264                .add_child(click_a)
2265                .add_child(click_b)
2266                .access_merge_subtree(),
2267        );
2268        tree.layout(SizeProposal::exact(100.0, 50.0));
2269        let actions = tree.accessibility_node(card).actions().to_vec();
2270        let click_count = actions.iter().filter(|a| **a == Action::Click).count();
2271        assert_eq!(
2272            click_count, 1,
2273            "Click should be present exactly once after merge (deduplicated)"
2274        );
2275    }
2276
2277    // Test 30 — covered by the i18n integration mechanism documented in
2278    // `From<LocalizedString> for String`. Since teksilo-core can't reference
2279    // LocalizedString, the merged-localized-label case is exercised by
2280    // tests 26 + 28 in combination: each child's resolved-at-call-time
2281    // String contributes to the merged label. The full FTL-bundle
2282    // round-trip is tested in the teksilo-i18n / teksilo-widgets integration
2283    // tests, not here.
2284
2285    // Test 31
2286    #[test]
2287    fn access_merge_subtree_first_nonempty_value_wins() {
2288        #[derive(Debug)]
2289        struct ValueWidget(&'static str);
2290        impl Widget for ValueWidget {
2291            fn layout_response(
2292                &self,
2293                proposal: SizeProposal,
2294                _ctx: &LayoutContext,
2295            ) -> crate::widget::LayoutResponse {
2296                proposal.resolve(0.0, 0.0).into()
2297            }
2298            fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2299                builder.set_role(Role::Slider);
2300                builder.set_value(self.0);
2301            }
2302        }
2303        let mut tree = WidgetTree::new();
2304        let v1 = tree.add(ValueWidget("first"));
2305        let v2 = tree.add(ValueWidget("second"));
2306        let card = tree.add(
2307            StackWidget::new()
2308                .add_child(v1)
2309                .add_child(v2)
2310                .access_merge_subtree(),
2311        );
2312        tree.layout(SizeProposal::exact(100.0, 50.0));
2313        assert_eq!(tree.text_value(card), Some("first".to_string()));
2314    }
2315
2316    // Test 32
2317    #[test]
2318    fn access_exclude_inside_merge() {
2319        let mut tree = WidgetTree::new();
2320        let visible = tree.add(FillWidget::new().label("VISIBLE"));
2321        let pruned = tree.add(FillWidget::new().label("PRUNED"));
2322        let inner_excluded = tree.add(
2323            StackWidget::new()
2324                .add_child(pruned)
2325                .access_exclude_subtree(),
2326        );
2327        let card = tree.add(
2328            StackWidget::new()
2329                .add_child(visible)
2330                .add_child(inner_excluded)
2331                .access_merge_subtree(),
2332        );
2333        tree.layout(SizeProposal::exact(100.0, 50.0));
2334        let merged = tree.text_content(card).unwrap_or_default();
2335        // VISIBLE present, PRUNED absent (because inner_excluded
2336        // pruned its own subtree before the merge could absorb it).
2337        assert!(merged.contains("VISIBLE"));
2338        assert!(
2339            !merged.contains("PRUNED"),
2340            "excluded subtree should not contribute to merge"
2341        );
2342    }
2343
2344    // Test 33
2345    #[test]
2346    fn access_merge_inside_merge() {
2347        let mut tree = WidgetTree::new();
2348        let inner_a = tree.add(FillWidget::new().label("a"));
2349        let inner_b = tree.add(FillWidget::new().label("b"));
2350        let inner_card = tree.add(
2351            StackWidget::new()
2352                .add_child(inner_a)
2353                .add_child(inner_b)
2354                .access_merge_subtree(),
2355        );
2356        let outer_extra = tree.add(FillWidget::new().label("X"));
2357        let outer = tree.add(
2358            StackWidget::new()
2359                .add_child(inner_card)
2360                .add_child(outer_extra)
2361                .access_merge_subtree(),
2362        );
2363        tree.layout(SizeProposal::exact(200.0, 100.0));
2364        // Outer absorbs inner_card's already-merged label ("a b") AND
2365        // outer_extra ("X"), giving something like "a b X" or "X a b".
2366        // Order is descendant-walk order; we just verify all parts
2367        // appear and inner children are NOT double-counted.
2368        let merged = tree.text_content(outer).unwrap_or_default();
2369        assert!(merged.contains("a"));
2370        assert!(merged.contains("b"));
2371        assert!(merged.contains("X"));
2372        // Inner children should not appear AS THEIR OWN nodes:
2373        let update = tree.sync_accessibility();
2374        assert!(find_node(&update, inner_a).is_none());
2375        assert!(find_node(&update, inner_b).is_none());
2376        assert!(find_node(&update, inner_card).is_none());
2377        assert!(find_node(&update, outer_extra).is_none());
2378    }
2379
2380    // Test 34
2381    #[test]
2382    fn access_customize_runs_last() {
2383        let mut tree = WidgetTree::new();
2384        let id = tree.add(
2385            FillWidget::new()
2386                .access_label_literal("A")
2387                .access_customize(|b| b.set_name("B")),
2388        );
2389        tree.layout(SizeProposal::exact(50.0, 50.0));
2390        assert_eq!(tree.accessibility_node(id).name(), Some("B"));
2391    }
2392
2393    // Test 35
2394    #[test]
2395    fn access_customize_can_reach_inner_mut() {
2396        let mut tree = WidgetTree::new();
2397        let id = tree.add(FillWidget::new().access_customize(|b| {
2398            b.inner_mut().set_author_id("from-customize");
2399        }));
2400        tree.layout(SizeProposal::exact(50.0, 50.0));
2401        let update = tree.sync_accessibility();
2402        let node = find_node(&update, id).unwrap();
2403        assert_eq!(node.author_id(), Some("from-customize"));
2404    }
2405
2406    // Test 36 — sanity guard. WidgetNode size delta when no overrides.
2407    #[test]
2408    fn access_overrides_zero_cost_when_unused() {
2409        // The override fields add only `Option<Box<...>>` (8 bytes for
2410        // the pointer-sized null) + `AccessSubtreeMode` (1 byte enum,
2411        // padded). Sanity: at most 16 bytes added.
2412        // We don't assert a specific size because struct layout shifts
2413        // with rustc versions; we just confirm both fields are
2414        // pointer/byte sized.
2415        use std::mem::size_of;
2416        assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
2417        assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
2418    }
2419
2420    #[test]
2421    fn accessibility_children_overrides_at_reading_order() {
2422        // Audit G17: a widget can present a different child ORDER to assistive
2423        // tech than its layout/paint child order via `accessibility_children()`
2424        // — the mechanism TableView/TreeTableView use to read the header before
2425        // the body even though they build the body first (z-order).
2426        #[derive(Debug)]
2427        struct NamedLeaf(&'static str);
2428        impl Widget for NamedLeaf {
2429            fn layout_response(
2430                &self,
2431                proposal: SizeProposal,
2432                _ctx: &LayoutContext,
2433            ) -> crate::widget::LayoutResponse {
2434                proposal.resolve(10.0, 10.0).into()
2435            }
2436            fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2437                // Button keeps `set_name` as the node's label (Role::Label
2438                // routes text to `value` instead), so the test can find the
2439                // leaves by name.
2440                builder.set_role(accesskit::Role::Button);
2441                builder.set_name(self.0);
2442            }
2443        }
2444
2445        #[derive(Debug, Default)]
2446        struct ReorderContainer {
2447            a: Option<WidgetId>,
2448            b: Option<WidgetId>,
2449        }
2450        impl Widget for ReorderContainer {
2451            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2452                let a = ctx.add(NamedLeaf("A"));
2453                let b = ctx.add(NamedLeaf("B"));
2454                self.a = Some(a);
2455                self.b = Some(b);
2456                vec![a, b]
2457            }
2458            fn layout_response(
2459                &self,
2460                proposal: SizeProposal,
2461                _ctx: &LayoutContext,
2462            ) -> crate::widget::LayoutResponse {
2463                proposal.resolve(100.0, 100.0).into()
2464            }
2465            fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2466                // A real role so the container itself isn't dropped as a
2467                // presentational node — we need to inspect its child order.
2468                builder.set_role(accesskit::Role::Group);
2469                builder.set_name("Container");
2470            }
2471            fn children(&self) -> Vec<WidgetId> {
2472                [self.a, self.b].into_iter().flatten().collect()
2473            }
2474            fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2475                // Reverse of build/layout order.
2476                Some([self.b, self.a].into_iter().flatten().collect())
2477            }
2478        }
2479
2480        let mut tree = WidgetTree::new();
2481        let container = tree.add(ReorderContainer::default());
2482        tree.layout(SizeProposal::exact(100.0, 100.0));
2483        let update = tree.sync_accessibility();
2484
2485        let a_nid = update
2486            .nodes
2487            .iter()
2488            .find(|(_, n)| n.label() == Some("A"))
2489            .map(|(id, _)| *id)
2490            .expect("A node present");
2491        let b_nid = update
2492            .nodes
2493            .iter()
2494            .find(|(_, n)| n.label() == Some("B"))
2495            .map(|(id, _)| *id)
2496            .expect("B node present");
2497        let cnid = crate::accessibility::widget_id_to_node_id(container);
2498        let cnode = update
2499            .nodes
2500            .iter()
2501            .find(|(id, _)| *id == cnid)
2502            .map(|(_, n)| n)
2503            .expect("container node present");
2504        let kids = cnode.children();
2505        let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
2506        let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
2507        assert!(
2508            pb < pa,
2509            "accessibility_children() must set AT order B-before-A; got {kids:?}"
2510        );
2511    }
2512}