Skip to main content

teksilo_core/widget_tree/
accessibility_impl.rs

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