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