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, TextPosition, TextSelection};
5
6use crate::widget_id::WidgetId;
7
8/// Builder wrapper around accesskit::Node for widget accessibility declarations.
9pub struct AccessNodeBuilder {
10    inner: Node,
11    name: Option<String>,
12    value: Option<String>,
13    role: Role,
14    actions: Vec<Action>,
15    toggled: Option<bool>,
16    expanded: Option<bool>,
17    selected: Option<bool>,
18    hidden: bool,
19    /// The owning widget's id. Set at construction time by the
20    /// tree walker (via `AccessNodeBuilder::for_widget`). Used by
21    /// the sub-tree API (`push_paragraph_child` / `push_text_run_child`)
22    /// to derive synthetic NodeIds without asking the caller to
23    /// pass the WidgetId at every call site.
24    owner: Option<WidgetId>,
25    /// Pending text selection targeting the widget's own node id.
26    /// Resolved at `build(id)` time because the widget doesn't know
27    /// its node id during `accessibility(&self, builder)`.
28    pending_self_selection: Option<(usize, usize)>,
29    /// Deferred text selection targeting synthetic child NodeIds
30    /// (TextRuns). Unlike `pending_self_selection`, these
31    /// TextPositions reference NodeIds that are already known at
32    /// the time the widget calls `set_text_selection_to`, so we
33    /// can populate the selection during `build(id)` directly —
34    /// the field just holds them until that point.
35    pending_explicit_selection: Option<(TextPosition, TextPosition)>,
36    /// Synthetic child nodes emitted by the widget via
37    /// `push_paragraph_child` / `push_text_run_child`. Drained by
38    /// the tree walker after `Widget::accessibility(&self, builder)`
39    /// returns and merged into the full AccessKit `TreeUpdate`.
40    children_collected: Vec<(NodeId, Node)>,
41}
42
43/// Discriminator kind for synthetic-NodeId hashing. Different
44/// kinds sharing the same (widget_id, element_id) tuple produce
45/// distinct NodeIds so paragraph and run nodes for the same
46/// source element don't collide.
47#[derive(Debug, Clone, Copy)]
48#[repr(u8)]
49pub enum SyntheticKind {
50    Paragraph = 1,
51    TextRun = 2,
52    ImageRun = 3,
53    /// An inline link span inside a label-style widget's text
54    /// (e.g. `[docs](url)` inside a TextWidget with `.markup(true)`
55    /// enabled). Each link span becomes one synthetic child of the
56    /// hosting widget's own node, with `Role::Link` and the link label
57    /// as its name.
58    Link = 4,
59    /// A lightweight `SceneItem` rendered by `teksilo_scene::SceneView`.
60    /// Items live outside the arena — `SceneView::accessibility`
61    /// emits one synthetic child per visible item using
62    /// `push_scene_child`.
63    SceneItem = 5,
64    /// A logical grouping declared via `Scene::add_a11y_group`.
65    /// Pure AT structure — no visual counterpart. The parent's
66    /// children list orders mixed `SceneItem` and `SceneGroup`
67    /// synthetic NodeIds however the app declared the logical tree.
68    SceneGroup = 6,
69    /// A magnetism anchor ("magnet") attached to a scene item. Emitted
70    /// as a synthetic child of the owning item's node by
71    /// `SceneView::accessibility` when magnetism is enabled, so the
72    /// anchor is screen-reader perceivable and can be the target of the
73    /// view's `active_descendant` during the keyboard connect flow.
74    SceneMagnet = 7,
75    /// A per-datum mark (bar / line point / pie slice) emitted by a
76    /// `teksilo-charts` widget's `accessibility()`.
77    ChartMark = 8,
78    /// An annotation body (a comment thread) attached to a run of text, emitted
79    /// by a rich-text widget alongside the `TextRun` that carries it. The run
80    /// points at this node through the `details` relation — AccessKit's
81    /// `aria-details` — which is what lets a screen reader say "has comment" and
82    /// let the user navigate in, rather than reciting the thread inline every
83    /// time the caret crosses the span.
84    Annotation = 9,
85    /// A positional mark in a margin lane — the strip beside a scroll area that
86    /// maps where things are in a document rather than picturing it.
87    ///
88    /// Emitted by the lane widget's `accessibility()`, one synthetic child per
89    /// mark, the same shape [`SyntheticKind::ChartMark`] uses per datum. Marks
90    /// get their own nodes, rather than riding the content they refer to, for
91    /// the reason `CodeGutter` gives for *not* doing so: line numbers are
92    /// uniform, dense and derivable from the text, so they belong on the
93    /// paragraph node as `position_in_set`; lane marks are sparse, heterogeneous
94    /// and not derivable from anything — "a comment from Marie at 62 percent" is
95    /// not in the paragraph, so there is no node to delegate to.
96    LaneMark = 10,
97}
98
99/// A captured live-region announcement — the text a screen reader would
100/// have spoken when a `Live::{Polite,Assertive}` node's value (or label)
101/// changed.
102///
103/// Teksilo has no OS accessibility layer in headless mode, and even with
104/// one there is no in-process way to observe what the platform *spoke*.
105/// [`crate::WidgetTree::sync_accessibility`] therefore diffs the live
106/// nodes of each freshly-built `TreeUpdate` and records the changes into
107/// a ring buffer that an automation / test harness drains via
108/// [`crate::WidgetTree::announcements_since`]. This is a faithful,
109/// in-process model of the live-region stream, not a replacement for an
110/// OS screen-reader smoke test.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct Announcement {
113    /// Monotonic sequence number, starting at 1. `announcements_since(n)`
114    /// returns every announcement whose `seq > n`.
115    pub seq: u64,
116    /// The announced text — the node's `value`, or its `label` when the
117    /// node carries no value.
118    pub text: String,
119    /// `true` for `Live::Assertive`, `false` for `Live::Polite`.
120    pub assertive: bool,
121}
122
123/// Top bit of the u64 NodeId encoding. Set for synthetic (widget-
124/// emitted child) NodeIds, clear for widget-derived NodeIds.
125/// Slotmap-derived WidgetIds never set bit 63 in practice because
126/// slotmap's KeyData encoding occupies bits 32-63 with a version
127/// counter that starts at 1.
128pub(crate) const SYNTHETIC_BIT: u64 = 1u64 << 63;
129
130/// Stable hash of (parent widget, element id, kind) producing a
131/// synthetic NodeId that survives edits for as long as the
132/// underlying element id is stable. Used by `AccessNodeBuilder`'s
133/// sub-tree API to allocate NodeIds for paragraph / text-run
134/// children without colliding with widget-derived NodeIds.
135pub fn synthetic_node_id(parent: WidgetId, element_id: u64, kind: SyntheticKind) -> NodeId {
136    use slotmap::Key;
137    let parent_raw = parent.data().as_ffi();
138    let h = fnv_mix_u64(parent_raw, element_id, kind as u64);
139    NodeId((h & !SYNTHETIC_BIT) | SYNTHETIC_BIT)
140}
141
142/// Whether a given `NodeId` is a synthetic child node (emitted by a
143/// widget via `push_paragraph_child` / `push_text_run_child`) rather
144/// than a widget-derived NodeId.
145pub fn is_synthetic(id: NodeId) -> bool {
146    id.0 & SYNTHETIC_BIT != 0
147}
148
149/// FNV-1a-inspired 64-bit mixer for three u64 inputs. Not a
150/// cryptographic hash — just a fast, deterministic, well-distributed
151/// mix for collision-free synthetic NodeIds across the
152/// (widget, element, kind) space.
153fn fnv_mix_u64(a: u64, b: u64, c: u64) -> u64 {
154    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
155    const FNV_PRIME: u64 = 0x100000001b3;
156    let mut h = FNV_OFFSET;
157    for byte in a
158        .to_le_bytes()
159        .iter()
160        .chain(b.to_le_bytes().iter())
161        .chain(c.to_le_bytes().iter())
162    {
163        h ^= *byte as u64;
164        h = h.wrapping_mul(FNV_PRIME);
165    }
166    h
167}
168
169/// Styling attributes surfaced to assistive technology on a synthetic
170/// `Role::TextRun` node (WCAG 1.3.1 Info and Relationships / EN 301 549
171/// 11.5.2.9 "text attributes"). All fields default to "unset"; a rich-text
172/// widget populates them per formatting run so a screen reader can convey
173/// bold / italic / underline / strikethrough spans. AccessKit has no
174/// dedicated `bold` property, so [`bold`](Self::bold) folds into
175/// `set_font_weight(700)` when no explicit [`font_weight`](Self::font_weight)
176/// is given.
177#[derive(Debug, Clone, Copy, Default)]
178pub struct TextRunAttributes {
179    /// Explicit numeric font weight (`100..=900`). Takes precedence over
180    /// [`bold`](Self::bold).
181    pub font_weight: Option<u16>,
182    /// Bold flag; folded to weight `700` when `font_weight` is `None`.
183    pub bold: bool,
184    pub italic: bool,
185    pub underline: bool,
186    pub strikethrough: bool,
187}
188
189/// The `TextDecoration` used for underline / strikethrough on a text run.
190/// Screen readers key off the *presence* of a decoration, not its colour,
191/// so a solid neutral (black) decoration is sufficient; the run's own
192/// foreground colour is not plumbed through this synthetic-node path.
193fn default_text_decoration() -> accesskit::TextDecoration {
194    accesskit::TextDecoration {
195        style: accesskit::TextDecorationStyle::Solid,
196        color: accesskit::Color {
197            red: 0,
198            green: 0,
199            blue: 0,
200            alpha: 255,
201        },
202    }
203}
204
205impl AccessNodeBuilder {
206    pub fn new() -> Self {
207        Self {
208            inner: Node::new(Role::Unknown),
209            name: None,
210            value: None,
211            role: Role::Unknown,
212            actions: Vec::new(),
213            toggled: None,
214            expanded: None,
215            selected: None,
216            hidden: false,
217            owner: None,
218            pending_self_selection: None,
219            pending_explicit_selection: None,
220            children_collected: Vec::new(),
221        }
222    }
223
224    /// Construct a builder with a known owner WidgetId. Used by the
225    /// tree walker when invoking `Widget::accessibility`; the owner
226    /// id drives synthetic NodeId derivation in the sub-tree API.
227    pub fn for_widget(owner: WidgetId) -> Self {
228        let mut b = Self::new();
229        b.owner = Some(owner);
230        b
231    }
232
233    pub fn set_role(&mut self, role: Role) {
234        self.role = role;
235        self.inner.set_role(role);
236    }
237
238    pub fn set_name(&mut self, name: impl Into<String>) {
239        let name: String = name.into();
240        self.inner.set_label(name.clone());
241        self.name = Some(name);
242    }
243
244    pub fn set_disabled(&mut self) {
245        self.inner.set_disabled();
246    }
247
248    /// Clear the disabled flag set by an earlier `set_disabled()` call. Used
249    /// by the override layer to un-set state a widget emitted unconditionally
250    /// (e.g. a Panel that always calls `set_hidden`/`set_disabled`).
251    pub fn clear_disabled(&mut self) {
252        self.inner.clear_disabled();
253    }
254
255    pub fn add_action(&mut self, action: Action) {
256        self.inner.add_action(action);
257        self.actions.push(action);
258    }
259
260    /// Remove a previously-advertised action. Used by the override layer
261    /// (`access_remove_action`) to suppress an action a widget emitted but
262    /// that doesn't apply in this composition.
263    pub fn remove_action(&mut self, action: Action) {
264        self.inner.remove_action(action);
265        self.actions.retain(|a| *a != action);
266    }
267
268    pub fn set_value(&mut self, value: impl Into<String>) {
269        let v: String = value.into();
270        self.inner.set_value(v.clone());
271        self.value = Some(v);
272    }
273
274    /// Advertise a color value on this node — typically paired with
275    /// [`accesskit::Role::ColorWell`]. Takes a `teksilo_tokens::Color` (f32
276    /// channels) and quantizes to AccessKit's 8-bit `Color` representation.
277    pub fn set_color_value(&mut self, color: teksilo_tokens::Color) {
278        let ak = accesskit::Color {
279            red: (color.r() * 255.0).round().clamp(0.0, 255.0) as u8,
280            green: (color.g() * 255.0).round().clamp(0.0, 255.0) as u8,
281            blue: (color.b() * 255.0).round().clamp(0.0, 255.0) as u8,
282            alpha: (color.a() * 255.0).round().clamp(0.0, 255.0) as u8,
283        };
284        self.inner.set_color_value(ak);
285    }
286
287    pub fn set_description(&mut self, description: impl Into<String>) {
288        self.inner.set_description(description.into());
289    }
290
291    pub fn set_live(&mut self, live: Live) {
292        self.inner.set_live(live);
293    }
294
295    pub fn set_described_by(&mut self, ids: impl Into<Vec<NodeId>>) {
296        self.inner.set_described_by(ids);
297    }
298
299    /// Append one node to the `described_by` relationship list. Mirror of
300    /// the existing `push_controlled`; used by the override layer's
301    /// `access_described_by` builder method and by the framework's
302    /// tooltip wiring.
303    pub fn push_described_by(&mut self, id: NodeId) {
304        self.inner.push_described_by(id);
305    }
306
307    /// Append one node to the `labelled_by` relationship list. Used by
308    /// `access_labelled_by` to point at an external label widget.
309    pub fn push_labelled_by(&mut self, id: NodeId) {
310        self.inner.push_labelled_by(id);
311    }
312
313    /// Replace the `details` relationship list — AccessKit's analogue of
314    /// `aria-details`.
315    ///
316    /// Distinct from `described_by`, and deliberately so: a *description* is text
317    /// a screen reader appends when announcing the element, while *details* points
318    /// at a structured node the user can navigate **into**. The W3C annotations
319    /// pattern is built on that difference — an annotated run carries
320    /// `aria-details` to a `role="comment"` node, so the reader can say "has
321    /// comment" and let the user go read it, rather than reciting a whole thread
322    /// inline every time the caret crosses the span.
323    pub fn set_details(&mut self, ids: impl Into<Vec<NodeId>>) {
324        self.inner.set_details(ids);
325    }
326
327    /// Append one node to the `details` relationship list.
328    ///
329    /// It is a list, not a single id, because overlapping annotations are normal:
330    /// one run of text can carry several comments, and each gets its own entry.
331    pub fn push_detail(&mut self, id: NodeId) {
332        self.inner.push_detail(id);
333    }
334
335    /// Stable author-supplied identifier (test/debug id, equivalent to
336    /// `aria-label`-style `data-testid`). Maps to `accesskit::Node::set_author_id`.
337    pub fn set_author_id(&mut self, id: impl Into<String>) {
338        self.inner.set_author_id(id.into());
339    }
340
341    /// Replace the node's custom-action list with `actions`. Used by the
342    /// override layer's `access_custom_action` builder method.
343    pub fn set_custom_actions(&mut self, actions: Vec<accesskit::CustomAction>) {
344        self.inner.set_custom_actions(actions);
345    }
346
347    pub fn set_toggled(&mut self, toggled: bool) {
348        self.toggled = Some(toggled);
349        self.inner.set_toggled(if toggled {
350            accesskit::Toggled::True
351        } else {
352            accesskit::Toggled::False
353        });
354    }
355
356    pub fn set_expanded(&mut self, expanded: bool) {
357        self.expanded = Some(expanded);
358        self.inner.set_expanded(expanded);
359    }
360
361    pub fn set_has_popup(&mut self, kind: accesskit::HasPopup) {
362        self.inner.set_has_popup(kind);
363    }
364
365    /// Placeholder text displayed when the widget has no user-entered value
366    /// yet. Screen readers treat this distinctly from `value` — they'll
367    /// announce the placeholder as hint text rather than as the current
368    /// value. Used by `ComboBox` when selection is `None`, by `TextInput`
369    /// before the user types, etc.
370    pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
371        self.inner.set_placeholder(placeholder.into());
372    }
373
374    /// Target URL for link-like widgets. Maps to `aria-url` / platform
375    /// link metadata so screen readers can announce the destination
376    /// (e.g. "link, `https://example.com`"). Informational only — does
377    /// not navigate when activated.
378    pub fn set_url(&mut self, url: impl Into<String>) {
379        self.inner.set_url(url.into());
380    }
381
382    /// Keyboard shortcut announcement (e.g. `"Ctrl+S"`). Maps to
383    /// `aria-keyshortcuts`. Used by menu items and buttons whose
384    /// chord is shown visually but must also be exposed to assistive
385    /// tech so shortcut users discover it.
386    pub fn set_keyboard_shortcut(&mut self, shortcut: impl Into<String>) {
387        self.inner.set_keyboard_shortcut(shortcut.into());
388    }
389
390    /// Autocomplete behavior for combobox / text input widgets. Maps to
391    /// ARIA `aria-autocomplete`: `Inline` completes within the field,
392    /// `List` shows a popup of matching values, `Both` does both.
393    pub fn set_auto_complete(&mut self, kind: accesskit::AutoComplete) {
394        self.inner.set_auto_complete(kind);
395    }
396
397    /// Selection state — used by `RadioButton`, `Tab`, `ListBoxOption`,
398    /// `TreeItem`, menu items in radio/check groups, etc. This is the
399    /// correct property for "this option in a mutually exclusive
400    /// group is the active one"; don't confuse with `set_toggled`,
401    /// which models checkbox/switch on-off state.
402    pub fn set_selected(&mut self, selected: bool) {
403        self.selected = Some(selected);
404        self.inner.set_selected(selected);
405    }
406
407    pub fn set_orientation(&mut self, orientation: accesskit::Orientation) {
408        self.inner.set_orientation(orientation);
409    }
410
411    /// 1-based index of this item in its parent set — maps to ARIA
412    /// `aria-posinset`. Pair with `set_size_of_set` on every item in
413    /// the set so AT can announce "tab 3 of 5", "row 12 of 200", etc.
414    /// Use on `Role::Tab`, `Role::ListBoxOption`, `Role::Row`,
415    /// `Role::MenuItem`, and similar collection items.
416    pub fn set_position_in_set(&mut self, position: usize) {
417        self.inner.set_position_in_set(position);
418    }
419
420    /// Total number of items in this item's parent set — maps to ARIA
421    /// `aria-setsize`. Set on every collection item alongside
422    /// `set_position_in_set`; the value should reflect the *logical*
423    /// set size, not the visible window (e.g. report 200 for a
424    /// virtualized 200-row list even when only 20 rows are realized).
425    pub fn set_size_of_set(&mut self, size: usize) {
426        self.inner.set_size_of_set(size);
427    }
428
429    // ── Grid / table semantics (`aria-rowcount` / `aria-colindex` / …) ──
430    //
431    // Typed wrappers over the corresponding `accesskit::Node` setters so
432    // grid/table widgets don't have to drop to `inner_mut()`. On a
433    // `Role::Grid` / `Role::Table` container set the *logical* row/column
434    // counts (not the realized window); on each cell set its 1-based
435    // row/column index.
436
437    /// Total logical row count on a grid/table container (`aria-rowcount`).
438    pub fn set_row_count(&mut self, count: usize) {
439        self.inner.set_row_count(count);
440    }
441
442    /// Total logical column count on a grid/table container (`aria-colcount`).
443    pub fn set_column_count(&mut self, count: usize) {
444        self.inner.set_column_count(count);
445    }
446
447    /// 1-based row index of a cell / row (`aria-rowindex`).
448    pub fn set_row_index(&mut self, index: usize) {
449        self.inner.set_row_index(index);
450    }
451
452    /// 1-based column index of a cell (`aria-colindex`).
453    pub fn set_column_index(&mut self, index: usize) {
454        self.inner.set_column_index(index);
455    }
456
457    /// Number of rows a cell spans (`aria-rowspan`).
458    pub fn set_row_span(&mut self, span: usize) {
459        self.inner.set_row_span(span);
460    }
461
462    /// Number of columns a cell spans (`aria-colspan`).
463    pub fn set_column_span(&mut self, span: usize) {
464        self.inner.set_column_span(span);
465    }
466
467    /// Whether the container allows multiple selected items
468    /// (`aria-multiselectable`). Set on the `Role::Grid` / `Role::ListBox`
469    /// container in multi-select mode.
470    pub fn set_multiselectable(&mut self, value: bool) {
471        if value {
472            self.inner.set_multiselectable();
473        } else {
474            self.inner.clear_multiselectable();
475        }
476    }
477
478    /// The currently-active descendant (`aria-activedescendant`) — the
479    /// roving-focus pattern where focus stays on a composite container and
480    /// this points at the focused child (e.g. the focused grid cell).
481    pub fn set_active_descendant(&mut self, id: NodeId) {
482        self.inner.set_active_descendant(id);
483    }
484
485    /// Flag the node as a modal dialog. Use on `Role::Dialog` /
486    /// `Role::AlertDialog` when input is blocked outside the dialog.
487    pub fn set_modal(&mut self) {
488        self.inner.set_modal();
489    }
490
491    /// Mark this node as the current item within its container
492    /// (e.g. the "current page" crumb inside a `Navigation`, the
493    /// current step in a wizard). Maps to ARIA `aria-current`.
494    pub fn set_aria_current(&mut self, current: accesskit::AriaCurrent) {
495        self.inner.set_aria_current(current);
496    }
497
498    /// Single-step delta for `Slider` / `SpinButton` — how much the
499    /// value changes per keyboard arrow or Action::Increment tick.
500    pub fn set_numeric_value_step(&mut self, step: f64) {
501        self.inner.set_numeric_value_step(step);
502    }
503
504    /// Page-step delta for `Slider` / `SpinButton` — how much the
505    /// value changes per PgUp/PgDown or coarse adjustment.
506    pub fn set_numeric_value_jump(&mut self, jump: f64) {
507        self.inner.set_numeric_value_jump(jump);
508    }
509
510    /// Append a controlled-node relationship — e.g. a `Tab` pointing
511    /// at its matching `TabPanel`, a `ComboBox` pointing at its
512    /// listbox popup. AccessKit / ARIA equivalent of `aria-controls`.
513    pub fn push_controlled(&mut self, id: NodeId) {
514        self.inner.push_controlled(id);
515    }
516
517    /// Declare this radio button's membership in a radio group.
518    /// Each `RadioButton` node should push every sibling in its
519    /// group (including itself); screen readers use this to
520    /// announce positional info like "2 of 3".
521    pub fn push_to_radio_group(&mut self, id: NodeId) {
522        self.inner.push_to_radio_group(id);
523    }
524
525    pub fn set_numeric_value(&mut self, value: f64) {
526        self.inner.set_numeric_value(value);
527    }
528
529    pub fn set_min_numeric_value(&mut self, value: f64) {
530        self.inner.set_min_numeric_value(value);
531    }
532
533    pub fn set_max_numeric_value(&mut self, value: f64) {
534        self.inner.set_max_numeric_value(value);
535    }
536
537    /// Hide this node from all assistive technologies (equivalent to
538    /// `aria-hidden="true"`). The node is still in the widget tree but
539    /// is invisible to screen readers and other ATs. Use for purely
540    /// decorative elements — e.g. scrollbars (AT scrolls via the
541    /// parent `ScrollView`'s scroll actions instead).
542    pub fn set_hidden(&mut self) {
543        self.hidden = true;
544        self.inner.set_hidden();
545    }
546
547    /// Clear the hidden flag set by an earlier `set_hidden()` call. Used
548    /// by the override layer to re-expose a widget that marked itself
549    /// presentational. AccessKit's Node `hidden` is local — un-hiding this
550    /// node does not propagate to descendants, but descendants are not
551    /// transitively hidden by their ancestor's `hidden` either.
552    pub fn clear_hidden(&mut self) {
553        self.hidden = false;
554        self.inner.clear_hidden();
555    }
556
557    pub fn is_hidden(&self) -> bool {
558        self.hidden
559    }
560
561    pub fn role(&self) -> Role {
562        self.role
563    }
564
565    pub fn name(&self) -> Option<&str> {
566        self.name.as_deref()
567    }
568
569    pub fn actions(&self) -> &[Action] {
570        &self.actions
571    }
572
573    pub fn value(&self) -> Option<&str> {
574        self.value.as_deref()
575    }
576
577    pub fn toggled(&self) -> Option<bool> {
578        self.toggled
579    }
580
581    pub fn expanded(&self) -> Option<bool> {
582        self.expanded
583    }
584
585    pub fn selected(&self) -> Option<bool> {
586        self.selected
587    }
588
589    /// Build the AccessKit Node with the given ID. Resolves any
590    /// `pending_self_selection` recorded via `set_caret_position_on_self`
591    /// or `set_text_selection_on_self` — at this point we know the
592    /// widget's NodeId and can inject it into the text selection.
593    /// Returns the primary `(NodeId, Node)` pair plus any synthetic
594    /// child nodes emitted by the widget via `push_paragraph_child`
595    /// / `push_text_run_child`. The tree walker is responsible for
596    /// merging these into the final `TreeUpdate`.
597    pub fn build(mut self, id: WidgetId) -> (NodeId, Node, Vec<(NodeId, Node)>) {
598        let node_id = widget_id_to_node_id(id);
599        // Priority: explicit (child-targeting) selection wins over
600        // self-targeting selection — widgets that emit TextRun
601        // children use the explicit path.
602        if let Some((anchor, focus)) = self.pending_explicit_selection.take() {
603            let selection = TextSelection { anchor, focus };
604            self.inner.set_text_selection(selection);
605        } else if let Some((anchor, focus)) = self.pending_self_selection.take() {
606            let selection = TextSelection {
607                anchor: TextPosition {
608                    node: node_id,
609                    character_index: anchor,
610                },
611                focus: TextPosition {
612                    node: node_id,
613                    character_index: focus,
614                },
615            };
616            self.inner.set_text_selection(selection);
617        }
618
619        // accesskit contract: a `Role::Label` node carries its text in the
620        // `value` property, NOT `label`. Every platform adapter reads it that
621        // way — Windows UIA derives the node's Name from `value` (its
622        // `label_comes_from_value()` returns true for `Role::Label`), macOS
623        // maps `Role::Label` to `NSAccessibilityStaticTextRole` whose content
624        // is exposed as AXValue, and `accesskit_consumer` reads `value` when
625        // another control is `labelled_by` this node. A name left in the
626        // `label` property is therefore silently dropped on Windows and stray
627        // on macOS. Widgets set the accessible name uniformly via `set_name`
628        // (-> the `label` property); re-serialize it to `value` here, the one
629        // place every emitted node is finalized — widget nodes (via the tree
630        // walker) and scene synthetic children (via `push_scene_child`, which
631        // also funnels through `build`). The builder's logical `name()` view
632        // is intentionally left untouched, so introspection / `find_by_label`
633        // continue to report the accessible name regardless of role. Reading
634        // the inner node directly keeps this robust against any label set
635        // outside `set_name`, and idempotent (a second pass finds no label).
636        if self.inner.role() == Role::Label
637            && let Some(label) = self.inner.label().map(|s| s.to_string())
638        {
639            if self.inner.value().is_none() {
640                self.inner.set_value(label);
641            }
642            self.inner.clear_label();
643        }
644
645        (node_id, self.inner, self.children_collected)
646    }
647
648    /// Get a reference to the inner node for advanced use.
649    pub fn inner_mut(&mut self) -> &mut Node {
650        &mut self.inner
651    }
652
653    /// The widget id this builder was constructed for, if any. Set
654    /// by [`AccessNodeBuilder::for_widget`]; used by the scene-tree
655    /// walker to derive synthetic `NodeId`s for items / groups outside
656    /// the closure form (`push_scene_child*`).
657    pub fn owner_id(&self) -> Option<crate::widget_id::WidgetId> {
658        self.owner
659    }
660
661    /// Run a mutator over a synthetic child node previously pushed
662    /// via `push_scene_child` (or its `_under` variant). Used by
663    /// the scene walker to apply cross-tree decorations (relations /
664    /// live regions / landmarks) after the initial hierarchy emit.
665    /// Returns `true` if the node was found.
666    ///
667    /// Cannot be used to mutate widget-derived NodeIds — those live
668    /// in the global TreeUpdate and are owned by other widgets.
669    pub fn with_collected_node<F: FnOnce(&mut Node)>(&mut self, node_id: NodeId, f: F) -> bool {
670        for (id, node) in self.children_collected.iter_mut() {
671            if *id == node_id {
672                f(node);
673                return true;
674            }
675        }
676        false
677    }
678
679    /// Mark this node as read-only. Used by `RichTextEditor::read_only` so
680    /// screen readers announce the widget as a document rather than a form
681    /// field.
682    pub fn set_read_only(&mut self) {
683        self.inner.set_read_only();
684    }
685
686    /// Declare the current text selection. `anchor` and `focus` are
687    /// character indices into the widget's flat text representation; pass
688    /// equal indices for a collapsed caret. Uses the same `NodeId` for
689    /// both positions (typical for single-node text widgets that expose
690    /// the document as one run, which is what the first milestone of
691    /// `RichTextEditor` does).
692    pub fn set_text_selection(&mut self, node_id: NodeId, anchor: usize, focus: usize) {
693        let selection = TextSelection {
694            anchor: TextPosition {
695                node: node_id,
696                character_index: anchor,
697            },
698            focus: TextPosition {
699                node: node_id,
700                character_index: focus,
701            },
702        };
703        self.inner.set_text_selection(selection);
704    }
705
706    /// Convenience for exposing a caret position as a collapsed selection.
707    pub fn set_caret_position(&mut self, node_id: NodeId, character_index: usize) {
708        self.set_text_selection(node_id, character_index, character_index);
709    }
710
711    /// Declare a text selection whose anchor and focus live on the
712    /// widget's own AccessKit node. The widget doesn't know its own
713    /// `NodeId` inside `accessibility(&self, builder)` — it's only
714    /// resolved when the tree walker calls `builder.build(widget_id)`.
715    /// This method stashes the character indices and defers the
716    /// `set_text_selection` call until `build()` knows the ID.
717    pub fn set_text_selection_on_self(&mut self, anchor: usize, focus: usize) {
718        self.pending_self_selection = Some((anchor, focus));
719    }
720
721    /// Convenience wrapper for a collapsed caret on the widget's own node.
722    pub fn set_caret_position_on_self(&mut self, character_index: usize) {
723        self.set_text_selection_on_self(character_index, character_index);
724    }
725
726    // ── Sub-tree API: multi-node widgets (rich text, etc.) ─────────────
727
728    /// Push a `Role::Paragraph` child on the current node and return
729    /// its `NodeId`. The NodeId is synthetic (bit 63 set) and
730    /// deterministic given the owning widget + `element_id`.
731    ///
732    /// The owning `WidgetId` comes from the builder's `owner`
733    /// field, set by `AccessNodeBuilder::for_widget`. Returns
734    /// `NodeId(0)` (a no-op placeholder) if the builder has no
735    /// owner, which can only happen when a widget constructs a
736    /// builder manually via `new()` instead of going through the
737    /// tree walker. That's a programming error worth catching in
738    /// debug.
739    pub fn push_paragraph_child(&mut self, element_id: u64) -> NodeId {
740        let Some(owner) = self.owner else {
741            debug_assert!(
742                false,
743                "push_paragraph_child called on a builder with no owner — \
744                 widgets must only call this from Widget::accessibility"
745            );
746            return NodeId(0);
747        };
748        let node_id = synthetic_node_id(owner, element_id, SyntheticKind::Paragraph);
749        let node = Node::new(Role::Paragraph);
750        self.children_collected.push((node_id, node));
751        self.inner.push_child(node_id);
752        node_id
753    }
754
755    /// Push a `Role::Comment` child carrying an annotation's text, and return its
756    /// `NodeId` so the annotated run can point at it via [`Self::push_detail`].
757    ///
758    /// `group_id` must be the annotation's own durable identity (a comment's uid,
759    /// never a store id), so the node keeps the same `NodeId` across rebuilds and
760    /// a screen reader's cursor is not thrown out of the thread by an unrelated
761    /// edit elsewhere in the document.
762    ///
763    /// Per the W3C annotations pattern the *body* carries the name; the annotated
764    /// span itself must NOT be given an accessible name (`role="mark"` forbids it)
765    /// — naming the span would make the reader announce the comment's text in
766    /// place of the prose.
767    pub fn push_annotation_child(&mut self, group_id: u64, text: impl Into<String>) -> NodeId {
768        let Some(owner) = self.owner else {
769            debug_assert!(
770                false,
771                "push_annotation_child called on a builder with no owner — \
772                 widgets must only call this from Widget::accessibility"
773            );
774            return NodeId(0);
775        };
776        let node_id = synthetic_node_id(owner, group_id, SyntheticKind::Annotation);
777        let mut node = Node::new(Role::Comment);
778        node.set_value(text.into());
779        self.children_collected.push((node_id, node));
780        self.inner.push_child(node_id);
781        node_id
782    }
783
784    /// Add a `details` target to an already-pushed **child** node.
785    ///
786    /// The sub-tree API builds children eagerly into `children_collected`, so a
787    /// relation between two synthetic siblings (a `TextRun` and its annotation
788    /// body) cannot go through the current node's own setters — it has to reach
789    /// back into the collected child. A no-op if `child` was never pushed, which
790    /// keeps a caller that emitted spans for a run it then skipped from panicking.
791    pub fn push_detail_on_child(&mut self, child: NodeId, detail: NodeId) {
792        if let Some((_, node)) = self
793            .children_collected
794            .iter_mut()
795            .find(|(id, _)| *id == child)
796        {
797            node.push_detail(detail);
798        }
799    }
800
801    /// Push a `Role::Link` child on the current node. Used by label
802    /// widgets (e.g. `TextWidget` with `.markup(true)` enabled) to
803    /// expose inline `[label](url)` links as individual accessible
804    /// nodes alongside the parent's own text.
805    ///
806    /// `element_id` should be a stable identifier for the link inside
807    /// the parent widget (typically the byte offset of the `[` in the
808    /// original markup source, so the NodeId survives identical
809    /// re-layouts).
810    ///
811    /// The returned `NodeId` is synthetic (bit 63 set) and deterministic
812    /// given `(owner, element_id)`.
813    pub fn push_link_child(
814        &mut self,
815        element_id: u64,
816        label: impl Into<String>,
817        url: impl Into<String>,
818    ) -> NodeId {
819        let Some(owner) = self.owner else {
820            debug_assert!(
821                false,
822                "push_link_child called on a builder with no owner — \
823                 widgets must only call this from Widget::accessibility"
824            );
825            return NodeId(0);
826        };
827        let node_id = synthetic_node_id(owner, element_id, SyntheticKind::Link);
828        let mut node = Node::new(Role::Link);
829        let label: String = label.into();
830        if !label.is_empty() {
831            node.set_label(label);
832        }
833        // AccessKit exposes the link target through the `Value` property
834        // on a `Role::Link` node — same convention the standalone
835        // `Link` widget uses via `set_value(...)`.
836        node.set_value(url.into());
837        self.children_collected.push((node_id, node));
838        self.inner.push_child(node_id);
839        node_id
840    }
841
842    /// Push a synthetic child node representing a lightweight
843    /// `SceneItem` (or `SceneGroup`) emitted by `teksilo_scene::SceneView`.
844    /// The caller customizes a sub-`AccessNodeBuilder` (mirroring the
845    /// `Widget::accessibility` shape) and gets back the
846    /// deterministic synthetic `NodeId` allocated for the
847    /// `(owner, element_id, kind)` tuple.
848    ///
849    /// `kind` must be [`SyntheticKind::SceneItem`] or
850    /// [`SyntheticKind::SceneGroup`]; passing any other variant
851    /// panics in debug.
852    ///
853    /// Any further synthetic children the closure pushes (a
854    /// `SceneGroup` containing nested `SceneItem`s) are forwarded into the parent's
855    /// `children_collected` and re-parented under the
856    /// just-pushed node via the closure's own `inner.push_child`
857    /// calls — same convention as `push_paragraph_child` →
858    /// `push_text_run_child`.
859    pub fn push_scene_child(
860        &mut self,
861        element_id: u64,
862        kind: SyntheticKind,
863        customize: impl FnOnce(&mut AccessNodeBuilder),
864    ) -> NodeId {
865        debug_assert!(
866            matches!(
867                kind,
868                SyntheticKind::SceneItem
869                    | SyntheticKind::SceneGroup
870                    | SyntheticKind::SceneMagnet
871                    | SyntheticKind::ChartMark
872                    | SyntheticKind::LaneMark
873            ),
874            "push_scene_child requires SyntheticKind::SceneItem, ::SceneGroup, ::SceneMagnet, ::ChartMark, or ::LaneMark"
875        );
876        let Some(owner) = self.owner else {
877            debug_assert!(
878                false,
879                "push_scene_child called on a builder with no owner — \
880                 widgets must only call this from Widget::accessibility"
881            );
882            return NodeId(0);
883        };
884        let node_id = synthetic_node_id(owner, element_id, kind);
885        // Build the child against a fresh sub-builder so the item
886        // sees the same `&mut AccessNodeBuilder` shape as widgets.
887        // Owner-id is the SceneView's so further `push_scene_child`
888        // calls inside the customize closure (a `SceneGroup`
889        // emitting nested items) hash off the same owner.
890        let mut child_builder = AccessNodeBuilder::for_widget(owner);
891        customize(&mut child_builder);
892        // `build(owner)` re-derives a widget-keyed NodeId we throw
893        // away — we use our synthetic `node_id` instead. The
894        // returned `Node` carries the role / label / bounds / etc
895        // the customize closure populated; any *grand*children the
896        // closure pushed via further `push_scene_child` calls come
897        // back in the third tuple field and we forward them so the
898        // main TreeUpdate sees the full subtree.
899        let (_unused, node, grand_children) = child_builder.build(owner);
900        self.children_collected.push((node_id, node));
901        for (gid, gnode) in grand_children {
902            self.children_collected.push((gid, gnode));
903        }
904        self.inner.push_child(node_id);
905        node_id
906    }
907
908    /// Append an existing synthetic node id as a child of a
909    /// previously-pushed `SceneGroup` (or `SceneItem`) child. Used by
910    /// the scene logical-tree walker to re-parent items under their
911    /// declared logical group rather than as direct children of the
912    /// SceneView.
913    ///
914    /// Returns `true` if the parent was found (and the child was
915    /// attached), `false` if the parent isn't in
916    /// `children_collected` — the caller misordered the pushes.
917    pub fn attach_scene_child_under(&mut self, parent: NodeId, child: NodeId) -> bool {
918        for (id, node) in self.children_collected.iter_mut() {
919            if *id == parent {
920                node.push_child(child);
921                return true;
922            }
923        }
924        false
925    }
926
927    /// Like `push_scene_child` but lets the caller pick the
928    /// parent. `parent = None` attaches to the widget's own node
929    /// (same behavior as `push_scene_child`); `parent = Some(...)`
930    /// attaches to the previously-pushed scene-child with that id.
931    /// The scene logical-tree walker uses this to nest scene items
932    /// under declared `A11yGroup` parents.
933    ///
934    /// Returns the deterministic synthetic `NodeId` for the new
935    /// child. If `parent` was `Some` but the parent wasn't found
936    /// in `children_collected`, the child still gets created and
937    /// recorded but ends up attached to the widget's own node as a
938    /// fallback (and a debug-assert fires).
939    pub fn push_scene_child_under(
940        &mut self,
941        parent: Option<NodeId>,
942        element_id: u64,
943        kind: SyntheticKind,
944        customize: impl FnOnce(&mut AccessNodeBuilder),
945    ) -> NodeId {
946        debug_assert!(
947            matches!(
948                kind,
949                SyntheticKind::SceneItem
950                    | SyntheticKind::SceneGroup
951                    | SyntheticKind::SceneMagnet
952                    | SyntheticKind::ChartMark
953                    | SyntheticKind::LaneMark
954            ),
955            "push_scene_child_under requires SyntheticKind::SceneItem, ::SceneGroup, ::SceneMagnet, ::ChartMark, or ::LaneMark"
956        );
957        let Some(owner) = self.owner else {
958            debug_assert!(
959                false,
960                "push_scene_child_under called on a builder with no owner — \
961                 widgets must only call this from Widget::accessibility"
962            );
963            return NodeId(0);
964        };
965        let node_id = synthetic_node_id(owner, element_id, kind);
966        let mut child_builder = AccessNodeBuilder::for_widget(owner);
967        customize(&mut child_builder);
968        let (_unused, node, grand_children) = child_builder.build(owner);
969        self.children_collected.push((node_id, node));
970        for (gid, gnode) in grand_children {
971            self.children_collected.push((gid, gnode));
972        }
973        match parent {
974            Some(parent_id) => {
975                let attached = self.attach_scene_child_under(parent_id, node_id);
976                if !attached {
977                    debug_assert!(
978                        false,
979                        "push_scene_child_under: parent {:?} not in children_collected — \
980                         caller must push the parent before its children",
981                        parent_id
982                    );
983                    self.inner.push_child(node_id);
984                }
985            }
986            None => {
987                self.inner.push_child(node_id);
988            }
989        }
990        node_id
991    }
992
993    /// Override a previously-pushed paragraph child's role to
994    /// `Role::Heading` with the given hierarchical level. Used by
995    /// the rich text editor when a block carries a
996    /// `BlockFormat::heading_level`. Returns `true` if the node was
997    /// found and updated, `false` otherwise (caller misused the
998    /// api — the paragraph must have been pushed earlier).
999    pub fn set_paragraph_as_heading(&mut self, node_id: NodeId, level: u8) -> bool {
1000        for (id, node) in self.children_collected.iter_mut() {
1001            if *id == node_id {
1002                node.set_role(Role::Heading);
1003                // AccessKit's `set_level` takes a usize (via the
1004                // usize_property_methods macro). Clamp to 1..=6 for
1005                // conventional heading semantics.
1006                let level: usize = (level as usize).clamp(1, 6);
1007                node.set_level(level);
1008                return true;
1009            }
1010        }
1011        false
1012    }
1013
1014    /// Set 1-based position-in-set / size-of-set on a previously-pushed
1015    /// synthetic child (a paragraph, "line 42 of 200").
1016    ///
1017    /// AccessKit exposes `position_in_set` / `size_of_set` on every node, but
1018    /// [`set_position_in_set`](Self::set_position_in_set) /
1019    /// [`set_size_of_set`](Self::set_size_of_set) only touch the widget's own
1020    /// node. This reaches a collected child by NodeId, the same way
1021    /// [`set_paragraph_as_heading`](Self::set_paragraph_as_heading) does.
1022    /// Returns whether the child was found.
1023    pub fn set_child_position_in_set(
1024        &mut self,
1025        node_id: NodeId,
1026        position: usize,
1027        size: usize,
1028    ) -> bool {
1029        self.with_collected_node(node_id, |node| {
1030            node.set_position_in_set(position);
1031            node.set_size_of_set(size);
1032        })
1033    }
1034
1035    /// Link a run of `Role::TextRun` children as one visual line, so assistive
1036    /// technology navigating by line treats them as a continuous line rather
1037    /// than fracturing at each formatting or chunk boundary.
1038    ///
1039    /// Sets each run's `next_on_line` to its successor and each successor's
1040    /// `previous_on_line` to its predecessor (AccessKit's doubly-linked
1041    /// same-line chain); the first run keeps no `previous_on_line` and the last
1042    /// no `next_on_line`, which is how the consumer detects the line's ends. A
1043    /// slice of zero or one is a no-op. Every id must be a run pushed earlier via
1044    /// [`push_text_run_child`](Self::push_text_run_child).
1045    pub fn link_runs_on_line(&mut self, run_ids: &[NodeId]) {
1046        for pair in run_ids.windows(2) {
1047            let (a, b) = (pair[0], pair[1]);
1048            self.with_collected_node(a, |node| node.set_next_on_line(b));
1049            self.with_collected_node(b, |node| node.set_previous_on_line(a));
1050        }
1051    }
1052
1053    /// Push a `Role::TextRun` child under `parent_node` (usually a
1054    /// paragraph NodeId returned from `push_paragraph_child`, but
1055    /// may also be the widget's own node for inline editors).
1056    ///
1057    /// `element_id` is the stable id of the underlying text-document
1058    /// inline element; combined with `parent_widget` and a
1059    /// disambiguator it produces a synthetic NodeId that survives
1060    /// edits. `fragment_offset` is the block-relative character
1061    /// offset of this run — used as the disambiguator so two
1062    /// highlight-split sub-runs sharing one source element don't
1063    /// collide.
1064    ///
1065    /// `character_lengths` must be the UTF-8 byte length of each
1066    /// character in `value`, per AccessKit's contract. Optional
1067    /// `word_starts`, `character_positions`, and `character_widths`
1068    /// populate the corresponding AccessKit properties.
1069    ///
1070    /// Returns the allocated synthetic `NodeId` so the caller can
1071    /// reference it later when attaching a `TextSelection` via
1072    /// `set_text_selection_to`.
1073    #[allow(clippy::too_many_arguments)]
1074    pub fn push_text_run_child(
1075        &mut self,
1076        parent_node: NodeId,
1077        element_id: u64,
1078        fragment_offset: usize,
1079        value: String,
1080        character_lengths: Vec<u8>,
1081        word_starts: Option<Vec<u8>>,
1082        character_positions: Option<Vec<f32>>,
1083        character_widths: Option<Vec<f32>>,
1084        attrs: TextRunAttributes,
1085    ) -> NodeId {
1086        let Some(owner) = self.owner else {
1087            debug_assert!(
1088                false,
1089                "push_text_run_child called on a builder with no owner — \
1090                 widgets must only call this from Widget::accessibility"
1091            );
1092            return NodeId(0);
1093        };
1094        // Give sub-runs of one source element (a highlight split, or a run
1095        // chunked to stay under the AccessKit word-start cap) distinct NodeIds.
1096        // A plain `element_id ^ (fragment_offset << 32)` would XOR the offset
1097        // into the very bits `element_id` already uses to encode the owning
1098        // block, so a chunk at offset 255 in block A could alias a whole-line run
1099        // in a block whose id is `A ^ 255`. Hashing the offset across all 64 bits
1100        // removes that structure; `fragment_offset == 0` (the whole-run common
1101        // case) stays a no-op, so those NodeIds are unchanged.
1102        let mixed_element = if fragment_offset == 0 {
1103            element_id
1104        } else {
1105            fnv_mix_u64(element_id, fragment_offset as u64, 0)
1106        };
1107        let node_id = synthetic_node_id(owner, mixed_element, SyntheticKind::TextRun);
1108        let mut node = Node::new(Role::TextRun);
1109        node.set_value(value);
1110        node.set_character_lengths(character_lengths);
1111        if let Some(ws) = word_starts {
1112            node.set_word_starts(ws);
1113        }
1114        if let Some(pos) = character_positions {
1115            node.set_character_positions(pos);
1116        }
1117        if let Some(widths) = character_widths {
1118            node.set_character_widths(widths);
1119        }
1120        // Text attributes (WCAG 1.3.1 / EN 301 549 11.5.2.9). AccessKit has no
1121        // bold flag, so an explicit weight wins, else bold => 700.
1122        if let Some(w) = attrs.font_weight {
1123            node.set_font_weight(w as f32);
1124        } else if attrs.bold {
1125            node.set_font_weight(700.0);
1126        }
1127        if attrs.italic {
1128            node.set_italic();
1129        }
1130        if attrs.underline {
1131            node.set_underline(default_text_decoration());
1132        }
1133        if attrs.strikethrough {
1134            node.set_strikethrough(default_text_decoration());
1135        }
1136        self.children_collected.push((node_id, node));
1137        // Attach the text-run to its parent paragraph's child list.
1138        // The parent must already be in `children_collected`.
1139        for (id, parent) in self.children_collected.iter_mut() {
1140            if *id == parent_node {
1141                parent.push_child(node_id);
1142                return node_id;
1143            }
1144        }
1145        // Parent not found — push as a direct child of the widget's
1146        // own node as a fallback. Caller misused the API.
1147        self.inner.push_child(node_id);
1148        node_id
1149    }
1150
1151    /// Push a single `Role::TextRun` child attached **directly** to the
1152    /// widget's own node (no intervening `Role::Paragraph`). This is the
1153    /// single-line text-input shape: `Role::TextInput` → one
1154    /// `Role::TextRun`.
1155    ///
1156    /// Required for screen-reader typing echo. accesskit_consumer's
1157    /// `supports_text_ranges()` returns `false` for a text input that
1158    /// only sets `character_lengths` on its *own* node — it needs a
1159    /// `Role::TextRun` child. Without it the macOS adapter never emits
1160    /// `AXSelectedTextChanged`, so VoiceOver reads the value once on
1161    /// focus but never echoes characters/words while typing. Emit this
1162    /// even when `value` / `character_lengths` are empty so
1163    /// `supports_text_ranges()` is already true before the first
1164    /// keystroke (the change-diff's *old* node must also support ranges
1165    /// for the notification to fire). Target the caret/selection at the
1166    /// returned `NodeId` via [`set_text_selection_to`](Self::set_text_selection_to).
1167    pub fn push_text_run_child_on_self(
1168        &mut self,
1169        element_id: u64,
1170        value: String,
1171        character_lengths: Vec<u8>,
1172        word_starts: Option<Vec<u8>>,
1173    ) -> NodeId {
1174        let Some(owner) = self.owner else {
1175            debug_assert!(
1176                false,
1177                "push_text_run_child_on_self called on a builder with no owner — \
1178                 widgets must only call this from Widget::accessibility"
1179            );
1180            return NodeId(0);
1181        };
1182        let node_id = synthetic_node_id(owner, element_id, SyntheticKind::TextRun);
1183        let mut node = Node::new(Role::TextRun);
1184        node.set_value(value);
1185        node.set_character_lengths(character_lengths);
1186        if let Some(ws) = word_starts {
1187            node.set_word_starts(ws);
1188        }
1189        self.children_collected.push((node_id, node));
1190        self.inner.push_child(node_id);
1191        node_id
1192    }
1193
1194    /// Declare a text selection that references TextRun children
1195    /// previously emitted via `push_text_run_child`. Both the
1196    /// anchor and the focus are expressed as
1197    /// `(NodeId, character_index)` pairs where the character index
1198    /// is an index into the target TextRun's `character_lengths`
1199    /// (NOT a document-absolute offset — per AccessKit's contract).
1200    pub fn set_text_selection_to(&mut self, anchor: (NodeId, usize), focus: (NodeId, usize)) {
1201        self.pending_explicit_selection = Some((
1202            TextPosition {
1203                node: anchor.0,
1204                character_index: anchor.1,
1205            },
1206            TextPosition {
1207                node: focus.0,
1208                character_index: focus.1,
1209            },
1210        ));
1211    }
1212}
1213
1214impl Default for AccessNodeBuilder {
1215    fn default() -> Self {
1216        Self::new()
1217    }
1218}
1219
1220/// Convert a WidgetId to an AccessKit NodeId.
1221pub fn widget_id_to_node_id(id: WidgetId) -> NodeId {
1222    use slotmap::Key;
1223    let key_data = id.data();
1224    let raw = key_data.as_ffi();
1225    NodeId(raw)
1226}
1227
1228/// Convert an AccessKit NodeId back to a WidgetId. Returns `None`
1229/// for synthetic NodeIds (widget-emitted child nodes like TextRuns);
1230/// callers that need to route an `ActionRequest` targeting a
1231/// synthetic NodeId must consult `WidgetTree::synthetic_parent_map`
1232/// to find the owning widget.
1233pub fn node_id_to_widget_id_maybe(node_id: NodeId) -> Option<WidgetId> {
1234    if is_synthetic(node_id) {
1235        return None;
1236    }
1237    use slotmap::KeyData;
1238    let key_data = KeyData::from_ffi(node_id.0);
1239    Some(key_data.into())
1240}
1241
1242/// Legacy infallible converter kept for existing call sites that
1243/// never encounter synthetic NodeIds. New code should prefer
1244/// [`node_id_to_widget_id_maybe`]. Panics in debug for synthetic
1245/// ids to catch misrouted calls early.
1246pub fn node_id_to_widget_id(node_id: NodeId) -> WidgetId {
1247    debug_assert!(
1248        !is_synthetic(node_id),
1249        "node_id_to_widget_id called on synthetic NodeId — use node_id_to_widget_id_maybe"
1250    );
1251    use slotmap::KeyData;
1252    let key_data = KeyData::from_ffi(node_id.0);
1253    key_data.into()
1254}
1255
1256/// The special root node ID for the accessibility tree.
1257pub fn root_node_id() -> NodeId {
1258    NodeId(0)
1259}
1260
1261/// Query result for accessibility information about a widget.
1262#[derive(Debug)]
1263pub struct AccessibilityInfo {
1264    role: Role,
1265    name: Option<String>,
1266    actions: Vec<Action>,
1267    toggled: Option<bool>,
1268    expanded: Option<bool>,
1269    selected: Option<bool>,
1270    disabled: bool,
1271    hidden: bool,
1272}
1273
1274impl AccessibilityInfo {
1275    pub fn new(role: Role, name: Option<String>, actions: Vec<Action>) -> Self {
1276        Self {
1277            role,
1278            name,
1279            actions,
1280            toggled: None,
1281            expanded: None,
1282            selected: None,
1283            disabled: false,
1284            hidden: false,
1285        }
1286    }
1287
1288    pub fn with_toggled(mut self, toggled: bool) -> Self {
1289        self.toggled = Some(toggled);
1290        self
1291    }
1292
1293    pub fn with_expanded(mut self, expanded: bool) -> Self {
1294        self.expanded = Some(expanded);
1295        self
1296    }
1297
1298    pub fn with_selected(mut self, selected: bool) -> Self {
1299        self.selected = Some(selected);
1300        self
1301    }
1302
1303    pub fn with_disabled(mut self, disabled: bool) -> Self {
1304        self.disabled = disabled;
1305        self
1306    }
1307
1308    pub fn with_hidden(mut self, hidden: bool) -> Self {
1309        self.hidden = hidden;
1310        self
1311    }
1312
1313    pub fn role(&self) -> Role {
1314        self.role
1315    }
1316
1317    pub fn name(&self) -> Option<&str> {
1318        self.name.as_deref()
1319    }
1320
1321    pub fn actions(&self) -> &[Action] {
1322        &self.actions
1323    }
1324
1325    pub fn is_toggled(&self) -> bool {
1326        self.toggled.unwrap_or(false)
1327    }
1328
1329    pub fn is_expanded(&self) -> bool {
1330        self.expanded.unwrap_or(false)
1331    }
1332
1333    pub fn is_selected(&self) -> bool {
1334        self.selected.unwrap_or(false)
1335    }
1336
1337    pub fn is_disabled(&self) -> bool {
1338        self.disabled
1339    }
1340
1341    pub fn is_hidden(&self) -> bool {
1342        self.hidden
1343    }
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348    use super::*;
1349
1350    fn fake_widget(id: u64) -> WidgetId {
1351        slotmap::KeyData::from_ffi(id).into()
1352    }
1353
1354    #[test]
1355    fn widget_derived_node_id_has_bit_63_clear() {
1356        // A freshly-minted slotmap key (version 1, index 0) encodes
1357        // to a u64 with bit 63 clear. The top-bit namespace split
1358        // (synthetic NodeIds set bit 63, widget-derived NodeIds clear
1359        // it) only works if widget-derived NodeIds stay below bit 63.
1360        let wid = fake_widget(1);
1361        let nid = widget_id_to_node_id(wid);
1362        assert_eq!(
1363            nid.0 & SYNTHETIC_BIT,
1364            0,
1365            "widget NodeId must have bit 63 clear"
1366        );
1367        assert!(!is_synthetic(nid));
1368    }
1369
1370    #[test]
1371    fn synthetic_node_id_has_bit_63_set() {
1372        let wid = fake_widget(42);
1373        let nid = synthetic_node_id(wid, 17, SyntheticKind::TextRun);
1374        assert_eq!(nid.0 & SYNTHETIC_BIT, SYNTHETIC_BIT);
1375        assert!(is_synthetic(nid));
1376    }
1377
1378    #[test]
1379    fn link_runs_on_line_chains_runs_both_ways() {
1380        let mut b = AccessNodeBuilder::for_widget(fake_widget(1));
1381        let para = b.push_paragraph_child(1);
1382        let run = |b: &mut AccessNodeBuilder, off: usize| {
1383            b.push_text_run_child(
1384                para,
1385                10,
1386                off,
1387                "abc".to_string(),
1388                vec![1, 1, 1],
1389                None,
1390                None,
1391                None,
1392                TextRunAttributes::default(),
1393            )
1394        };
1395        let (r0, r1, r2) = (run(&mut b, 0), run(&mut b, 3), run(&mut b, 6));
1396        b.link_runs_on_line(&[r0, r1, r2]);
1397
1398        let (_id, _n, children) = b.build(fake_widget(1));
1399        let node = |id| {
1400            children
1401                .iter()
1402                .find(|(i, _)| *i == id)
1403                .map(|(_, n)| n)
1404                .unwrap()
1405        };
1406        // First run: forward only. Middle: both. Last: back only.
1407        assert_eq!(node(r0).previous_on_line(), None);
1408        assert_eq!(node(r0).next_on_line(), Some(r1));
1409        assert_eq!(node(r1).previous_on_line(), Some(r0));
1410        assert_eq!(node(r1).next_on_line(), Some(r2));
1411        assert_eq!(node(r2).previous_on_line(), Some(r1));
1412        assert_eq!(node(r2).next_on_line(), None);
1413    }
1414
1415    /// A chunk at a non-zero offset in one element must not collide with a
1416    /// whole run in another element, even when the two element ids differ by
1417    /// exactly the low-byte XOR of the chunk offset — the aliasing the old
1418    /// `element_id ^ (offset << 32)` mix allowed.
1419    #[test]
1420    fn a_chunk_offset_does_not_alias_another_elements_run() {
1421        let mut b = AccessNodeBuilder::for_widget(fake_widget(1));
1422        let para = b.push_paragraph_child(1);
1423        // `synth_element_id` encodes the block id in bits 32-61.
1424        let elem_a: u64 = 0xABCD_u64 << 32;
1425        let elem_b: u64 = (0xABCD_u64 ^ 255) << 32;
1426        let run = |b: &mut AccessNodeBuilder, elem: u64, off: usize| {
1427            b.push_text_run_child(
1428                para,
1429                elem,
1430                off,
1431                "x".to_string(),
1432                vec![1],
1433                None,
1434                None,
1435                None,
1436                TextRunAttributes::default(),
1437            )
1438        };
1439        let a_chunk = run(&mut b, elem_a, 255); // offset-255 chunk in block A
1440        let b_whole = run(&mut b, elem_b, 0); // whole run in block B = A ^ 255
1441        assert_ne!(
1442            a_chunk, b_whole,
1443            "a chunk offset must not alias another block's run NodeId"
1444        );
1445    }
1446
1447    #[test]
1448    fn set_child_position_in_set_numbers_a_paragraph() {
1449        let mut b = AccessNodeBuilder::for_widget(fake_widget(1));
1450        let para = b.push_paragraph_child(5);
1451        assert!(b.set_child_position_in_set(para, 42, 200));
1452        assert!(
1453            !b.set_child_position_in_set(NodeId(999), 1, 1),
1454            "an unknown child is not found"
1455        );
1456
1457        let (_id, _n, children) = b.build(fake_widget(1));
1458        let p = children
1459            .iter()
1460            .find(|(i, _)| *i == para)
1461            .map(|(_, n)| n)
1462            .unwrap();
1463        assert_eq!(p.position_in_set(), Some(42));
1464        assert_eq!(p.size_of_set(), Some(200));
1465    }
1466
1467    #[test]
1468    fn synthetic_node_id_stable_across_calls() {
1469        // Same (widget, element, kind) produces identical NodeIds —
1470        // this stability is required for screen-reader focus to survive
1471        // accessibility rebuilds.
1472        let wid = fake_widget(42);
1473        let a = synthetic_node_id(wid, 17, SyntheticKind::TextRun);
1474        let b = synthetic_node_id(wid, 17, SyntheticKind::TextRun);
1475        assert_eq!(a, b);
1476    }
1477
1478    #[test]
1479    fn synthetic_node_id_differs_by_kind() {
1480        let wid = fake_widget(42);
1481        let p = synthetic_node_id(wid, 17, SyntheticKind::Paragraph);
1482        let r = synthetic_node_id(wid, 17, SyntheticKind::TextRun);
1483        assert_ne!(
1484            p, r,
1485            "paragraph and text-run kinds must produce distinct NodeIds"
1486        );
1487    }
1488
1489    #[test]
1490    fn synthetic_node_id_differs_by_element() {
1491        let wid = fake_widget(42);
1492        let a = synthetic_node_id(wid, 1, SyntheticKind::TextRun);
1493        let b = synthetic_node_id(wid, 2, SyntheticKind::TextRun);
1494        assert_ne!(a, b);
1495    }
1496
1497    #[test]
1498    fn node_id_to_widget_id_maybe_returns_none_for_synthetic() {
1499        let wid = fake_widget(42);
1500        let syn = synthetic_node_id(wid, 17, SyntheticKind::TextRun);
1501        assert!(node_id_to_widget_id_maybe(syn).is_none());
1502    }
1503
1504    #[test]
1505    fn node_id_to_widget_id_maybe_round_trips_widget_ids() {
1506        let wid = fake_widget(99);
1507        let nid = widget_id_to_node_id(wid);
1508        let back = node_id_to_widget_id_maybe(nid).unwrap();
1509        assert_eq!(wid, back);
1510    }
1511
1512    #[test]
1513    fn push_paragraph_child_and_text_run_child_emit_synthetic_nodes() {
1514        let owner = fake_widget(7);
1515        let mut builder = AccessNodeBuilder::for_widget(owner);
1516        builder.set_role(Role::MultilineTextInput);
1517
1518        let para = builder.push_paragraph_child(100);
1519        let run = builder.push_text_run_child(
1520            para,
1521            200,
1522            0,
1523            "hello".to_string(),
1524            vec![1, 1, 1, 1, 1],
1525            Some(vec![0]),
1526            None,
1527            None,
1528            TextRunAttributes::default(),
1529        );
1530        assert!(is_synthetic(para));
1531        assert!(is_synthetic(run));
1532
1533        let (_nid, _node, children) = builder.build(owner);
1534        // Two emitted children: paragraph + text run.
1535        assert_eq!(children.len(), 2);
1536        assert!(children.iter().any(|(id, _)| *id == para));
1537        assert!(children.iter().any(|(id, _)| *id == run));
1538    }
1539
1540    #[test]
1541    fn text_run_attributes_reach_at_node() {
1542        // Audit G7 / EN 301 549 11.5.2.9: bold / italic / underline /
1543        // strikethrough formatting on a run is exposed on its TextRun node.
1544        let owner = fake_widget(9);
1545        let mut builder = AccessNodeBuilder::for_widget(owner);
1546        builder.set_role(Role::MultilineTextInput);
1547        let para = builder.push_paragraph_child(1);
1548        let run = builder.push_text_run_child(
1549            para,
1550            2,
1551            0,
1552            "ab".to_string(),
1553            vec![1, 1],
1554            None,
1555            None,
1556            None,
1557            TextRunAttributes {
1558                bold: true,
1559                italic: true,
1560                underline: true,
1561                strikethrough: true,
1562                ..Default::default()
1563            },
1564        );
1565        let (_nid, _node, children) = builder.build(owner);
1566        let (_, run_node) = children
1567            .iter()
1568            .find(|(id, _)| *id == run)
1569            .expect("run node");
1570        assert_eq!(
1571            run_node.font_weight(),
1572            Some(700.0),
1573            "bold folds to font weight 700"
1574        );
1575        assert!(run_node.is_italic(), "italic flag set");
1576        assert!(run_node.underline().is_some(), "underline decoration set");
1577        assert!(
1578            run_node.strikethrough().is_some(),
1579            "strikethrough decoration set"
1580        );
1581
1582        // An explicit numeric weight wins over the bold flag.
1583        let owner2 = fake_widget(10);
1584        let mut b2 = AccessNodeBuilder::for_widget(owner2);
1585        b2.set_role(Role::MultilineTextInput);
1586        let p2 = b2.push_paragraph_child(1);
1587        let r2 = b2.push_text_run_child(
1588            p2,
1589            2,
1590            0,
1591            "x".to_string(),
1592            vec![1],
1593            None,
1594            None,
1595            None,
1596            TextRunAttributes {
1597                bold: true,
1598                font_weight: Some(300),
1599                ..Default::default()
1600            },
1601        );
1602        let (_, _, kids2) = b2.build(owner2);
1603        let (_, r2n) = kids2.iter().find(|(id, _)| *id == r2).expect("run2 node");
1604        assert_eq!(
1605            r2n.font_weight(),
1606            Some(300.0),
1607            "explicit weight wins over bold"
1608        );
1609    }
1610
1611    #[test]
1612    fn set_text_selection_to_wins_over_self_selection() {
1613        let owner = fake_widget(3);
1614        let mut builder = AccessNodeBuilder::for_widget(owner);
1615        builder.set_role(Role::MultilineTextInput);
1616        // Emit a paragraph + run so set_text_selection_to has a
1617        // real synthetic NodeId to target.
1618        let para = builder.push_paragraph_child(1);
1619        let run = builder.push_text_run_child(
1620            para,
1621            2,
1622            0,
1623            "ab".to_string(),
1624            vec![1, 1],
1625            None,
1626            None,
1627            None,
1628            TextRunAttributes::default(),
1629        );
1630        // Both a self-targeted AND an explicit selection are
1631        // staged — the explicit one must win.
1632        builder.set_text_selection_on_self(0, 0);
1633        builder.set_text_selection_to((run, 0), (run, 2));
1634        let (_nid, node, _children) = builder.build(owner);
1635        let sel = node.text_selection().expect("text selection set");
1636        assert_eq!(sel.focus.node, run);
1637        assert_eq!(sel.focus.character_index, 2);
1638    }
1639}