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