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