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