Skip to main content

teksilo_core/
accessibility.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use accesskit::{Action, Live, Node, NodeId, Role, TextDirection, TextPosition, TextSelection};
5
6use crate::widget_id::WidgetId;
7
8pub mod audit;
9pub mod target_audit;
10pub mod text_runs;
11
12/// Builder wrapper around accesskit::Node for widget accessibility declarations.
13pub struct AccessNodeBuilder {
14    inner: Node,
15    name: Option<String>,
16    value: Option<String>,
17    role: Role,
18    actions: Vec<Action>,
19    toggled: Option<bool>,
20    expanded: Option<bool>,
21    selected: Option<bool>,
22    hidden: bool,
23    /// The owning widget's id. Set at construction time by the
24    /// tree walker (via `AccessNodeBuilder::for_widget`). Used by
25    /// the sub-tree API (`push_paragraph_child` / `push_text_run_child`)
26    /// to derive synthetic NodeIds without asking the caller to
27    /// pass the WidgetId at every call site.
28    owner: Option<WidgetId>,
29    /// Pending text selection targeting the widget's own node id.
30    /// Resolved at `build(id)` time because the widget doesn't know
31    /// its node id during `accessibility(&self, builder)`.
32    pending_self_selection: Option<(usize, usize)>,
33    /// Deferred text selection targeting synthetic child NodeIds
34    /// (TextRuns). Unlike `pending_self_selection`, these
35    /// TextPositions reference NodeIds that are already known at
36    /// the time the widget calls `set_text_selection_to`, so we
37    /// can populate the selection during `build(id)` directly —
38    /// the field just holds them until that point.
39    pending_explicit_selection: Option<(TextPosition, TextPosition)>,
40    /// Synthetic child nodes emitted by the widget via
41    /// `push_paragraph_child` / `push_text_run_child`. Drained by
42    /// the tree walker after `Widget::accessibility(&self, builder)`
43    /// returns and merged into the full AccessKit `TreeUpdate`.
44    children_collected: Vec<(NodeId, Node)>,
45    /// Bounds a widget declared for its synthetic children in **its own**
46    /// coordinate space, translated into window space by [`build`] —
47    /// which is the first moment the owner's absolute rect is known,
48    /// because the walker writes it onto the node just before calling.
49    ///
50    /// A widget that already holds absolute rects (a scene item, a
51    /// composite emitting geometry it got from a child it placed) calls
52    /// `set_bounds` on the child node directly and never appears here.
53    ///
54    /// [`build`]: AccessNodeBuilder::build
55    child_local_bounds: Vec<(NodeId, teksilo_canvas::Rect)>,
56    /// This builder exists only to harvest an accessible *name* — the
57    /// merge pass and the tooltip-description probe both run a widget's
58    /// `accessibility()` on a throwaway builder and read nothing but the
59    /// name off it. Emitting synthetic children there would allocate
60    /// node ids that never reach the tree, and, worse, would be
61    /// discarded along with the builder while the widget's real node
62    /// keeps its own copies.
63    name_probe: bool,
64}
65
66/// The ids of every `Role::TextRun` this node attached to **itself**, or
67/// `None` when there are none.
68///
69/// Scoped to the node's own child list on purpose. A builder's collected
70/// vector also holds *grandchildren* — a scene item is emitted through a
71/// nested builder whose whole subtree is folded into the outer one — and
72/// those belong to a parent of their own, which may well be a
73/// `Role::Label` that does support ranges. Dropping them because the
74/// *outer* node cannot carry runs would delete nodes the item's own
75/// children list still names, and `accesskit_consumer` panics on a
76/// child id that reaches no node.
77fn own_run_ids(
78    inner: &Node,
79    children: &[(NodeId, Node)],
80) -> Option<std::collections::HashSet<NodeId>> {
81    let attached: std::collections::HashSet<NodeId> = inner.children().iter().copied().collect();
82    let ids: std::collections::HashSet<NodeId> = children
83        .iter()
84        .filter(|(id, node)| node.role() == Role::TextRun && attached.contains(id))
85        .map(|(id, _)| *id)
86        .collect();
87    (!ids.is_empty()).then_some(ids)
88}
89
90/// Everything one `Role::TextRun` node needs.
91#[derive(Debug, Clone)]
92pub struct TextRunSpec {
93    /// Mixed with the owner and the run kind to derive the node id.
94    pub element_id: u64,
95    pub value: String,
96    /// UTF-8 byte length of each character of `value`. Their sum must be
97    /// `value.len()`; AccessKit panics otherwise.
98    pub character_lengths: Vec<u8>,
99    pub word_starts: Vec<u8>,
100    pub character_positions: Vec<f32>,
101    pub character_widths: Vec<f32>,
102    pub bounds: teksilo_canvas::Rect,
103    /// `true` when `bounds` is already in window space.
104    pub bounds_are_absolute: bool,
105    pub text_direction: TextDirection,
106    pub attrs: TextRunAttributes,
107}
108
109/// A Teksilo affine as AccessKit expresses one.
110///
111/// Both store a 3×2 matrix mapping `(x, y)` to
112/// `(a·x + c·y + tx, b·x + d·y + ty)`, and both spell it `[a, b, c, d, tx, ty]`,
113/// so this is a widening cast and not a change of convention.
114///
115/// Write the result with [`accesskit::Node::set_transform`] on a node whose
116/// own bounds — and every rectangle below it — are stated in a coordinate
117/// space of its own rather than in window space. That is the one supported way
118/// to publish such a rectangle: a consumer reads `bounds` "in the coordinate
119/// space of the nearest ancestor with a non-`None` transform", composes the
120/// chain for `bounding_box()`, and inverts it per step when hit-testing, so
121/// projecting by hand instead would be exact only for the rectangle and wrong
122/// for the per-character geometry and the hit path.
123///
124/// The framework does this for the children of a content-transform node (see
125/// `WidgetTree::build_accessibility_recursive`); a widget emitting synthetic
126/// children of its own in a non-window space — a scene item, a chart mark —
127/// calls it directly.
128pub fn to_accesskit_affine(t: teksilo_canvas::Transform2D) -> accesskit::Affine {
129    let [a, b, c, d, tx, ty] = t.m;
130    accesskit::Affine::new([a as f64, b as f64, c as f64, d as f64, tx as f64, ty as f64])
131}
132
133/// The text a node announces, read the way every platform adapter reads it.
134///
135/// A `Role::Label` carries its text in `value`, not `label` — Windows UIA
136/// derives the Name from `value` for that role, macOS exposes it as
137/// `AXValue`, and `accesskit_consumer` reads `value` when another control is
138/// `labelled_by` it. `AccessNodeBuilder::build` moves it there. Everything
139/// else carries its name in `label`. A test or probe that reads one property
140/// sees nothing on half the tree.
141pub fn announced_text(node: &Node) -> Option<&str> {
142    if node.role() == Role::Label {
143        node.value().or_else(|| node.label())
144    } else {
145        node.label().or_else(|| node.value())
146    }
147}
148
149/// Whether a node with this role can carry text ranges at all.
150///
151/// `accesskit_consumer::Node::supports_text_ranges` is
152/// `(is_text_input || role ∈ {Label, Document, Terminal}) && has runs`
153/// (`accesskit_consumer-0.39.0/src/text.rs:1402`). Runs under any other
154/// role are inert: no platform exposes them, and they still cost a node
155/// in every update.
156pub fn role_supports_text_ranges(role: Role) -> bool {
157    matches!(
158        role,
159        Role::Label
160            | Role::Document
161            | Role::Terminal
162            | Role::TextInput
163            | Role::MultilineTextInput
164            | Role::SearchInput
165            | Role::DateInput
166            | Role::DateTimeInput
167            | Role::WeekInput
168            | Role::MonthInput
169            | Role::TimeInput
170            | Role::EmailInput
171            | Role::NumberInput
172            | Role::PasswordInput
173            | Role::PhoneNumberInput
174            | Role::UrlInput
175            | Role::EditableComboBox
176            | Role::SpinButton
177    )
178}
179
180/// Discriminator kind for synthetic-NodeId hashing. Different
181/// kinds sharing the same (widget_id, element_id) tuple produce
182/// distinct NodeIds so paragraph and run nodes for the same
183/// source element don't collide.
184#[derive(Debug, Clone, Copy)]
185#[repr(u8)]
186pub enum SyntheticKind {
187    Paragraph = 1,
188    TextRun = 2,
189    ImageRun = 3,
190    /// An inline link span inside a label-style widget's text
191    /// (e.g. `[docs](url)` inside a TextWidget with `.markup(true)`
192    /// enabled). Each link span becomes one synthetic child of the
193    /// hosting widget's own node, with `Role::Link` and the link label
194    /// as its name.
195    Link = 4,
196    /// A lightweight `SceneItem` rendered by `teksilo_scene::SceneView`.
197    /// Items live outside the arena — `SceneView::accessibility`
198    /// emits one synthetic child per visible item using
199    /// `push_scene_child`.
200    SceneItem = 5,
201    /// A logical grouping declared via `Scene::add_a11y_group`.
202    /// Pure AT structure — no visual counterpart. The parent's
203    /// children list orders mixed `SceneItem` and `SceneGroup`
204    /// synthetic NodeIds however the app declared the logical tree.
205    SceneGroup = 6,
206    /// A magnetism anchor ("magnet") attached to a scene item. Emitted
207    /// as a synthetic child of the owning item's node by
208    /// `SceneView::accessibility` when magnetism is enabled, so the
209    /// anchor is screen-reader perceivable and can be the target of the
210    /// view's `active_descendant` during the keyboard connect flow.
211    SceneMagnet = 7,
212    /// A per-datum mark (bar / line point / pie slice) emitted by a
213    /// `teksilo-charts` widget's `accessibility()`.
214    ChartMark = 8,
215    /// An annotation body (a comment thread) attached to a run of text, emitted
216    /// by a rich-text widget alongside the `TextRun` that carries it. The run
217    /// points at this node through the `details` relation — AccessKit's
218    /// `aria-details` — which is what lets a screen reader say "has comment" and
219    /// let the user navigate in, rather than reciting the thread inline every
220    /// time the caret crosses the span.
221    Annotation = 9,
222    /// A positional mark in a margin lane — the strip beside a scroll area that
223    /// maps where things are in a document rather than picturing it.
224    ///
225    /// Emitted by the lane widget's `accessibility()`, one synthetic child per
226    /// mark, the same shape [`SyntheticKind::ChartMark`] uses per datum. Marks
227    /// get their own nodes, rather than riding the content they refer to, for
228    /// the reason `CodeGutter` gives for *not* doing so: line numbers are
229    /// uniform, dense and derivable from the text, so they belong on the
230    /// paragraph node as `position_in_set`; lane marks are sparse, heterogeneous
231    /// and not derivable from anything — "a comment from Marie at 62 percent" is
232    /// not in the paragraph, so there is no node to delegate to.
233    LaneMark = 10,
234    /// The selection transform frame and its handles, emitted by
235    /// `teksilo_scene::SceneView` when a transform controller is installed.
236    ///
237    /// One frame node per *view* plus at most nine handle nodes hang off it —
238    /// not one per item — because the frame belongs to the selection rather
239    /// than to any item, and a multi-item selection has no owner. The handles
240    /// are a paint pass, never scene items, so they add nothing to the item
241    /// walk, to `items_in_rect`, or to a marquee's result.
242    SceneHandle = 11,
243}
244
245/// A captured live-region announcement — the text a screen reader would
246/// have spoken when a `Live::{Polite,Assertive}` node's value (or label)
247/// changed.
248///
249/// Teksilo has no OS accessibility layer in headless mode, and even with
250/// one there is no in-process way to observe what the platform *spoke*.
251/// [`crate::WidgetTree::sync_accessibility`] therefore diffs the live
252/// nodes of each freshly-built `TreeUpdate` and records the changes into
253/// a ring buffer that an automation / test harness drains via
254/// [`crate::WidgetTree::announcements_since`]. This is a faithful,
255/// in-process model of the live-region stream, not a replacement for an
256/// OS screen-reader smoke test.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct Announcement {
259    /// Monotonic sequence number, starting at 1. `announcements_since(n)`
260    /// returns every announcement whose `seq > n`.
261    pub seq: u64,
262    /// The announced text — the node's `value`, or its `label` when the
263    /// node carries no value.
264    pub text: String,
265    /// `true` for `Live::Assertive`, `false` for `Live::Polite`.
266    pub assertive: bool,
267}
268
269/// Top bit of the u64 NodeId encoding. Set for synthetic (widget-
270/// emitted child) NodeIds, clear for widget-derived NodeIds.
271/// Slotmap-derived WidgetIds never set bit 63 in practice because
272/// slotmap's KeyData encoding occupies bits 32-63 with a version
273/// counter that starts at 1.
274pub(crate) const SYNTHETIC_BIT: u64 = 1u64 << 63;
275
276/// Stable hash of (parent widget, element id, kind) producing a
277/// synthetic NodeId that survives edits for as long as the
278/// underlying element id is stable. Used by `AccessNodeBuilder`'s
279/// sub-tree API to allocate NodeIds for paragraph / text-run
280/// children without colliding with widget-derived NodeIds.
281pub fn synthetic_node_id(parent: WidgetId, element_id: u64, kind: SyntheticKind) -> NodeId {
282    use slotmap::Key;
283    let parent_raw = parent.data().as_ffi();
284    let h = fnv_mix_u64(parent_raw, element_id, kind as u64);
285    NodeId((h & !SYNTHETIC_BIT) | SYNTHETIC_BIT)
286}
287
288/// Convert an ARIA-style **1-based** ordinal to the **zero-based** integer
289/// AccessKit stores.
290///
291/// AccessKit deliberately departs from ARIA on four properties, and says so in
292/// its own documentation (`accesskit-0.25.0/src/lib.rs`, each carrying a
293/// "**Difference with ARIA**" paragraph):
294///
295/// | property | ARIA | AccessKit |
296/// |---|---|---|
297/// | `position_in_set` / `aria-posinset` | 1-based | **0-based** |
298/// | `row_index` / `aria-rowindex` | 1-based | **0-based** |
299/// | `column_index` / `aria-colindex` | 1-based | **0-based** |
300/// | `level` / `aria-level` | 1-based | **0-based** |
301///
302/// Two of the three platform adapters add the 1 back before handing the value
303/// to the platform — `accesskit_windows-0.35.0/src/node.rs:682-687` and
304/// `:698-701`, `accesskit_atspi_common-0.20.0/src/node.rs:394` — so writing an
305/// ARIA-shaped number straight through lands one too high in what the screen
306/// reader actually says. `accesskit_macos-0.27.0` reads none of the four, so
307/// the error is invisible there, which is part of why it went unnoticed.
308///
309/// Teksilo's public surface stays 1-based, because that is the convention every
310/// call site, every doc comment and ARIA itself already use, and because "the
311/// first tab is tab 1" is what the value means to a person. The conversion
312/// happens once, here, at the boundary.
313///
314/// Saturating rather than panicking on 0: an off-by-one in a caller should
315/// produce a slightly wrong announcement, not take down the application. A
316/// caller that passes 0 gets the same node it would have got for 1.
317fn to_accesskit_ordinal(one_based: usize) -> usize {
318    debug_assert!(
319        one_based >= 1,
320        "AccessKit ordinals are 1-based at this boundary; 0 is not a position, \
321         row, column or level"
322    );
323    one_based.saturating_sub(1)
324}
325
326/// Whether a given `NodeId` is a synthetic child node (emitted by a
327/// widget via `push_paragraph_child` / `push_text_run_child`) rather
328/// than a widget-derived NodeId.
329pub fn is_synthetic(id: NodeId) -> bool {
330    id.0 & SYNTHETIC_BIT != 0
331}
332
333/// FNV-1a-inspired 64-bit mixer for three u64 inputs. Not a
334/// cryptographic hash — just a fast, deterministic, well-distributed
335/// mix for collision-free synthetic NodeIds across the
336/// (widget, element, kind) space.
337pub(crate) fn fnv_mix_u64(a: u64, b: u64, c: u64) -> u64 {
338    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
339    const FNV_PRIME: u64 = 0x100000001b3;
340    let mut h = FNV_OFFSET;
341    for byte in a
342        .to_le_bytes()
343        .iter()
344        .chain(b.to_le_bytes().iter())
345        .chain(c.to_le_bytes().iter())
346    {
347        h ^= *byte as u64;
348        h = h.wrapping_mul(FNV_PRIME);
349    }
350    h
351}
352
353/// Styling attributes surfaced to assistive technology on a synthetic
354/// `Role::TextRun` node (WCAG 1.3.1 Info and Relationships / EN 301 549
355/// 11.5.2.9 "text attributes"). All fields default to "unset"; a rich-text
356/// widget populates them per formatting run so a screen reader can convey
357/// bold / italic / underline / strikethrough spans. AccessKit has no
358/// dedicated `bold` property, so [`bold`](Self::bold) folds into
359/// `set_font_weight(700)` when no explicit [`font_weight`](Self::font_weight)
360/// is given.
361#[derive(Debug, Clone, Copy, Default)]
362pub struct TextRunAttributes {
363    /// Explicit numeric font weight (`100..=900`). Takes precedence over
364    /// [`bold`](Self::bold).
365    pub font_weight: Option<u16>,
366    /// Bold flag; folded to weight `700` when `font_weight` is `None`.
367    pub bold: bool,
368    pub italic: bool,
369    pub underline: bool,
370    pub strikethrough: bool,
371}
372
373/// Apply a run's formatting to its node.
374///
375/// WCAG 1.3.1 / EN 301 549 11.5.2.9. AccessKit has no bold flag, so an
376/// explicit weight wins and a bare `bold` folds to 700.
377fn apply_run_attrs(node: &mut Node, attrs: TextRunAttributes) {
378    if let Some(weight) = attrs.font_weight {
379        node.set_font_weight(weight as f32);
380    } else if attrs.bold {
381        node.set_font_weight(700.0);
382    }
383    if attrs.italic {
384        node.set_italic();
385    }
386    if attrs.underline {
387        node.set_underline(default_text_decoration());
388    }
389    if attrs.strikethrough {
390        node.set_strikethrough(default_text_decoration());
391    }
392}
393
394/// The `TextDecoration` used for underline / strikethrough on a text run.
395/// Screen readers key off the *presence* of a decoration, not its colour,
396/// so a solid neutral (black) decoration is sufficient; the run's own
397/// foreground colour is not plumbed through this synthetic-node path.
398fn default_text_decoration() -> accesskit::TextDecoration {
399    accesskit::TextDecoration {
400        style: accesskit::TextDecorationStyle::Solid,
401        color: accesskit::Color {
402            red: 0,
403            green: 0,
404            blue: 0,
405            alpha: 255,
406        },
407    }
408}
409
410impl AccessNodeBuilder {
411    pub fn new() -> Self {
412        Self {
413            inner: Node::new(Role::Unknown),
414            name: None,
415            value: None,
416            role: Role::Unknown,
417            actions: Vec::new(),
418            toggled: None,
419            expanded: None,
420            selected: None,
421            hidden: false,
422            owner: None,
423            pending_self_selection: None,
424            pending_explicit_selection: None,
425            children_collected: Vec::new(),
426            child_local_bounds: Vec::new(),
427            name_probe: false,
428        }
429    }
430
431    /// Construct a builder with a known owner WidgetId. Used by the
432    /// tree walker when invoking `Widget::accessibility`; the owner
433    /// id drives synthetic NodeId derivation in the sub-tree API.
434    pub fn for_widget(owner: WidgetId) -> Self {
435        let mut b = Self::new();
436        b.owner = Some(owner);
437        b
438    }
439
440    /// A builder whose only purpose is to harvest a widget's accessible
441    /// *name*.
442    ///
443    /// The merge pass (`AccessSubtreeMode::Merge`) and the
444    /// tooltip-description probe both run a widget's `accessibility()`
445    /// against a throwaway builder and read nothing off it but the name.
446    /// Text runs must not be emitted there: their node ids would never
447    /// reach the tree, and the run-emitting helpers still need to compute
448    /// the merged text so the *name* comes out right.
449    pub fn for_name_probe(owner: WidgetId) -> Self {
450        let mut b = Self::for_widget(owner);
451        b.name_probe = true;
452        b
453    }
454
455    /// Whether this builder is a name probe (see
456    /// [`for_name_probe`](Self::for_name_probe)), or has no owner at all —
457    /// the two cases in which a widget must emit no synthetic children.
458    ///
459    /// An owner-less builder is a legitimate, if unusual, caller: the
460    /// overlay measurement path and the debug inspector both run
461    /// `accessibility()` on an arbitrary widget through
462    /// [`AccessNodeBuilder::new`] to read its role and name.
463    pub fn emits_no_children(&self) -> bool {
464        self.name_probe || self.owner.is_none()
465    }
466
467    pub fn set_role(&mut self, role: Role) {
468        self.role = role;
469        self.inner.set_role(role);
470    }
471
472    pub fn set_name(&mut self, name: impl Into<String>) {
473        let name: String = name.into();
474        self.inner.set_label(name.clone());
475        self.name = Some(name);
476    }
477
478    pub fn set_disabled(&mut self) {
479        self.inner.set_disabled();
480    }
481
482    /// Clear the disabled flag set by an earlier `set_disabled()` call. Used
483    /// by the override layer to un-set state a widget emitted unconditionally
484    /// (e.g. a Panel that always calls `set_hidden`/`set_disabled`).
485    pub fn clear_disabled(&mut self) {
486        self.inner.clear_disabled();
487    }
488
489    pub fn add_action(&mut self, action: Action) {
490        self.inner.add_action(action);
491        self.actions.push(action);
492    }
493
494    /// Remove a previously-advertised action. Used by the override layer
495    /// (`access_remove_action`) to suppress an action a widget emitted but
496    /// that doesn't apply in this composition.
497    pub fn remove_action(&mut self, action: Action) {
498        self.inner.remove_action(action);
499        self.actions.retain(|a| *a != action);
500    }
501
502    pub fn set_value(&mut self, value: impl Into<String>) {
503        let v: String = value.into();
504        self.inner.set_value(v.clone());
505        self.value = Some(v);
506    }
507
508    /// Advertise a color value on this node — typically paired with
509    /// [`accesskit::Role::ColorWell`]. Takes a `teksilo_tokens::Color` (f32
510    /// channels) and quantizes to AccessKit's 8-bit `Color` representation.
511    pub fn set_color_value(&mut self, color: teksilo_tokens::Color) {
512        let ak = accesskit::Color {
513            red: (color.r() * 255.0).round().clamp(0.0, 255.0) as u8,
514            green: (color.g() * 255.0).round().clamp(0.0, 255.0) as u8,
515            blue: (color.b() * 255.0).round().clamp(0.0, 255.0) as u8,
516            alpha: (color.a() * 255.0).round().clamp(0.0, 255.0) as u8,
517        };
518        self.inner.set_color_value(ak);
519    }
520
521    pub fn set_description(&mut self, description: impl Into<String>) {
522        self.inner.set_description(description.into());
523    }
524
525    pub fn set_live(&mut self, live: Live) {
526        self.inner.set_live(live);
527    }
528
529    pub fn set_described_by(&mut self, ids: impl Into<Vec<NodeId>>) {
530        self.inner.set_described_by(ids);
531    }
532
533    /// Append one node to the `described_by` relationship list. Mirror of
534    /// the existing `push_controlled`; used by the override layer's
535    /// `access_described_by` builder method and by the framework's
536    /// tooltip wiring.
537    pub fn push_described_by(&mut self, id: NodeId) {
538        self.inner.push_described_by(id);
539    }
540
541    /// Append one node to the `labelled_by` relationship list. Used by
542    /// `access_labelled_by` to point at an external label widget.
543    pub fn push_labelled_by(&mut self, id: NodeId) {
544        self.inner.push_labelled_by(id);
545    }
546
547    /// Replace the `details` relationship list — AccessKit's analogue of
548    /// `aria-details`.
549    ///
550    /// Distinct from `described_by`, and deliberately so: a *description* is text
551    /// a screen reader appends when announcing the element, while *details* points
552    /// at a structured node the user can navigate **into**. The W3C annotations
553    /// pattern is built on that difference — an annotated run carries
554    /// `aria-details` to a `role="comment"` node, so the reader can say "has
555    /// comment" and let the user go read it, rather than reciting a whole thread
556    /// inline every time the caret crosses the span.
557    pub fn set_details(&mut self, ids: impl Into<Vec<NodeId>>) {
558        self.inner.set_details(ids);
559    }
560
561    /// Append one node to the `details` relationship list.
562    ///
563    /// It is a list, not a single id, because overlapping annotations are normal:
564    /// one run of text can carry several comments, and each gets its own entry.
565    pub fn push_detail(&mut self, id: NodeId) {
566        self.inner.push_detail(id);
567    }
568
569    /// Stable author-supplied identifier (test/debug id, equivalent to
570    /// `aria-label`-style `data-testid`). Maps to `accesskit::Node::set_author_id`.
571    pub fn set_author_id(&mut self, id: impl Into<String>) {
572        self.inner.set_author_id(id.into());
573    }
574
575    /// Replace the node's custom-action list with `actions`, and keep the
576    /// supported-action gate in step with it.
577    ///
578    /// **The gate is the whole reachability story, not the list.** A platform
579    /// adapter reports a node's custom actions through
580    /// [`accesskit::Action::CustomAction`] being
581    /// supported, not through the list being non-empty
582    /// (`accesskit_ios-0.2.0/src/node.rs:109` is the one that says so in code),
583    /// so a list published without it is decoration: named, announced by
584    /// nothing, invokable by nobody. Three separate widgets in this repo have
585    /// shipped that exact defect, each having to remember a second call beside
586    /// this one.
587    ///
588    /// So this method owns both halves. A non-empty list advertises the gate; an
589    /// empty one withdraws it, because a gate with nothing behind it offers an
590    /// assistive-technology user a menu that is not there. Callers that also
591    /// call `add_action(Action::CustomAction)` themselves are correct and
592    /// unaffected — both operations are idempotent.
593    pub fn set_custom_actions(&mut self, actions: Vec<accesskit::CustomAction>) {
594        let empty = actions.is_empty();
595        self.inner.set_custom_actions(actions);
596        if empty {
597            self.remove_action(accesskit::Action::CustomAction);
598        } else {
599            self.add_action(accesskit::Action::CustomAction);
600        }
601    }
602
603    pub fn set_toggled(&mut self, toggled: bool) {
604        self.toggled = Some(toggled);
605        self.inner.set_toggled(if toggled {
606            accesskit::Toggled::True
607        } else {
608            accesskit::Toggled::False
609        });
610    }
611
612    pub fn set_expanded(&mut self, expanded: bool) {
613        self.expanded = Some(expanded);
614        self.inner.set_expanded(expanded);
615    }
616
617    pub fn set_has_popup(&mut self, kind: accesskit::HasPopup) {
618        self.inner.set_has_popup(kind);
619    }
620
621    /// Placeholder text displayed when the widget has no user-entered value
622    /// yet. Screen readers treat this distinctly from `value` — they'll
623    /// announce the placeholder as hint text rather than as the current
624    /// value. Used by `ComboBox` when selection is `None`, by `TextInput`
625    /// before the user types, etc.
626    pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
627        self.inner.set_placeholder(placeholder.into());
628    }
629
630    /// Target URL for link-like widgets. Maps to `aria-url` / platform
631    /// link metadata so screen readers can announce the destination
632    /// (e.g. "link, `https://example.com`"). Informational only — does
633    /// not navigate when activated.
634    pub fn set_url(&mut self, url: impl Into<String>) {
635        self.inner.set_url(url.into());
636    }
637
638    /// Keyboard shortcut announcement (e.g. `"Ctrl+S"`). Maps to
639    /// `aria-keyshortcuts`. Used by menu items and buttons whose
640    /// chord is shown visually but must also be exposed to assistive
641    /// tech so shortcut users discover it.
642    pub fn set_keyboard_shortcut(&mut self, shortcut: impl Into<String>) {
643        self.inner.set_keyboard_shortcut(shortcut.into());
644    }
645
646    /// Autocomplete behavior for combobox / text input widgets. Maps to
647    /// ARIA `aria-autocomplete`: `Inline` completes within the field,
648    /// `List` shows a popup of matching values, `Both` does both.
649    pub fn set_auto_complete(&mut self, kind: accesskit::AutoComplete) {
650        self.inner.set_auto_complete(kind);
651    }
652
653    /// Selection state — used by `RadioButton`, `Tab`, `ListBoxOption`,
654    /// `TreeItem`, menu items in radio/check groups, etc. This is the
655    /// correct property for "this option in a mutually exclusive
656    /// group is the active one"; don't confuse with `set_toggled`,
657    /// which models checkbox/switch on-off state.
658    pub fn set_selected(&mut self, selected: bool) {
659        self.selected = Some(selected);
660        self.inner.set_selected(selected);
661    }
662
663    pub fn set_orientation(&mut self, orientation: accesskit::Orientation) {
664        self.inner.set_orientation(orientation);
665    }
666
667    /// **1-based** index of this item in its parent set — the ARIA
668    /// `aria-posinset` convention, so the first item is 1.
669    ///
670    /// Use on `Role::Tab`, `Role::ListBoxOption`, `Role::Row`,
671    /// `Role::MenuItem` and similar collection items, and set
672    /// [`set_size_of_set`](Self::set_size_of_set) on their **container** so
673    /// assistive technology can announce "tab 3 of 5".
674    ///
675    /// ⚠ AccessKit's own `position_in_set` is **zero-based**, unlike
676    /// `aria-posinset` — see `accesskit-0.25.0/src/lib.rs`, "Difference with
677    /// ARIA". This wrapper converts, so callers keep the ARIA convention that
678    /// every caller and every doc in this repository already assumed. Do not
679    /// reach past it with `inner_mut().set_position_in_set(..)`: that skips the
680    /// conversion, and the value then arrives one too high on Windows and
681    /// Linux, both of which add the 1 back
682    /// (`accesskit_windows-0.35.0/src/node.rs:682-687`,
683    /// `accesskit_atspi_common-0.20.0/src/node.rs:394`).
684    pub fn set_position_in_set(&mut self, position: usize) {
685        self.inner
686            .set_position_in_set(to_accesskit_ordinal(position));
687    }
688
689    /// Total number of items in a collection — maps to ARIA `aria-setsize`.
690    ///
691    /// ⚠ Set this on the **container** (`Role::ListBox`, `Role::TabList`,
692    /// `Role::Tree`, `Role::Menu`, …), not on each item. Unlike
693    /// `aria-setsize`, which is per item, AccessKit resolves an item's set size
694    /// by walking *up* from its parent: `size_of_set_from_container`
695    /// (`accesskit_consumer-0.39.0/src/node.rs:629-641`) starts at
696    /// `filtered_parent`, so a value written on the item itself is read by no
697    /// adapter on any platform.
698    ///
699    /// Report the **logical** set size, not the realized virtualization window:
700    /// 200 for a 200-row list even when 20 rows exist as widgets.
701    pub fn set_size_of_set(&mut self, size: usize) {
702        self.inner.set_size_of_set(size);
703    }
704
705    /// **1-based** hierarchical depth — the ARIA `aria-level` convention, so a
706    /// root tree item is level 1 and an `<h1>` is level 1.
707    ///
708    /// ⚠ AccessKit's `level` is **zero-based**, unlike `aria-level`. This
709    /// wrapper converts. Reaching past it with `inner_mut().set_level(..)`
710    /// makes every heading and every tree row announce one level too deep on
711    /// Windows, which adds the 1 back
712    /// (`accesskit_windows-0.35.0/src/node.rs:698-701`).
713    pub fn set_level(&mut self, level: usize) {
714        self.inner.set_level(to_accesskit_ordinal(level));
715    }
716
717    // ── Grid / table semantics (`aria-rowcount` / `aria-colindex` / …) ──
718    //
719    // Typed wrappers over the corresponding `accesskit::Node` setters so
720    // grid/table widgets don't have to drop to `inner_mut()`. On a
721    // `Role::Grid` / `Role::Table` container set the *logical* row/column
722    // counts (not the realized window); on each cell set its 1-based
723    // row/column index.
724
725    /// Total logical row count on a grid/table container (`aria-rowcount`).
726    pub fn set_row_count(&mut self, count: usize) {
727        self.inner.set_row_count(count);
728    }
729
730    /// Total logical column count on a grid/table container (`aria-colcount`).
731    pub fn set_column_count(&mut self, count: usize) {
732        self.inner.set_column_count(count);
733    }
734
735    /// **1-based** row index of a cell or row — the ARIA `aria-rowindex`
736    /// convention, so the header row is 1 and the first body row is 2.
737    ///
738    /// ⚠ AccessKit's `row_index` is **zero-based**, unlike `aria-rowindex`.
739    /// This wrapper converts; `inner_mut().set_row_index(..)` does not.
740    pub fn set_row_index(&mut self, index: usize) {
741        self.inner.set_row_index(to_accesskit_ordinal(index));
742    }
743
744    /// **1-based** column index of a cell — the ARIA `aria-colindex`
745    /// convention, so the leftmost column is 1.
746    ///
747    /// ⚠ AccessKit's `column_index` is **zero-based**, unlike `aria-colindex`.
748    /// This wrapper converts; `inner_mut().set_column_index(..)` does not.
749    pub fn set_column_index(&mut self, index: usize) {
750        self.inner.set_column_index(to_accesskit_ordinal(index));
751    }
752
753    /// Number of rows a cell spans (`aria-rowspan`).
754    pub fn set_row_span(&mut self, span: usize) {
755        self.inner.set_row_span(span);
756    }
757
758    /// Number of columns a cell spans (`aria-colspan`).
759    pub fn set_column_span(&mut self, span: usize) {
760        self.inner.set_column_span(span);
761    }
762
763    /// Whether the container allows multiple selected items
764    /// (`aria-multiselectable`). Set on the `Role::Grid` / `Role::ListBox`
765    /// container in multi-select mode.
766    pub fn set_multiselectable(&mut self, value: bool) {
767        if value {
768            self.inner.set_multiselectable();
769        } else {
770            self.inner.clear_multiselectable();
771        }
772    }
773
774    /// The currently-active descendant (`aria-activedescendant`) — the
775    /// roving-focus pattern where focus stays on a composite container and
776    /// this points at the focused child (e.g. the focused grid cell).
777    pub fn set_active_descendant(&mut self, id: NodeId) {
778        self.inner.set_active_descendant(id);
779    }
780
781    /// Flag the node as a modal dialog. Use on `Role::Dialog` /
782    /// `Role::AlertDialog` when input is blocked outside the dialog.
783    pub fn set_modal(&mut self) {
784        self.inner.set_modal();
785    }
786
787    /// Mark this node as the current item within its container
788    /// (e.g. the "current page" crumb inside a `Navigation`, the
789    /// current step in a wizard). Maps to ARIA `aria-current`.
790    pub fn set_aria_current(&mut self, current: accesskit::AriaCurrent) {
791        self.inner.set_aria_current(current);
792    }
793
794    /// Single-step delta for `Slider` / `SpinButton` — how much the
795    /// value changes per keyboard arrow or Action::Increment tick.
796    pub fn set_numeric_value_step(&mut self, step: f64) {
797        self.inner.set_numeric_value_step(step);
798    }
799
800    /// Page-step delta for `Slider` / `SpinButton` — how much the
801    /// value changes per PgUp/PgDown or coarse adjustment.
802    pub fn set_numeric_value_jump(&mut self, jump: f64) {
803        self.inner.set_numeric_value_jump(jump);
804    }
805
806    /// Append a controlled-node relationship — e.g. a `Tab` pointing
807    /// at its matching `TabPanel`, a `ComboBox` pointing at its
808    /// listbox popup. AccessKit / ARIA equivalent of `aria-controls`.
809    pub fn push_controlled(&mut self, id: NodeId) {
810        self.inner.push_controlled(id);
811    }
812
813    /// Declare this radio button's membership in a radio group.
814    /// Each `RadioButton` node should push every sibling in its
815    /// group (including itself); screen readers use this to
816    /// announce positional info like "2 of 3".
817    pub fn push_to_radio_group(&mut self, id: NodeId) {
818        self.inner.push_to_radio_group(id);
819    }
820
821    pub fn set_numeric_value(&mut self, value: f64) {
822        self.inner.set_numeric_value(value);
823    }
824
825    pub fn set_min_numeric_value(&mut self, value: f64) {
826        self.inner.set_min_numeric_value(value);
827    }
828
829    pub fn set_max_numeric_value(&mut self, value: f64) {
830        self.inner.set_max_numeric_value(value);
831    }
832
833    /// Hide this node from all assistive technologies (equivalent to
834    /// `aria-hidden="true"`). The node is still in the widget tree but
835    /// is invisible to screen readers and other ATs. Use for purely
836    /// decorative elements — e.g. scrollbars (AT scrolls via the
837    /// parent `ScrollView`'s scroll actions instead).
838    pub fn set_hidden(&mut self) {
839        self.hidden = true;
840        self.inner.set_hidden();
841    }
842
843    /// Clear the hidden flag set by an earlier `set_hidden()` call. Used
844    /// by the override layer to re-expose a widget that marked itself
845    /// presentational. AccessKit's Node `hidden` is local — un-hiding this
846    /// node does not propagate to descendants, but descendants are not
847    /// transitively hidden by their ancestor's `hidden` either.
848    pub fn clear_hidden(&mut self) {
849        self.hidden = false;
850        self.inner.clear_hidden();
851    }
852
853    pub fn is_hidden(&self) -> bool {
854        self.hidden
855    }
856
857    pub fn role(&self) -> Role {
858        self.role
859    }
860
861    pub fn name(&self) -> Option<&str> {
862        self.name.as_deref()
863    }
864
865    pub fn actions(&self) -> &[Action] {
866        &self.actions
867    }
868
869    pub fn value(&self) -> Option<&str> {
870        self.value.as_deref()
871    }
872
873    pub fn toggled(&self) -> Option<bool> {
874        self.toggled
875    }
876
877    pub fn expanded(&self) -> Option<bool> {
878        self.expanded
879    }
880
881    pub fn selected(&self) -> Option<bool> {
882        self.selected
883    }
884
885    /// Build the AccessKit Node with the given ID. Resolves any
886    /// `pending_self_selection` recorded via `set_caret_position_on_self`
887    /// or `set_text_selection_on_self` — at this point we know the
888    /// widget's NodeId and can inject it into the text selection.
889    /// Returns the primary `(NodeId, Node)` pair plus any synthetic
890    /// child nodes emitted by the widget via `push_paragraph_child`
891    /// / `push_text_run_child`. The tree walker is responsible for
892    /// merging these into the final `TreeUpdate`.
893    pub fn build(
894        mut self,
895        id: WidgetId,
896    ) -> (
897        NodeId,
898        Node,
899        Vec<(NodeId, Node)>,
900        Vec<(NodeId, teksilo_canvas::Rect)>,
901    ) {
902        let node_id = widget_id_to_node_id(id);
903        // Priority: explicit (child-targeting) selection wins over
904        // self-targeting selection — widgets that emit TextRun
905        // children use the explicit path.
906        if let Some((anchor, focus)) = self.pending_explicit_selection.take() {
907            let selection = TextSelection { anchor, focus };
908            self.inner.set_text_selection(selection);
909        } else if let Some((anchor, focus)) = self.pending_self_selection.take() {
910            let selection = TextSelection {
911                anchor: TextPosition {
912                    node: node_id,
913                    character_index: anchor,
914                },
915                focus: TextPosition {
916                    node: node_id,
917                    character_index: focus,
918                },
919            };
920            self.inner.set_text_selection(selection);
921        }
922
923        // accesskit contract: a `Role::Label` node carries its text in the
924        // `value` property, NOT `label`. Every platform adapter reads it that
925        // way — Windows UIA derives the node's Name from `value` (its
926        // `label_comes_from_value()` returns true for `Role::Label`), macOS
927        // maps `Role::Label` to `NSAccessibilityStaticTextRole` whose content
928        // is exposed as AXValue, and `accesskit_consumer` reads `value` when
929        // another control is `labelled_by` this node. A name left in the
930        // `label` property is therefore silently dropped on Windows and stray
931        // on macOS. Widgets set the accessible name uniformly via `set_name`
932        // (-> the `label` property); re-serialize it to `value` here, the one
933        // place every emitted node is finalized — widget nodes (via the tree
934        // walker) and scene synthetic children (via `push_scene_child`, which
935        // also funnels through `build`). The builder's logical `name()` view
936        // is intentionally left untouched, so introspection / `find_by_label`
937        // continue to report the accessible name regardless of role. Reading
938        // the inner node directly keeps this robust against any label set
939        // outside `set_name`, and idempotent (a second pass finds no label).
940        if self.inner.role() == Role::Label
941            && let Some(label) = self.inner.label().map(|s| s.to_string())
942        {
943            if self.inner.value().is_none() {
944                self.inner.set_value(label);
945            }
946            self.inner.clear_label();
947        }
948
949        // A role override away from a text-range-capable role leaves any
950        // runs inert: no platform exposes them, and they would still cost
951        // a node in every update and a child stop the walker has to
952        // reconcile. Drop them rather than ship them.
953        // `Role::Unknown` is exempt: it is the builder's initial value, so
954        // dropping there would punish a caller that never set a role at all
955        // rather than one that set a role text ranges cannot live under.
956        // Such a node is pruned by the walker in any case.
957        if !self.children_collected.is_empty()
958            && self.inner.role() != Role::Unknown
959            && !role_supports_text_ranges(self.inner.role())
960            && let Some(run_ids) = own_run_ids(&self.inner, &self.children_collected)
961        {
962            self.children_collected
963                .retain(|(cid, _)| !run_ids.contains(cid));
964            self.child_local_bounds
965                .retain(|(cid, _)| !run_ids.contains(cid));
966            let kept: Vec<NodeId> = self
967                .inner
968                .children()
969                .iter()
970                .copied()
971                .filter(|cid| !run_ids.contains(cid))
972                .collect();
973            self.inner.set_children(kept);
974        }
975
976        // Synthetic children declared in the widget's own space become
977        // absolute here: the walker has already written the owner's
978        // window-space rect onto `inner`, and this is the only point at
979        // which both halves are in hand.
980        let origin = self
981            .inner
982            .bounds()
983            .map(|r| (r.x0, r.y0))
984            .unwrap_or((0.0, 0.0));
985        let local_bounds = std::mem::take(&mut self.child_local_bounds);
986        for (child_id, local) in &local_bounds {
987            let absolute = accesskit::Rect {
988                x0: origin.0 + local.x as f64,
989                y0: origin.1 + local.y as f64,
990                x1: origin.0 + (local.x + local.width) as f64,
991                y1: origin.1 + (local.y + local.height) as f64,
992            };
993            for (id, node) in self.children_collected.iter_mut() {
994                if id == child_id {
995                    node.set_bounds(absolute);
996                    break;
997                }
998            }
999        }
1000
1001        (node_id, self.inner, self.children_collected, local_bounds)
1002    }
1003
1004    /// Get a reference to the inner node for advanced use.
1005    pub fn inner_mut(&mut self) -> &mut Node {
1006        &mut self.inner
1007    }
1008
1009    /// The widget id this builder was constructed for, if any. Set
1010    /// by [`AccessNodeBuilder::for_widget`]; used by the scene-tree
1011    /// walker to derive synthetic `NodeId`s for items / groups outside
1012    /// the closure form (`push_scene_child*`).
1013    pub fn owner_id(&self) -> Option<crate::widget_id::WidgetId> {
1014        self.owner
1015    }
1016
1017    /// Run a mutator over a synthetic child node previously pushed
1018    /// via `push_scene_child` (or its `_under` variant). Used by
1019    /// the scene walker to apply cross-tree decorations (relations /
1020    /// live regions / landmarks) after the initial hierarchy emit.
1021    /// Returns `true` if the node was found.
1022    ///
1023    /// Cannot be used to mutate widget-derived NodeIds — those live
1024    /// in the global TreeUpdate and are owned by other widgets.
1025    pub fn with_collected_node<F: FnOnce(&mut Node)>(&mut self, node_id: NodeId, f: F) -> bool {
1026        for (id, node) in self.children_collected.iter_mut() {
1027            if *id == node_id {
1028                f(node);
1029                return true;
1030            }
1031        }
1032        false
1033    }
1034
1035    /// Mark this node as read-only. Used by `RichTextEditor::read_only` so
1036    /// screen readers announce the widget as a document rather than a form
1037    /// field.
1038    pub fn set_read_only(&mut self) {
1039        self.inner.set_read_only();
1040    }
1041
1042    /// Declare the current text selection. `anchor` and `focus` are
1043    /// character indices into the widget's flat text representation; pass
1044    /// equal indices for a collapsed caret. Uses the same `NodeId` for
1045    /// both positions (typical for single-node text widgets that expose
1046    /// the document as one run, which is what the first milestone of
1047    /// `RichTextEditor` does).
1048    pub fn set_text_selection(&mut self, node_id: NodeId, anchor: usize, focus: usize) {
1049        let selection = TextSelection {
1050            anchor: TextPosition {
1051                node: node_id,
1052                character_index: anchor,
1053            },
1054            focus: TextPosition {
1055                node: node_id,
1056                character_index: focus,
1057            },
1058        };
1059        self.inner.set_text_selection(selection);
1060    }
1061
1062    /// Convenience for exposing a caret position as a collapsed selection.
1063    pub fn set_caret_position(&mut self, node_id: NodeId, character_index: usize) {
1064        self.set_text_selection(node_id, character_index, character_index);
1065    }
1066
1067    /// Declare a text selection whose anchor and focus live on the
1068    /// widget's own AccessKit node. The widget doesn't know its own
1069    /// `NodeId` inside `accessibility(&self, builder)` — it's only
1070    /// resolved when the tree walker calls `builder.build(widget_id)`.
1071    /// This method stashes the character indices and defers the
1072    /// `set_text_selection` call until `build()` knows the ID.
1073    pub fn set_text_selection_on_self(&mut self, anchor: usize, focus: usize) {
1074        self.pending_self_selection = Some((anchor, focus));
1075    }
1076
1077    /// Convenience wrapper for a collapsed caret on the widget's own node.
1078    pub fn set_caret_position_on_self(&mut self, character_index: usize) {
1079        self.set_text_selection_on_self(character_index, character_index);
1080    }
1081
1082    // ── Sub-tree API: multi-node widgets (rich text, etc.) ─────────────
1083
1084    /// Push a `Role::Paragraph` child on the current node and return
1085    /// its `NodeId`. The NodeId is synthetic (bit 63 set) and
1086    /// deterministic given the owning widget + `element_id`.
1087    ///
1088    /// The owning `WidgetId` comes from the builder's `owner`
1089    /// field, set by `AccessNodeBuilder::for_widget`. Returns
1090    /// `NodeId(0)` (a no-op placeholder) if the builder has no
1091    /// owner, which can only happen when a widget constructs a
1092    /// builder manually via `new()` instead of going through the
1093    /// tree walker. That's a programming error worth catching in
1094    /// debug.
1095    pub fn push_paragraph_child(&mut self, element_id: u64) -> NodeId {
1096        let Some(owner) = self.owner else {
1097            debug_assert!(
1098                false,
1099                "push_paragraph_child called on a builder with no owner — \
1100                 widgets must only call this from Widget::accessibility"
1101            );
1102            return NodeId(0);
1103        };
1104        let node_id = synthetic_node_id(owner, element_id, SyntheticKind::Paragraph);
1105        let node = Node::new(Role::Paragraph);
1106        self.children_collected.push((node_id, node));
1107        self.inner.push_child(node_id);
1108        node_id
1109    }
1110
1111    /// Push a `Role::Comment` child carrying an annotation's text, and return its
1112    /// `NodeId` so the annotated run can point at it via [`Self::push_detail`].
1113    ///
1114    /// `group_id` must be the annotation's own durable identity (a comment's uid,
1115    /// never a store id), so the node keeps the same `NodeId` across rebuilds and
1116    /// a screen reader's cursor is not thrown out of the thread by an unrelated
1117    /// edit elsewhere in the document.
1118    ///
1119    /// Per the W3C annotations pattern the *body* carries the name; the annotated
1120    /// span itself must NOT be given an accessible name (`role="mark"` forbids it)
1121    /// — naming the span would make the reader announce the comment's text in
1122    /// place of the prose.
1123    pub fn push_annotation_child(&mut self, group_id: u64, text: impl Into<String>) -> NodeId {
1124        let Some(owner) = self.owner else {
1125            debug_assert!(
1126                false,
1127                "push_annotation_child called on a builder with no owner — \
1128                 widgets must only call this from Widget::accessibility"
1129            );
1130            return NodeId(0);
1131        };
1132        let node_id = synthetic_node_id(owner, group_id, SyntheticKind::Annotation);
1133        let mut node = Node::new(Role::Comment);
1134        node.set_value(text.into());
1135        self.children_collected.push((node_id, node));
1136        self.inner.push_child(node_id);
1137        node_id
1138    }
1139
1140    /// Add a `details` target to an already-pushed **child** node.
1141    ///
1142    /// The sub-tree API builds children eagerly into `children_collected`, so a
1143    /// relation between two synthetic siblings (a `TextRun` and its annotation
1144    /// body) cannot go through the current node's own setters — it has to reach
1145    /// back into the collected child. A no-op if `child` was never pushed, which
1146    /// keeps a caller that emitted spans for a run it then skipped from panicking.
1147    pub fn push_detail_on_child(&mut self, child: NodeId, detail: NodeId) {
1148        if let Some((_, node)) = self
1149            .children_collected
1150            .iter_mut()
1151            .find(|(id, _)| *id == child)
1152        {
1153            node.push_detail(detail);
1154        }
1155    }
1156
1157    /// Push a `Role::Link` child on the current node. Used by label
1158    /// widgets (e.g. `TextWidget` with `.markup(true)` enabled) to
1159    /// expose inline `[label](url)` links as individual accessible
1160    /// nodes alongside the parent's own text.
1161    ///
1162    /// `element_id` should be a stable identifier for the link inside
1163    /// the parent widget (typically the byte offset of the `[` in the
1164    /// original markup source, so the NodeId survives identical
1165    /// re-layouts).
1166    ///
1167    /// The returned `NodeId` is synthetic (bit 63 set) and deterministic
1168    /// given `(owner, element_id)`.
1169    pub fn push_link_child(
1170        &mut self,
1171        element_id: u64,
1172        label: impl Into<String>,
1173        url: impl Into<String>,
1174    ) -> NodeId {
1175        let Some(owner) = self.owner else {
1176            debug_assert!(
1177                false,
1178                "push_link_child called on a builder with no owner — \
1179                 widgets must only call this from Widget::accessibility"
1180            );
1181            return NodeId(0);
1182        };
1183        let node_id = synthetic_node_id(owner, element_id, SyntheticKind::Link);
1184        let mut node = Node::new(Role::Link);
1185        let label: String = label.into();
1186        if !label.is_empty() {
1187            node.set_label(label);
1188        }
1189        // AccessKit exposes the link target through the `Value` property
1190        // on a `Role::Link` node — same convention the standalone
1191        // `Link` widget uses via `set_value(...)`.
1192        node.set_value(url.into());
1193        self.children_collected.push((node_id, node));
1194        self.inner.push_child(node_id);
1195        node_id
1196    }
1197
1198    /// Push a synthetic child node representing a lightweight
1199    /// `SceneItem` (or `SceneGroup`) emitted by `teksilo_scene::SceneView`.
1200    /// The caller customizes a sub-`AccessNodeBuilder` (mirroring the
1201    /// `Widget::accessibility` shape) and gets back the
1202    /// deterministic synthetic `NodeId` allocated for the
1203    /// `(owner, element_id, kind)` tuple.
1204    ///
1205    /// `kind` must be one of the synthetic kinds this path
1206    /// allocates: [`SyntheticKind::SceneItem`],
1207    /// [`SyntheticKind::SceneGroup`], [`SyntheticKind::SceneMagnet`],
1208    /// [`SyntheticKind::SceneHandle`], [`SyntheticKind::ChartMark`] or
1209    /// [`SyntheticKind::LaneMark`];
1210    /// passing any other variant panics in debug. The last two are
1211    /// how a chart or a margin lane emits one node per datum from
1212    /// its own `accessibility()`.
1213    ///
1214    /// Any further synthetic children the closure pushes (a
1215    /// `SceneGroup` containing nested `SceneItem`s) are forwarded into the parent's
1216    /// `children_collected` and re-parented under the
1217    /// just-pushed node via the closure's own `inner.push_child`
1218    /// calls — same convention as `push_paragraph_child` →
1219    /// `push_text_run_child`.
1220    pub fn push_scene_child(
1221        &mut self,
1222        element_id: u64,
1223        kind: SyntheticKind,
1224        customize: impl FnOnce(&mut AccessNodeBuilder),
1225    ) -> NodeId {
1226        debug_assert!(
1227            matches!(
1228                kind,
1229                SyntheticKind::SceneItem
1230                    | SyntheticKind::SceneGroup
1231                    | SyntheticKind::SceneMagnet
1232                    | SyntheticKind::SceneHandle
1233                    | SyntheticKind::ChartMark
1234                    | SyntheticKind::LaneMark
1235            ),
1236            "push_scene_child requires SyntheticKind::SceneItem, ::SceneGroup, ::SceneMagnet, ::SceneHandle, ::ChartMark, or ::LaneMark"
1237        );
1238        let Some(owner) = self.owner else {
1239            debug_assert!(
1240                false,
1241                "push_scene_child called on a builder with no owner — \
1242                 widgets must only call this from Widget::accessibility"
1243            );
1244            return NodeId(0);
1245        };
1246        let node_id = synthetic_node_id(owner, element_id, kind);
1247        // Build the child against a fresh sub-builder so the item
1248        // sees the same `&mut AccessNodeBuilder` shape as widgets.
1249        // Owner-id is the SceneView's so further `push_scene_child`
1250        // calls inside the customize closure (a `SceneGroup`
1251        // emitting nested items) hash off the same owner.
1252        let mut child_builder = AccessNodeBuilder::for_widget(owner);
1253        customize(&mut child_builder);
1254        // `build(owner)` re-derives a widget-keyed NodeId we throw
1255        // away — we use our synthetic `node_id` instead. The
1256        // returned `Node` carries the role / label / bounds / etc
1257        // the customize closure populated; any *grand*children the
1258        // closure pushed via further `push_scene_child` calls come
1259        // back in the third tuple field and we forward them so the
1260        // main TreeUpdate sees the full subtree.
1261        let (_unused, node, grand_children, _local) = child_builder.build(owner);
1262        self.children_collected.push((node_id, node));
1263        for (gid, gnode) in grand_children {
1264            self.children_collected.push((gid, gnode));
1265        }
1266        self.inner.push_child(node_id);
1267        node_id
1268    }
1269
1270    /// Append an existing synthetic node id as a child of a
1271    /// previously-pushed `SceneGroup` (or `SceneItem`) child. Used by
1272    /// the scene logical-tree walker to re-parent items under their
1273    /// declared logical group rather than as direct children of the
1274    /// SceneView.
1275    ///
1276    /// Returns `true` if the parent was found (and the child was
1277    /// attached), `false` if the parent isn't in
1278    /// `children_collected` — the caller misordered the pushes.
1279    pub fn attach_scene_child_under(&mut self, parent: NodeId, child: NodeId) -> bool {
1280        for (id, node) in self.children_collected.iter_mut() {
1281            if *id == parent {
1282                node.push_child(child);
1283                return true;
1284            }
1285        }
1286        false
1287    }
1288
1289    /// Like `push_scene_child` but lets the caller pick the
1290    /// parent. `parent = None` attaches to the widget's own node
1291    /// (same behavior as `push_scene_child`); `parent = Some(...)`
1292    /// attaches to the previously-pushed scene-child with that id.
1293    /// The scene logical-tree walker uses this to nest scene items
1294    /// under declared `A11yGroup` parents.
1295    ///
1296    /// Returns the deterministic synthetic `NodeId` for the new
1297    /// child. If `parent` was `Some` but the parent wasn't found
1298    /// in `children_collected`, the child still gets created and
1299    /// recorded but ends up attached to the widget's own node as a
1300    /// fallback (and a debug-assert fires).
1301    pub fn push_scene_child_under(
1302        &mut self,
1303        parent: Option<NodeId>,
1304        element_id: u64,
1305        kind: SyntheticKind,
1306        customize: impl FnOnce(&mut AccessNodeBuilder),
1307    ) -> NodeId {
1308        debug_assert!(
1309            matches!(
1310                kind,
1311                SyntheticKind::SceneItem
1312                    | SyntheticKind::SceneGroup
1313                    | SyntheticKind::SceneMagnet
1314                    | SyntheticKind::SceneHandle
1315                    | SyntheticKind::ChartMark
1316                    | SyntheticKind::LaneMark
1317            ),
1318            "push_scene_child_under requires SyntheticKind::SceneItem, ::SceneGroup, ::SceneMagnet, ::SceneHandle, ::ChartMark, or ::LaneMark"
1319        );
1320        let Some(owner) = self.owner else {
1321            debug_assert!(
1322                false,
1323                "push_scene_child_under called on a builder with no owner — \
1324                 widgets must only call this from Widget::accessibility"
1325            );
1326            return NodeId(0);
1327        };
1328        let node_id = synthetic_node_id(owner, element_id, kind);
1329        let mut child_builder = AccessNodeBuilder::for_widget(owner);
1330        customize(&mut child_builder);
1331        let (_unused, node, grand_children, _local) = child_builder.build(owner);
1332        self.children_collected.push((node_id, node));
1333        for (gid, gnode) in grand_children {
1334            self.children_collected.push((gid, gnode));
1335        }
1336        match parent {
1337            Some(parent_id) => {
1338                let attached = self.attach_scene_child_under(parent_id, node_id);
1339                if !attached {
1340                    debug_assert!(
1341                        false,
1342                        "push_scene_child_under: parent {:?} not in children_collected — \
1343                         caller must push the parent before its children",
1344                        parent_id
1345                    );
1346                    self.inner.push_child(node_id);
1347                }
1348            }
1349            None => {
1350                self.inner.push_child(node_id);
1351            }
1352        }
1353        node_id
1354    }
1355
1356    /// Override a previously-pushed paragraph child's role to
1357    /// `Role::Heading` with the given hierarchical level. Used by
1358    /// the rich text editor when a block carries a
1359    /// `BlockFormat::heading_level`. Returns `true` if the node was
1360    /// found and updated, `false` otherwise (caller misused the
1361    /// api — the paragraph must have been pushed earlier).
1362    pub fn set_paragraph_as_heading(&mut self, node_id: NodeId, level: u8) -> bool {
1363        for (id, node) in self.children_collected.iter_mut() {
1364            if *id == node_id {
1365                node.set_role(Role::Heading);
1366                // AccessKit's `set_level` takes a usize (via the
1367                // usize_property_methods macro). Clamp to 1..=6 for
1368                // conventional heading semantics.
1369                // Clamped to the conventional 1..=6 heading range, then
1370                // converted: AccessKit's `level` is zero-based, so an H1 is 0.
1371                // Writing the 1-based number straight through made every
1372                // heading announce one level too deep on Windows, and made
1373                // "heading level 1" unreachable.
1374                let level: usize = to_accesskit_ordinal((level as usize).clamp(1, 6));
1375                node.set_level(level);
1376                return true;
1377            }
1378        }
1379        false
1380    }
1381
1382    /// Set the **1-based** position-in-set on a previously-pushed synthetic
1383    /// child (a paragraph, "line 42 of 200"), and the set size on this widget's
1384    /// own node, which is the container the child hangs from.
1385    ///
1386    /// AccessKit exposes `position_in_set` on every node, but
1387    /// [`set_position_in_set`](Self::set_position_in_set) only touches the
1388    /// widget's own node. This reaches a collected child by NodeId, the same
1389    /// way [`set_paragraph_as_heading`](Self::set_paragraph_as_heading) does,
1390    /// and applies the same 1-based-to-zero-based conversion.
1391    ///
1392    /// `size` deliberately lands on the parent rather than the child: AccessKit
1393    /// resolves a set size by walking up from the item, so a size written on
1394    /// the child is read by nobody. See
1395    /// [`set_size_of_set`](Self::set_size_of_set).
1396    ///
1397    /// Returns whether the child was found.
1398    pub fn set_child_position_in_set(
1399        &mut self,
1400        node_id: NodeId,
1401        position: usize,
1402        size: usize,
1403    ) -> bool {
1404        let found = self.with_collected_node(node_id, |node| {
1405            node.set_position_in_set(to_accesskit_ordinal(position));
1406        });
1407        if found {
1408            self.inner.set_size_of_set(size);
1409        }
1410        found
1411    }
1412
1413    /// Declare this node's base reading direction.
1414    ///
1415    /// AccessKit's text APIs read the direction from the *run*; the root
1416    /// carries it so a consumer that asks the container — and every
1417    /// platform that maps a paragraph direction onto its own attribute —
1418    /// gets an answer for an empty or geometry-less node too.
1419    pub fn set_text_direction(&mut self, direction: accesskit::TextDirection) {
1420        self.inner.set_text_direction(direction);
1421    }
1422
1423    /// Set the reading direction of a synthetic child pushed earlier.
1424    /// Returns `true` if the child was found.
1425    pub fn set_child_text_direction(
1426        &mut self,
1427        child: NodeId,
1428        direction: accesskit::TextDirection,
1429    ) -> bool {
1430        self.with_collected_node(child, |node| node.set_text_direction(direction))
1431    }
1432
1433    /// Declare a synthetic child's bounds in the **owner's own**
1434    /// coordinate space; [`build`](Self::build) translates them into
1435    /// window space once the owner's absolute rect is known.
1436    ///
1437    /// This is what a widget should use for geometry it derives from its
1438    /// own layout — a label's line boxes, an editor's rows. A widget
1439    /// holding rects that are *already* absolute (a scene item under a
1440    /// view transform) calls `set_bounds` on the child node instead and
1441    /// never comes through here.
1442    ///
1443    /// Returns `true` if the child was found.
1444    pub fn set_child_bounds_local(&mut self, child: NodeId, rect: teksilo_canvas::Rect) -> bool {
1445        if !self.children_collected.iter().any(|(id, _)| *id == child) {
1446            return false;
1447        }
1448        self.child_local_bounds.push((child, rect));
1449        true
1450    }
1451
1452    /// Link a run of `Role::TextRun` children as one visual line, so assistive
1453    /// technology navigating by line treats them as a continuous line rather
1454    /// than fracturing at each formatting or chunk boundary.
1455    ///
1456    /// Sets each run's `next_on_line` to its successor and each successor's
1457    /// `previous_on_line` to its predecessor (AccessKit's doubly-linked
1458    /// same-line chain); the first run keeps no `previous_on_line` and the last
1459    /// no `next_on_line`, which is how the consumer detects the line's ends. A
1460    /// slice of zero or one is a no-op. Every id must be a run pushed earlier via
1461    /// [`push_text_run_child`](Self::push_text_run_child).
1462    pub fn link_runs_on_line(&mut self, run_ids: &[NodeId]) {
1463        for pair in run_ids.windows(2) {
1464            let (a, b) = (pair[0], pair[1]);
1465            self.with_collected_node(a, |node| node.set_next_on_line(b));
1466            self.with_collected_node(b, |node| node.set_previous_on_line(a));
1467        }
1468    }
1469
1470    /// Push one `Role::TextRun` child, under `parent` when given and
1471    /// under the widget's own node otherwise.
1472    ///
1473    /// The single door every text run in the framework goes through.
1474    /// Returns `None` when the builder has no owner (so no id can be
1475    /// derived) or when the id it derived is already taken — a duplicate
1476    /// child id panics `accesskit_consumer`'s tree builder, so it is
1477    /// dropped here with a diagnostic instead.
1478    ///
1479    /// Prefer [`text_runs::push_text_runs`], which owns the chunking,
1480    /// the word segmentation, the hard-break rule and the line links;
1481    /// this is the primitive underneath it.
1482    pub fn push_text_run(&mut self, parent: Option<NodeId>, spec: TextRunSpec) -> Option<NodeId> {
1483        let owner = self.owner?;
1484        let node_id = synthetic_node_id(owner, spec.element_id, SyntheticKind::TextRun);
1485        if let Some((existing, _)) = self
1486            .children_collected
1487            .iter()
1488            .find(|(id, _)| *id == node_id)
1489        {
1490            debug_assert!(
1491                false,
1492                "Teksilo bug: two text runs of widget {owner:?} derived the same \
1493                 accessibility node id {existing:?}. Give each source its own \
1494                 `TextRunSource::id_seed`. Please file a bug report."
1495            );
1496            eprintln!(
1497                "Teksilo bug: two text runs of widget {owner:?} derived the same \
1498                 accessibility node id {existing:?}; the second was dropped. \
1499                 Please file a bug report."
1500            );
1501            return None;
1502        }
1503
1504        let TextRunSpec {
1505            value,
1506            character_lengths,
1507            word_starts,
1508            character_positions,
1509            character_widths,
1510            bounds,
1511            bounds_are_absolute,
1512            text_direction,
1513            attrs,
1514            ..
1515        } = spec;
1516
1517        debug_assert_eq!(
1518            character_lengths.iter().map(|n| *n as usize).sum::<usize>(),
1519            value.len(),
1520            "AccessKit requires the character lengths of a text run to sum to its value's byte length"
1521        );
1522        let mut node = Node::new(Role::TextRun);
1523        node.set_value(value);
1524        let expected = character_lengths.len();
1525        node.set_character_lengths(character_lengths);
1526        node.set_word_starts(word_starts);
1527        // All four or none: `Range::bounding_boxes()` throws away every box
1528        // it has already collected the moment one run is missing any of
1529        // them, so a half-populated run empties the geometry of every
1530        // range that touches it.
1531        if character_positions.len() == expected && character_widths.len() == expected {
1532            node.set_character_positions(character_positions);
1533            node.set_character_widths(character_widths);
1534        }
1535        node.set_text_direction(text_direction);
1536        if bounds_are_absolute {
1537            node.set_bounds(accesskit::Rect {
1538                x0: bounds.x as f64,
1539                y0: bounds.y as f64,
1540                x1: (bounds.x + bounds.width) as f64,
1541                y1: (bounds.y + bounds.height) as f64,
1542            });
1543        }
1544        apply_run_attrs(&mut node, attrs);
1545
1546        self.children_collected.push((node_id, node));
1547        if !bounds_are_absolute {
1548            self.child_local_bounds.push((node_id, bounds));
1549        }
1550
1551        match parent {
1552            Some(parent_node) => {
1553                for (id, node) in self.children_collected.iter_mut() {
1554                    if *id == parent_node {
1555                        node.push_child(node_id);
1556                        return Some(node_id);
1557                    }
1558                }
1559                // Parent not found — attach to the widget's own node
1560                // rather than orphaning the run. Caller misused the API.
1561                self.inner.push_child(node_id);
1562            }
1563            None => self.inner.push_child(node_id),
1564        }
1565        Some(node_id)
1566    }
1567
1568    /// Push a `Role::TextRun` child under `parent_node` (usually a
1569    /// paragraph NodeId returned from `push_paragraph_child`, but
1570    /// may also be the widget's own node for inline editors).
1571    ///
1572    /// `element_id` is the stable id of the underlying text-document
1573    /// inline element; combined with `parent_widget` and a
1574    /// disambiguator it produces a synthetic NodeId that survives
1575    /// edits. `fragment_offset` is the block-relative character
1576    /// offset of this run — used as the disambiguator so two
1577    /// highlight-split sub-runs sharing one source element don't
1578    /// collide.
1579    ///
1580    /// `character_lengths` must be the UTF-8 byte length of each
1581    /// character in `value`, per AccessKit's contract. Optional
1582    /// `word_starts`, `character_positions`, and `character_widths`
1583    /// populate the corresponding AccessKit properties.
1584    ///
1585    /// Returns the allocated synthetic `NodeId` so the caller can
1586    /// reference it later when attaching a `TextSelection` via
1587    /// `set_text_selection_to`.
1588    #[allow(clippy::too_many_arguments)]
1589    pub fn push_text_run_child(
1590        &mut self,
1591        parent_node: NodeId,
1592        element_id: u64,
1593        fragment_offset: usize,
1594        value: String,
1595        character_lengths: Vec<u8>,
1596        word_starts: Option<Vec<u8>>,
1597        character_positions: Option<Vec<f32>>,
1598        character_widths: Option<Vec<f32>>,
1599        attrs: TextRunAttributes,
1600    ) -> NodeId {
1601        let Some(owner) = self.owner else {
1602            debug_assert!(
1603                false,
1604                "push_text_run_child called on a builder with no owner — \
1605                 widgets must only call this from Widget::accessibility"
1606            );
1607            return NodeId(0);
1608        };
1609        // Give sub-runs of one source element (a highlight split, or a run
1610        // chunked to stay under the AccessKit word-start cap) distinct NodeIds.
1611        // A plain `element_id ^ (fragment_offset << 32)` would XOR the offset
1612        // into the very bits `element_id` already uses to encode the owning
1613        // block, so a chunk at offset 255 in block A could alias a whole-line run
1614        // in a block whose id is `A ^ 255`. Hashing the offset across all 64 bits
1615        // removes that structure; `fragment_offset == 0` (the whole-run common
1616        // case) stays a no-op, so those NodeIds are unchanged.
1617        let mixed_element = if fragment_offset == 0 {
1618            element_id
1619        } else {
1620            fnv_mix_u64(element_id, fragment_offset as u64, 0)
1621        };
1622        let node_id = synthetic_node_id(owner, mixed_element, SyntheticKind::TextRun);
1623        let mut node = Node::new(Role::TextRun);
1624        node.set_value(value);
1625        node.set_character_lengths(character_lengths);
1626        if let Some(ws) = word_starts {
1627            node.set_word_starts(ws);
1628        }
1629        if let Some(pos) = character_positions {
1630            node.set_character_positions(pos);
1631        }
1632        if let Some(widths) = character_widths {
1633            node.set_character_widths(widths);
1634        }
1635        // Text attributes (WCAG 1.3.1 / EN 301 549 11.5.2.9). AccessKit has no
1636        // bold flag, so an explicit weight wins, else bold => 700.
1637        if let Some(w) = attrs.font_weight {
1638            node.set_font_weight(w as f32);
1639        } else if attrs.bold {
1640            node.set_font_weight(700.0);
1641        }
1642        if attrs.italic {
1643            node.set_italic();
1644        }
1645        if attrs.underline {
1646            node.set_underline(default_text_decoration());
1647        }
1648        if attrs.strikethrough {
1649            node.set_strikethrough(default_text_decoration());
1650        }
1651        self.children_collected.push((node_id, node));
1652        // Attach the text-run to its parent paragraph's child list.
1653        // The parent must already be in `children_collected`.
1654        for (id, parent) in self.children_collected.iter_mut() {
1655            if *id == parent_node {
1656                parent.push_child(node_id);
1657                return node_id;
1658            }
1659        }
1660        // Parent not found — push as a direct child of the widget's
1661        // own node as a fallback. Caller misused the API.
1662        self.inner.push_child(node_id);
1663        node_id
1664    }
1665
1666    /// Push a single `Role::TextRun` child attached **directly** to the
1667    /// widget's own node (no intervening `Role::Paragraph`). This is the
1668    /// single-line text-input shape: `Role::TextInput` → one
1669    /// `Role::TextRun`.
1670    ///
1671    /// Required for screen-reader typing echo. accesskit_consumer's
1672    /// `supports_text_ranges()` returns `false` for a text input that
1673    /// only sets `character_lengths` on its *own* node — it needs a
1674    /// `Role::TextRun` child. Without it the macOS adapter never emits
1675    /// `AXSelectedTextChanged`, so VoiceOver reads the value once on
1676    /// focus but never echoes characters/words while typing. Emit this
1677    /// even when `value` / `character_lengths` are empty so
1678    /// `supports_text_ranges()` is already true before the first
1679    /// keystroke (the change-diff's *old* node must also support ranges
1680    /// for the notification to fire). Target the caret/selection at the
1681    /// returned `NodeId` via [`set_text_selection_to`](Self::set_text_selection_to).
1682    pub fn push_text_run_child_on_self(
1683        &mut self,
1684        element_id: u64,
1685        value: String,
1686        character_lengths: Vec<u8>,
1687        word_starts: Option<Vec<u8>>,
1688    ) -> NodeId {
1689        let Some(owner) = self.owner else {
1690            debug_assert!(
1691                false,
1692                "push_text_run_child_on_self called on a builder with no owner — \
1693                 widgets must only call this from Widget::accessibility"
1694            );
1695            return NodeId(0);
1696        };
1697        let node_id = synthetic_node_id(owner, element_id, SyntheticKind::TextRun);
1698        let mut node = Node::new(Role::TextRun);
1699        node.set_value(value);
1700        node.set_character_lengths(character_lengths);
1701        if let Some(ws) = word_starts {
1702            node.set_word_starts(ws);
1703        }
1704        self.children_collected.push((node_id, node));
1705        self.inner.push_child(node_id);
1706        node_id
1707    }
1708
1709    /// Declare a text selection that references TextRun children
1710    /// previously emitted via `push_text_run_child`. Both the
1711    /// anchor and the focus are expressed as
1712    /// `(NodeId, character_index)` pairs where the character index
1713    /// is an index into the target TextRun's `character_lengths`
1714    /// (NOT a document-absolute offset — per AccessKit's contract).
1715    pub fn set_text_selection_to(&mut self, anchor: (NodeId, usize), focus: (NodeId, usize)) {
1716        self.pending_explicit_selection = Some((
1717            TextPosition {
1718                node: anchor.0,
1719                character_index: anchor.1,
1720            },
1721            TextPosition {
1722                node: focus.0,
1723                character_index: focus.1,
1724            },
1725        ));
1726    }
1727}
1728
1729impl Default for AccessNodeBuilder {
1730    fn default() -> Self {
1731        Self::new()
1732    }
1733}
1734
1735/// Convert a WidgetId to an AccessKit NodeId.
1736pub fn widget_id_to_node_id(id: WidgetId) -> NodeId {
1737    use slotmap::Key;
1738    let key_data = id.data();
1739    let raw = key_data.as_ffi();
1740    NodeId(raw)
1741}
1742
1743/// Convert an AccessKit NodeId back to a WidgetId. Returns `None`
1744/// for synthetic NodeIds (widget-emitted child nodes like TextRuns);
1745/// callers that need to route an `ActionRequest` targeting a
1746/// synthetic NodeId must consult `WidgetTree::synthetic_parent_map`
1747/// to find the owning widget.
1748pub fn node_id_to_widget_id_maybe(node_id: NodeId) -> Option<WidgetId> {
1749    if is_synthetic(node_id) {
1750        return None;
1751    }
1752    use slotmap::KeyData;
1753    let key_data = KeyData::from_ffi(node_id.0);
1754    Some(key_data.into())
1755}
1756
1757/// Legacy infallible converter kept for existing call sites that
1758/// never encounter synthetic NodeIds. New code should prefer
1759/// [`node_id_to_widget_id_maybe`]. Panics in debug for synthetic
1760/// ids to catch misrouted calls early.
1761pub fn node_id_to_widget_id(node_id: NodeId) -> WidgetId {
1762    debug_assert!(
1763        !is_synthetic(node_id),
1764        "node_id_to_widget_id called on synthetic NodeId — use node_id_to_widget_id_maybe"
1765    );
1766    use slotmap::KeyData;
1767    let key_data = KeyData::from_ffi(node_id.0);
1768    key_data.into()
1769}
1770
1771/// The special root node ID for the accessibility tree.
1772pub fn root_node_id() -> NodeId {
1773    NodeId(0)
1774}
1775
1776/// Query result for accessibility information about a widget.
1777#[derive(Debug)]
1778pub struct AccessibilityInfo {
1779    role: Role,
1780    name: Option<String>,
1781    actions: Vec<Action>,
1782    toggled: Option<bool>,
1783    expanded: Option<bool>,
1784    selected: Option<bool>,
1785    disabled: bool,
1786    hidden: bool,
1787}
1788
1789impl AccessibilityInfo {
1790    pub fn new(role: Role, name: Option<String>, actions: Vec<Action>) -> Self {
1791        Self {
1792            role,
1793            name,
1794            actions,
1795            toggled: None,
1796            expanded: None,
1797            selected: None,
1798            disabled: false,
1799            hidden: false,
1800        }
1801    }
1802
1803    pub fn with_toggled(mut self, toggled: bool) -> Self {
1804        self.toggled = Some(toggled);
1805        self
1806    }
1807
1808    pub fn with_expanded(mut self, expanded: bool) -> Self {
1809        self.expanded = Some(expanded);
1810        self
1811    }
1812
1813    pub fn with_selected(mut self, selected: bool) -> Self {
1814        self.selected = Some(selected);
1815        self
1816    }
1817
1818    pub fn with_disabled(mut self, disabled: bool) -> Self {
1819        self.disabled = disabled;
1820        self
1821    }
1822
1823    pub fn with_hidden(mut self, hidden: bool) -> Self {
1824        self.hidden = hidden;
1825        self
1826    }
1827
1828    pub fn role(&self) -> Role {
1829        self.role
1830    }
1831
1832    pub fn name(&self) -> Option<&str> {
1833        self.name.as_deref()
1834    }
1835
1836    pub fn actions(&self) -> &[Action] {
1837        &self.actions
1838    }
1839
1840    pub fn is_toggled(&self) -> bool {
1841        self.toggled.unwrap_or(false)
1842    }
1843
1844    pub fn is_expanded(&self) -> bool {
1845        self.expanded.unwrap_or(false)
1846    }
1847
1848    pub fn is_selected(&self) -> bool {
1849        self.selected.unwrap_or(false)
1850    }
1851
1852    pub fn is_disabled(&self) -> bool {
1853        self.disabled
1854    }
1855
1856    pub fn is_hidden(&self) -> bool {
1857        self.hidden
1858    }
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863    use super::*;
1864
1865    fn fake_widget(id: u64) -> WidgetId {
1866        slotmap::KeyData::from_ffi(id).into()
1867    }
1868
1869    #[test]
1870    fn widget_derived_node_id_has_bit_63_clear() {
1871        // A freshly-minted slotmap key (version 1, index 0) encodes
1872        // to a u64 with bit 63 clear. The top-bit namespace split
1873        // (synthetic NodeIds set bit 63, widget-derived NodeIds clear
1874        // it) only works if widget-derived NodeIds stay below bit 63.
1875        let wid = fake_widget(1);
1876        let nid = widget_id_to_node_id(wid);
1877        assert_eq!(
1878            nid.0 & SYNTHETIC_BIT,
1879            0,
1880            "widget NodeId must have bit 63 clear"
1881        );
1882        assert!(!is_synthetic(nid));
1883    }
1884
1885    #[test]
1886    fn synthetic_node_id_has_bit_63_set() {
1887        let wid = fake_widget(42);
1888        let nid = synthetic_node_id(wid, 17, SyntheticKind::TextRun);
1889        assert_eq!(nid.0 & SYNTHETIC_BIT, SYNTHETIC_BIT);
1890        assert!(is_synthetic(nid));
1891    }
1892
1893    #[test]
1894    fn link_runs_on_line_chains_runs_both_ways() {
1895        let mut b = AccessNodeBuilder::for_widget(fake_widget(1));
1896        let para = b.push_paragraph_child(1);
1897        let run = |b: &mut AccessNodeBuilder, off: usize| {
1898            b.push_text_run_child(
1899                para,
1900                10,
1901                off,
1902                "abc".to_string(),
1903                vec![1, 1, 1],
1904                None,
1905                None,
1906                None,
1907                TextRunAttributes::default(),
1908            )
1909        };
1910        let (r0, r1, r2) = (run(&mut b, 0), run(&mut b, 3), run(&mut b, 6));
1911        b.link_runs_on_line(&[r0, r1, r2]);
1912
1913        let (_id, _n, children, _local) = b.build(fake_widget(1));
1914        let node = |id| {
1915            children
1916                .iter()
1917                .find(|(i, _)| *i == id)
1918                .map(|(_, n)| n)
1919                .unwrap()
1920        };
1921        // First run: forward only. Middle: both. Last: back only.
1922        assert_eq!(node(r0).previous_on_line(), None);
1923        assert_eq!(node(r0).next_on_line(), Some(r1));
1924        assert_eq!(node(r1).previous_on_line(), Some(r0));
1925        assert_eq!(node(r1).next_on_line(), Some(r2));
1926        assert_eq!(node(r2).previous_on_line(), Some(r1));
1927        assert_eq!(node(r2).next_on_line(), None);
1928    }
1929
1930    /// A chunk at a non-zero offset in one element must not collide with a
1931    /// whole run in another element, even when the two element ids differ by
1932    /// exactly the low-byte XOR of the chunk offset — the aliasing the old
1933    /// `element_id ^ (offset << 32)` mix allowed.
1934    #[test]
1935    fn a_chunk_offset_does_not_alias_another_elements_run() {
1936        let mut b = AccessNodeBuilder::for_widget(fake_widget(1));
1937        let para = b.push_paragraph_child(1);
1938        // `synth_element_id` encodes the block id in bits 32-61.
1939        let elem_a: u64 = 0xABCD_u64 << 32;
1940        let elem_b: u64 = (0xABCD_u64 ^ 255) << 32;
1941        let run = |b: &mut AccessNodeBuilder, elem: u64, off: usize| {
1942            b.push_text_run_child(
1943                para,
1944                elem,
1945                off,
1946                "x".to_string(),
1947                vec![1],
1948                None,
1949                None,
1950                None,
1951                TextRunAttributes::default(),
1952            )
1953        };
1954        let a_chunk = run(&mut b, elem_a, 255); // offset-255 chunk in block A
1955        let b_whole = run(&mut b, elem_b, 0); // whole run in block B = A ^ 255
1956        assert_ne!(
1957            a_chunk, b_whole,
1958            "a chunk offset must not alias another block's run NodeId"
1959        );
1960    }
1961
1962    #[test]
1963    fn set_child_position_in_set_numbers_a_paragraph() {
1964        let mut b = AccessNodeBuilder::for_widget(fake_widget(1));
1965        let para = b.push_paragraph_child(5);
1966        assert!(b.set_child_position_in_set(para, 42, 200));
1967        assert!(
1968            !b.set_child_position_in_set(NodeId(999), 1, 1),
1969            "an unknown child is not found"
1970        );
1971
1972        let (_id, own, children, _local) = b.build(fake_widget(1));
1973        let p = children
1974            .iter()
1975            .find(|(i, _)| *i == para)
1976            .map(|(_, n)| n)
1977            .unwrap();
1978        // The caller says "line 42", the ARIA convention. AccessKit stores the
1979        // zero-based 41, and the Windows and AT-SPI adapters add the 1 back.
1980        assert_eq!(p.position_in_set(), Some(41));
1981        // The set size belongs on the container, which here is the widget's own
1982        // node: `size_of_set_from_container` walks up from the item's parent,
1983        // so a size written on the paragraph itself is read by no adapter.
1984        assert_eq!(p.size_of_set(), None);
1985        assert_eq!(own.size_of_set(), Some(200));
1986    }
1987
1988    /// The whole point of the 1-based public surface: a caller writes the
1989    /// number a person would say, and AccessKit gets the number it expects.
1990    #[test]
1991    fn every_aria_ordinal_is_converted_at_the_boundary() {
1992        let mut b = AccessNodeBuilder::for_widget(fake_widget(1));
1993        b.set_position_in_set(1);
1994        b.set_row_index(1);
1995        b.set_column_index(1);
1996        b.set_level(1);
1997        let (_id, n, _children, _local) = b.build(fake_widget(1));
1998        assert_eq!(n.position_in_set(), Some(0), "the first item is index 0");
1999        assert_eq!(n.row_index(), Some(0), "the header row is row 0");
2000        assert_eq!(n.column_index(), Some(0), "the leftmost column is column 0");
2001        assert_eq!(n.level(), Some(0), "a root item is level 0");
2002    }
2003
2004    /// A count has no base, so it must pass through untouched. Getting this
2005    /// wrong would turn "3 of 12" into "3 of 11".
2006    #[test]
2007    fn a_count_is_not_an_ordinal_and_is_not_converted() {
2008        let mut b = AccessNodeBuilder::for_widget(fake_widget(1));
2009        b.set_size_of_set(12);
2010        b.set_row_count(100);
2011        b.set_column_count(4);
2012        b.set_row_span(2);
2013        b.set_column_span(3);
2014        let (_id, n, _children, _local) = b.build(fake_widget(1));
2015        assert_eq!(n.size_of_set(), Some(12));
2016        assert_eq!(n.row_count(), Some(100));
2017        assert_eq!(n.column_count(), Some(4));
2018        assert_eq!(n.row_span(), Some(2));
2019        assert_eq!(n.column_span(), Some(3));
2020    }
2021
2022    /// A heading level is the same ARIA convention, and an H1 must be able to
2023    /// announce as level 1. Before the conversion the clamp floor of 1 made
2024    /// AccessKit level 0 unreachable, so no heading anywhere could.
2025    #[test]
2026    fn an_h1_is_accesskit_level_zero() {
2027        for (heading, expected) in [(1u8, 0usize), (2, 1), (6, 5)] {
2028            let mut b = AccessNodeBuilder::for_widget(fake_widget(1));
2029            let para = b.push_paragraph_child(7);
2030            assert!(b.set_paragraph_as_heading(para, heading));
2031            let (_id, _n, children, _local) = b.build(fake_widget(1));
2032            let p = children
2033                .iter()
2034                .find(|(i, _)| *i == para)
2035                .map(|(_, n)| n)
2036                .unwrap();
2037            assert_eq!(p.role(), Role::Heading);
2038            assert_eq!(p.level(), Some(expected), "h{heading}");
2039        }
2040    }
2041
2042    #[test]
2043    fn synthetic_node_id_stable_across_calls() {
2044        // Same (widget, element, kind) produces identical NodeIds —
2045        // this stability is required for screen-reader focus to survive
2046        // accessibility rebuilds.
2047        let wid = fake_widget(42);
2048        let a = synthetic_node_id(wid, 17, SyntheticKind::TextRun);
2049        let b = synthetic_node_id(wid, 17, SyntheticKind::TextRun);
2050        assert_eq!(a, b);
2051    }
2052
2053    #[test]
2054    fn synthetic_node_id_differs_by_kind() {
2055        let wid = fake_widget(42);
2056        let p = synthetic_node_id(wid, 17, SyntheticKind::Paragraph);
2057        let r = synthetic_node_id(wid, 17, SyntheticKind::TextRun);
2058        assert_ne!(
2059            p, r,
2060            "paragraph and text-run kinds must produce distinct NodeIds"
2061        );
2062    }
2063
2064    #[test]
2065    fn synthetic_node_id_differs_by_element() {
2066        let wid = fake_widget(42);
2067        let a = synthetic_node_id(wid, 1, SyntheticKind::TextRun);
2068        let b = synthetic_node_id(wid, 2, SyntheticKind::TextRun);
2069        assert_ne!(a, b);
2070    }
2071
2072    #[test]
2073    fn node_id_to_widget_id_maybe_returns_none_for_synthetic() {
2074        let wid = fake_widget(42);
2075        let syn = synthetic_node_id(wid, 17, SyntheticKind::TextRun);
2076        assert!(node_id_to_widget_id_maybe(syn).is_none());
2077    }
2078
2079    #[test]
2080    fn node_id_to_widget_id_maybe_round_trips_widget_ids() {
2081        let wid = fake_widget(99);
2082        let nid = widget_id_to_node_id(wid);
2083        let back = node_id_to_widget_id_maybe(nid).unwrap();
2084        assert_eq!(wid, back);
2085    }
2086
2087    #[test]
2088    fn push_paragraph_child_and_text_run_child_emit_synthetic_nodes() {
2089        let owner = fake_widget(7);
2090        let mut builder = AccessNodeBuilder::for_widget(owner);
2091        builder.set_role(Role::MultilineTextInput);
2092
2093        let para = builder.push_paragraph_child(100);
2094        let run = builder.push_text_run_child(
2095            para,
2096            200,
2097            0,
2098            "hello".to_string(),
2099            vec![1, 1, 1, 1, 1],
2100            Some(vec![0]),
2101            None,
2102            None,
2103            TextRunAttributes::default(),
2104        );
2105        assert!(is_synthetic(para));
2106        assert!(is_synthetic(run));
2107
2108        let (_nid, _node, children, _local) = builder.build(owner);
2109        // Two emitted children: paragraph + text run.
2110        assert_eq!(children.len(), 2);
2111        assert!(children.iter().any(|(id, _)| *id == para));
2112        assert!(children.iter().any(|(id, _)| *id == run));
2113    }
2114
2115    #[test]
2116    fn text_run_attributes_reach_at_node() {
2117        // Audit G7 / EN 301 549 11.5.2.9: bold / italic / underline /
2118        // strikethrough formatting on a run is exposed on its TextRun node.
2119        let owner = fake_widget(9);
2120        let mut builder = AccessNodeBuilder::for_widget(owner);
2121        builder.set_role(Role::MultilineTextInput);
2122        let para = builder.push_paragraph_child(1);
2123        let run = builder.push_text_run_child(
2124            para,
2125            2,
2126            0,
2127            "ab".to_string(),
2128            vec![1, 1],
2129            None,
2130            None,
2131            None,
2132            TextRunAttributes {
2133                bold: true,
2134                italic: true,
2135                underline: true,
2136                strikethrough: true,
2137                ..Default::default()
2138            },
2139        );
2140        let (_nid, _node, children, _local) = builder.build(owner);
2141        let (_, run_node) = children
2142            .iter()
2143            .find(|(id, _)| *id == run)
2144            .expect("run node");
2145        assert_eq!(
2146            run_node.font_weight(),
2147            Some(700.0),
2148            "bold folds to font weight 700"
2149        );
2150        assert!(run_node.is_italic(), "italic flag set");
2151        assert!(run_node.underline().is_some(), "underline decoration set");
2152        assert!(
2153            run_node.strikethrough().is_some(),
2154            "strikethrough decoration set"
2155        );
2156
2157        // An explicit numeric weight wins over the bold flag.
2158        let owner2 = fake_widget(10);
2159        let mut b2 = AccessNodeBuilder::for_widget(owner2);
2160        b2.set_role(Role::MultilineTextInput);
2161        let p2 = b2.push_paragraph_child(1);
2162        let r2 = b2.push_text_run_child(
2163            p2,
2164            2,
2165            0,
2166            "x".to_string(),
2167            vec![1],
2168            None,
2169            None,
2170            None,
2171            TextRunAttributes {
2172                bold: true,
2173                font_weight: Some(300),
2174                ..Default::default()
2175            },
2176        );
2177        let (_, _, kids2, _local) = b2.build(owner2);
2178        let (_, r2n) = kids2.iter().find(|(id, _)| *id == r2).expect("run2 node");
2179        assert_eq!(
2180            r2n.font_weight(),
2181            Some(300.0),
2182            "explicit weight wins over bold"
2183        );
2184    }
2185
2186    #[test]
2187    fn set_text_selection_to_wins_over_self_selection() {
2188        let owner = fake_widget(3);
2189        let mut builder = AccessNodeBuilder::for_widget(owner);
2190        builder.set_role(Role::MultilineTextInput);
2191        // Emit a paragraph + run so set_text_selection_to has a
2192        // real synthetic NodeId to target.
2193        let para = builder.push_paragraph_child(1);
2194        let run = builder.push_text_run_child(
2195            para,
2196            2,
2197            0,
2198            "ab".to_string(),
2199            vec![1, 1],
2200            None,
2201            None,
2202            None,
2203            TextRunAttributes::default(),
2204        );
2205        // Both a self-targeted AND an explicit selection are
2206        // staged — the explicit one must win.
2207        builder.set_text_selection_on_self(0, 0);
2208        builder.set_text_selection_to((run, 0), (run, 2));
2209        let (_nid, node, _children, _local) = builder.build(owner);
2210        let sel = node.text_selection().expect("text selection set");
2211        assert_eq!(sel.focus.node, run);
2212        assert_eq!(sel.focus.character_index, 2);
2213    }
2214}