Skip to main content

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}