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