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}