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