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