Skip to main content

teksilo_core/widget_tree/
accessibility_impl.rs

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