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