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