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