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