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