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