oxiui_accessibility/tree.rs
1//! A11y tree builder for OxiUI.
2//!
3//! Provides [`A11yNode`], [`A11yTree`], and [`WidgetRole`] — together they
4//! convert an OxiUI widget graph into an accesskit [`TreeUpdate`].
5
6use std::collections::hash_map::DefaultHasher;
7use std::hash::{Hash, Hasher};
8
9use accesskit::{Node, NodeId, Role, Tree, TreeId, TreeUpdate};
10
11use crate::props::{A11yNodeProps, Toggled3};
12
13// ── Widget role mapping ──────────────────────────────────────────────────────
14
15/// The accessibility role of an OxiUI widget node.
16///
17/// Maps semantic OxiUI widget kinds to their closest ARIA / AccessKit
18/// [`Role`] equivalents.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum WidgetRole {
21 /// A top-level application window.
22 Window,
23 /// A generic container group (e.g. a panel or box layout).
24 Group,
25 /// A clickable button.
26 Button,
27 /// A read-only text label.
28 Label,
29 /// An editable text input field.
30 TextInput,
31 /// A table row (used by `oxiui-table`).
32 TableRow,
33 /// A single table cell.
34 TableCell,
35 /// A scrollable view container.
36 ScrollView,
37 /// An image widget.
38 Image,
39 /// Any widget whose role is not mapped.
40 Unknown,
41
42 // ── Interactive widgets ──────────────────────────────────────────────────
43 /// A checkbox control (two or three states).
44 Checkbox,
45 /// A slider for selecting a numeric value within a range.
46 Slider,
47 /// A progress bar or loading indicator.
48 ProgressBar,
49 /// A tab button within a tab strip.
50 Tab,
51 /// The content panel associated with a `Tab`.
52 TabPanel,
53 /// A pop-up or drop-down menu container.
54 Menu,
55 /// A single item inside a `Menu`.
56 MenuItem,
57 /// A modal dialog.
58 Dialog,
59 /// An alert or status message.
60 Alert,
61 /// A tooltip associated with another widget.
62 Tooltip,
63 /// A hierarchical tree container.
64 Tree,
65 /// A single item inside a `Tree`.
66 TreeItem,
67 /// A list item (e.g. an `<li>` equivalent).
68 ListItem,
69 /// A hyperlink.
70 Link,
71
72 // ── Table roles ──────────────────────────────────────────────────────────
73 /// A column header cell (e.g. `<th scope="col">`).
74 ColumnHeader,
75
76 // ── Landmark roles ───────────────────────────────────────────────────────
77 /// The site-wide banner / site header.
78 Banner,
79 /// A navigation landmark (nav menu).
80 Navigation,
81 /// The primary main content of the page.
82 Main,
83 /// Complementary content (e.g. a sidebar).
84 Complementary,
85 /// The content info / site footer.
86 ContentInfo,
87}
88
89impl From<WidgetRole> for Role {
90 fn from(r: WidgetRole) -> Role {
91 match r {
92 WidgetRole::Window => Role::Window,
93 WidgetRole::Group => Role::Group,
94 WidgetRole::Button => Role::Button,
95 WidgetRole::Label => Role::Label,
96 WidgetRole::TextInput => Role::TextInput,
97 WidgetRole::TableRow => Role::Row,
98 WidgetRole::TableCell => Role::Cell,
99 WidgetRole::ScrollView => Role::ScrollView,
100 WidgetRole::Image => Role::Image,
101 WidgetRole::Unknown => Role::Unknown,
102
103 WidgetRole::Checkbox => Role::CheckBox,
104 WidgetRole::Slider => Role::Slider,
105 WidgetRole::ProgressBar => Role::ProgressIndicator,
106 WidgetRole::Tab => Role::Tab,
107 WidgetRole::TabPanel => Role::TabPanel,
108 WidgetRole::Menu => Role::Menu,
109 WidgetRole::MenuItem => Role::MenuItem,
110 WidgetRole::Dialog => Role::Dialog,
111 WidgetRole::Alert => Role::Alert,
112 WidgetRole::Tooltip => Role::Tooltip,
113 WidgetRole::Tree => Role::Tree,
114 WidgetRole::TreeItem => Role::TreeItem,
115 WidgetRole::ListItem => Role::ListItem,
116 WidgetRole::Link => Role::Link,
117
118 WidgetRole::ColumnHeader => Role::ColumnHeader,
119
120 WidgetRole::Banner => Role::Banner,
121 WidgetRole::Navigation => Role::Navigation,
122 WidgetRole::Main => Role::Main,
123 WidgetRole::Complementary => Role::Complementary,
124 WidgetRole::ContentInfo => Role::ContentInfo,
125 }
126 }
127}
128
129impl std::fmt::Display for WidgetRole {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 let name = match self {
132 WidgetRole::Window => "window",
133 WidgetRole::Group => "group",
134 WidgetRole::Button => "button",
135 WidgetRole::Label => "label",
136 WidgetRole::TextInput => "text-input",
137 WidgetRole::TableRow => "table-row",
138 WidgetRole::TableCell => "table-cell",
139 WidgetRole::ScrollView => "scroll-view",
140 WidgetRole::Image => "image",
141 WidgetRole::Unknown => "unknown",
142
143 WidgetRole::Checkbox => "checkbox",
144 WidgetRole::Slider => "slider",
145 WidgetRole::ProgressBar => "progress-bar",
146 WidgetRole::Tab => "tab",
147 WidgetRole::TabPanel => "tab-panel",
148 WidgetRole::Menu => "menu",
149 WidgetRole::MenuItem => "menu-item",
150 WidgetRole::Dialog => "dialog",
151 WidgetRole::Alert => "alert",
152 WidgetRole::Tooltip => "tooltip",
153 WidgetRole::Tree => "tree",
154 WidgetRole::TreeItem => "tree-item",
155 WidgetRole::ListItem => "list-item",
156 WidgetRole::Link => "link",
157
158 WidgetRole::ColumnHeader => "column-header",
159
160 WidgetRole::Banner => "banner",
161 WidgetRole::Navigation => "navigation",
162 WidgetRole::Main => "main",
163 WidgetRole::Complementary => "complementary",
164 WidgetRole::ContentInfo => "content-info",
165 };
166 f.write_str(name)
167 }
168}
169
170// ── A11y node ────────────────────────────────────────────────────────────────
171
172/// A node in the OxiUI accessibility tree.
173///
174/// Each node corresponds to a widget in the UI hierarchy. The tree is
175/// independent of any rendering backend and can be built and inspected in
176/// headless tests.
177pub struct A11yNode {
178 /// Stable, unique identifier for this node within the tree.
179 pub id: NodeId,
180 /// The widget's accessibility role.
181 pub role: WidgetRole,
182 /// Optional human-readable label (e.g. button caption, field placeholder).
183 pub label: Option<String>,
184 /// Child nodes, in document/render order.
185 pub children: Vec<A11yNode>,
186 /// Rich property bag — description, state flags, range, relationships, etc.
187 pub props: A11yNodeProps,
188 /// Optional text content (for editable widgets).
189 pub text_content: Option<String>,
190}
191
192impl std::fmt::Debug for A11yNode {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 f.debug_struct("A11yNode")
195 .field("id", &self.id)
196 .field("role", &self.role)
197 .field("label", &self.label)
198 .field("children", &self.children)
199 .field("props", &self.props)
200 .field("text_content", &self.text_content)
201 .finish()
202 }
203}
204
205impl A11yNode {
206 /// Construct a minimal node with just an id, role, and optional label.
207 pub fn simple(id: NodeId, role: WidgetRole, label: Option<String>) -> Self {
208 Self {
209 id,
210 role,
211 label,
212 children: Vec::new(),
213 props: A11yNodeProps::default(),
214 text_content: None,
215 }
216 }
217
218 /// Compute a stable hash over this node's OxiUI-side content fields.
219 ///
220 /// The hash covers the label, role, text_content, and all `A11yNodeProps`
221 /// fields. It deliberately excludes `children` (child changes are detected
222 /// by the diff walk) and `id` (the id is the lookup key, not content).
223 ///
224 /// Used by [`A11yTree::diff`] to replace the previous `format!("{:?}")`
225 /// deep-equality fallback. `accesskit::Node` does not implement `PartialEq`,
226 /// so dirty tracking is performed on the OxiUI wrapper instead.
227 pub fn content_hash(&self) -> u64 {
228 let mut h = DefaultHasher::new();
229 // Label
230 self.label.hash(&mut h);
231 // Role — WidgetRole derives Debug + PartialEq + Eq; use Debug string
232 // so we don't need a custom Hash impl on WidgetRole.
233 format!("{:?}", self.role).hash(&mut h);
234 // Text content
235 self.text_content.hash(&mut h);
236 // Props — A11yNodeProps derives Debug; hash via its Debug representation
237 // since the individual field types (Option<f64>, etc.) don't all impl Hash.
238 // This is correct: two nodes with identical Debug-printed props are equal.
239 format!("{:?}", self.props).hash(&mut h);
240 // Also include the children ID list so that structural changes (adding /
241 // removing children) cause the parent's hash to change too.
242 let child_ids: Vec<u64> = self.children.iter().map(|c| c.id.0).collect();
243 child_ids.hash(&mut h);
244 h.finish()
245 }
246}
247
248// ── Tree builder ─────────────────────────────────────────────────────────────
249
250/// Builds and diffs accesskit [`TreeUpdate`]s from an [`A11yNode`] tree.
251///
252/// The struct stores the flat map of nodes (by `NodeId`) after a build, so
253/// subsequent calls to [`A11yTree::diff`] can compute minimal deltas.
254///
255/// The content-hash map (`hashes`) stores the OxiUI-side hash of each node at
256/// the time of the last `build_and_store` call. [`A11yTree::diff`] uses these
257/// hashes instead of `format!("{:?}", node)` string comparison, which avoids
258/// the overhead of serializing `accesskit::Node` (which doesn't impl `PartialEq`).
259#[derive(Default)]
260pub struct A11yTree {
261 /// Root id of the most recent build.
262 pub(crate) root_id: Option<NodeId>,
263 /// Flat, ordered snapshot: `(NodeId, Node)` from the most recent build.
264 pub(crate) snapshot: Vec<(NodeId, Node)>,
265 /// Per-node content hashes from the OxiUI `A11yNode` layer, keyed by NodeId.
266 pub(crate) hashes: std::collections::HashMap<NodeId, u64>,
267 /// Currently focused node, sent via `TreeUpdate::focus`.
268 pub(crate) focus: Option<NodeId>,
269}
270
271impl A11yTree {
272 /// Walk `root` and its descendants depth-first, producing a [`TreeUpdate`]
273 /// that describes the full tree.
274 ///
275 /// Also stores the snapshot internally so future [`diff`] calls can
276 /// compute minimal deltas.
277 ///
278 /// [`diff`]: A11yTree::diff
279 pub fn build(root: &A11yNode) -> TreeUpdate {
280 let mut nodes: Vec<(NodeId, Node)> = Vec::new();
281 collect_nodes(root, &mut nodes);
282 let root_id = root.id;
283 TreeUpdate {
284 nodes,
285 tree: Some(Tree::new(root_id)),
286 tree_id: TreeId::ROOT,
287 focus: root_id,
288 }
289 }
290
291 /// Build the full tree and store the snapshot for later diffing.
292 ///
293 /// Returns a `TreeUpdate` identical to [`A11yTree::build`].
294 ///
295 /// In addition to the AccessKit snapshot, this method collects the
296 /// OxiUI-side content hash of every node and stores it in `self.hashes`.
297 /// [`A11yTree::diff`] uses these hashes for efficient change detection.
298 pub fn build_and_store(&mut self, root: &A11yNode) -> TreeUpdate {
299 let mut nodes: Vec<(NodeId, Node)> = Vec::new();
300 let mut hashes: std::collections::HashMap<NodeId, u64> = std::collections::HashMap::new();
301 collect_nodes(root, &mut nodes);
302 collect_hashes(root, &mut hashes);
303 let root_id = root.id;
304 self.root_id = Some(root_id);
305 self.snapshot = nodes.clone();
306 self.hashes = hashes;
307 let focus = self.focus.unwrap_or(root_id);
308 TreeUpdate {
309 nodes,
310 tree: Some(Tree::new(root_id)),
311 tree_id: TreeId::ROOT,
312 focus,
313 }
314 }
315
316 // ── Focus tracking ───────────────────────────────────────────────────────
317
318 /// Set the currently-focused node.
319 ///
320 /// Pass `None` to clear the focus (the adapter will typically move focus
321 /// back to the root).
322 pub fn set_focus(&mut self, id: Option<NodeId>) {
323 self.focus = id;
324 }
325
326 /// Return the currently-focused node, if any.
327 pub fn focus(&self) -> Option<NodeId> {
328 self.focus
329 }
330
331 /// Produce a minimal `TreeUpdate` that only updates the focus field.
332 ///
333 /// Useful when only focus has changed and no node properties have changed.
334 pub fn focus_update(&self) -> TreeUpdate {
335 TreeUpdate {
336 nodes: Vec::new(),
337 tree: None,
338 tree_id: TreeId::ROOT,
339 focus: self.focus.unwrap_or(NodeId(0)),
340 }
341 }
342
343 // ── Live-region announce ─────────────────────────────────────────────────
344
345 /// Insert a transient live-region announcement node.
346 ///
347 /// Creates a synthetic [`accesskit::Role::Status`] node carrying `text`
348 /// as its value, with the live-region politeness derived from `urgency`.
349 /// The node is appended to the internal snapshot; the caller is responsible
350 /// for removing it on the next tick (by calling [`build_and_store`] with a
351 /// tree that doesn't include this id).
352 ///
353 /// Returns the newly-allocated `NodeId` so the caller can track it.
354 ///
355 /// [`build_and_store`]: A11yTree::build_and_store
356 pub fn announce(&mut self, text: &str, urgency: crate::props::LiveSetting) -> NodeId {
357 use accesskit::Live;
358 // Allocate a fresh id beyond the current max, or start at 0x8000_0000.
359 let new_raw: u64 = self
360 .snapshot
361 .iter()
362 .map(|(id, _)| id.0)
363 .max()
364 .unwrap_or(0)
365 .saturating_add(1)
366 .max(0x8000_0000);
367 let id = NodeId(new_raw);
368
369 let mut node = Node::new(Role::Status);
370 node.set_value(text);
371 node.set_live(Live::from(urgency));
372 node.set_live_atomic();
373
374 self.snapshot.push((id, node));
375 id
376 }
377
378 // ── Tree diff ────────────────────────────────────────────────────────────
379
380 /// Compute a minimal `TreeUpdate` delta from `old` to `new_tree`.
381 ///
382 /// Only nodes whose content has changed (or that are brand-new) are
383 /// included in the returned `nodes` list. Nodes that were removed are
384 /// handled implicitly by AccessKit: when the parent's children list no
385 /// longer references a node, the platform adapter orphans it.
386 ///
387 /// Change detection uses the OxiUI-side content hashes stored in
388 /// `self.hashes` rather than `format!("{:?}", accesskit::Node)` string
389 /// comparison. This is both faster and correct: `accesskit::Node` does not
390 /// implement `PartialEq`, so the Debug-string approach was a pragmatic
391 /// workaround. The hash approach is O(1) per node after the initial build.
392 ///
393 /// The returned update's `focus` is taken from `new_tree.focus`.
394 pub fn diff(old: &A11yTree, new_tree: &A11yTree) -> TreeUpdate {
395 // Build a NodeId → accesskit::Node map over the new snapshot for O(1) lookup.
396 let new_node_map: std::collections::HashMap<NodeId, &Node> = new_tree
397 .snapshot
398 .iter()
399 .map(|(id, node)| (*id, node))
400 .collect();
401
402 let mut changed: Vec<(NodeId, Node)> = Vec::new();
403
404 for (id, new_node) in &new_tree.snapshot {
405 let should_include = match old.hashes.get(id) {
406 // Brand-new node: not present in the old tree at all.
407 None => true,
408 // Node exists in both trees: compare content hashes.
409 Some(&old_hash) => {
410 let new_hash = new_tree.hashes.get(id).copied().unwrap_or(0);
411 old_hash != new_hash
412 }
413 };
414 if should_include {
415 changed.push((*id, new_node.clone()));
416 }
417 }
418
419 // Also emit the parent of any removed node so AccessKit can update its
420 // children list. Removal detection: a node present in `old.hashes` but
421 // absent from `new_node_map` has been removed. Its (former) parent will
422 // appear in `changed` already because the parent's child-id list changed
423 // and therefore its content_hash changed. No extra work is needed beyond
424 // the hash walk above.
425 let _ = new_node_map; // suppress unused-variable warning
426
427 let focus = new_tree.focus.unwrap_or(NodeId(0));
428 let tree = if old.root_id != new_tree.root_id {
429 new_tree.root_id.map(Tree::new)
430 } else {
431 None
432 };
433
434 TreeUpdate {
435 nodes: changed,
436 tree,
437 tree_id: TreeId::ROOT,
438 focus,
439 }
440 }
441}
442
443// ── Table accessibility helpers ───────────────────────────────────────────────
444
445/// Create a table-row [`A11yNode`] with a human-readable row description.
446///
447/// The `description` is set to `"Row N"` (1-based) so screen readers can
448/// announce the row position when the row itself has no label.
449pub fn table_row_node(id: NodeId, row_index: usize) -> A11yNode {
450 let mut node = A11yNode::simple(id, WidgetRole::TableRow, None);
451 node.props.description = Some(format!("Row {}", row_index + 1));
452 node
453}
454
455/// Create a table-cell [`A11yNode`] carrying the cell's text and coordinates.
456///
457/// The `description` encodes the row and column (1-based) so assistive
458/// technologies can announce the cell position in addition to its content.
459pub fn table_cell_node(id: NodeId, row: usize, col: usize, text: &str) -> A11yNode {
460 let mut node = A11yNode::simple(id, WidgetRole::TableCell, None);
461 node.text_content = Some(text.to_string());
462 node.props.description = Some(format!("Row {} Column {}", row + 1, col + 1));
463 node
464}
465
466/// Create a column-header [`A11yNode`] with a visible label and position hint.
467///
468/// The `label` is the column header text; the `description` encodes the
469/// column position (1-based) for screen readers that don't expose the label
470/// separately.
471pub fn column_header_node(id: NodeId, col: usize, label: &str) -> A11yNode {
472 let mut node = A11yNode::simple(id, WidgetRole::ColumnHeader, None);
473 node.label = Some(label.to_string());
474 node.props.description = Some(format!("Column {} header", col + 1));
475 node
476}
477
478/// Build a structured accessible table node tree.
479///
480/// Returns an [`A11yNode`] with role [`WidgetRole::Group`] (the closest
481/// available container role) that has:
482///
483/// - `col_count` [`WidgetRole::ColumnHeader`] children, one per entry in
484/// `col_headers` (or an empty label when the slice is shorter than
485/// `col_count`).
486/// - `row_count` [`WidgetRole::TableRow`] children, each containing
487/// `col_count` [`WidgetRole::TableCell`] children.
488///
489/// Row and column positions are encoded in each node's `description` field
490/// using the format established by the individual helper functions
491/// ([`table_row_node`], [`table_cell_node`], [`column_header_node`]).
492///
493/// Node IDs are minted sequentially starting from 1. The root node
494/// receives id `0`. Callers that need to merge this sub-tree into a
495/// larger id space must renumber the returned nodes.
496///
497/// # Example
498///
499/// ```
500/// use oxiui_accessibility::build_table_a11y;
501///
502/// let table = build_table_a11y(2, 3, &["Name", "Age", "City"]);
503/// // 3 column headers + 2 rows = 5 direct children
504/// assert_eq!(table.children.len(), 5);
505/// ```
506pub fn build_table_a11y(row_count: usize, col_count: usize, col_headers: &[&str]) -> A11yNode {
507 // Sequential id counter: root = 0, then 1..
508 let mut next_id: u64 = 0;
509
510 let mut root = A11yNode::simple(NodeId(next_id), WidgetRole::Group, None);
511 next_id += 1;
512
513 // Column-header children
514 for col_idx in 0..col_count {
515 let header_label = col_headers.get(col_idx).copied().unwrap_or("");
516 let header = column_header_node(NodeId(next_id), col_idx, header_label);
517 next_id += 1;
518 root.children.push(header);
519 }
520
521 // TableRow children, each containing TableCell children
522 for row_idx in 0..row_count {
523 let mut row = table_row_node(NodeId(next_id), row_idx);
524 next_id += 1;
525
526 for col_idx in 0..col_count {
527 let cell = table_cell_node(NodeId(next_id), row_idx, col_idx, "");
528 next_id += 1;
529 row.children.push(cell);
530 }
531
532 root.children.push(row);
533 }
534
535 root
536}
537
538// ── Text-run synthesis ────────────────────────────────────────────────────────
539
540/// Synthesize [`crate::props::TextRunChild`] segments for a text node.
541///
542/// Splits `text` at the selection boundaries so that assistive technologies
543/// can expose the exact caret or selection range.
544///
545/// * No selection → one segment for the whole text (`is_selected = false`).
546/// * Selection → up to three segments: text before, selected span, text after.
547/// Empty leading/trailing segments (selection at start or end) are omitted.
548///
549/// Byte offsets are clamped to valid char boundaries using
550/// [`crate::props::byte_offset_to_char_index`].
551pub fn synthesize_text_run_children(
552 text: &str,
553 selection: Option<&crate::props::TextSelection>,
554) -> Vec<crate::props::TextRunChild> {
555 use crate::props::{byte_offset_to_char_index, TextRunChild};
556
557 if text.is_empty() {
558 return Vec::new();
559 }
560
561 let sel = match selection {
562 None => {
563 return vec![TextRunChild {
564 text: text.to_string(),
565 char_offset: 0,
566 byte_offset: 0,
567 is_selected: false,
568 }];
569 }
570 Some(s) => s,
571 };
572
573 // Normalise anchor/focus to lo/hi byte offsets, clamped to text length.
574 let lo_byte = sel.anchor.min(sel.focus).min(text.len());
575 let hi_byte = sel.anchor.max(sel.focus).min(text.len());
576
577 // Snap to nearest char boundary (walk forward until we hit one).
578 let lo_byte = snap_to_char_boundary(text, lo_byte);
579 let hi_byte = snap_to_char_boundary(text, hi_byte);
580
581 let mut segments: Vec<TextRunChild> = Vec::with_capacity(3);
582
583 // Before selection
584 if lo_byte > 0 {
585 let before = &text[..lo_byte];
586 segments.push(TextRunChild {
587 text: before.to_string(),
588 char_offset: 0,
589 byte_offset: 0,
590 is_selected: false,
591 });
592 }
593
594 // Selected span
595 if lo_byte < hi_byte {
596 let selected = &text[lo_byte..hi_byte];
597 let char_off = byte_offset_to_char_index(text, lo_byte);
598 segments.push(TextRunChild {
599 text: selected.to_string(),
600 char_offset: char_off,
601 byte_offset: lo_byte,
602 is_selected: true,
603 });
604 } else {
605 // Collapsed caret — emit a zero-length selected segment
606 let char_off = byte_offset_to_char_index(text, lo_byte);
607 segments.push(TextRunChild {
608 text: String::new(),
609 char_offset: char_off,
610 byte_offset: lo_byte,
611 is_selected: true,
612 });
613 }
614
615 // After selection
616 if hi_byte < text.len() {
617 let after = &text[hi_byte..];
618 let char_off = byte_offset_to_char_index(text, hi_byte);
619 segments.push(TextRunChild {
620 text: after.to_string(),
621 char_offset: char_off,
622 byte_offset: hi_byte,
623 is_selected: false,
624 });
625 }
626
627 segments
628}
629
630/// Advance `offset` forward to the next UTF-8 char boundary in `text`.
631///
632/// If `offset` already sits on a boundary it is returned unchanged.
633/// If `offset >= text.len()` the string length is returned.
634fn snap_to_char_boundary(text: &str, offset: usize) -> usize {
635 if offset >= text.len() {
636 return text.len();
637 }
638 let mut pos = offset;
639 while pos < text.len() && !text.is_char_boundary(pos) {
640 pos += 1;
641 }
642 pos
643}
644
645// ── Internal helpers ─────────────────────────────────────────────────────────
646
647/// Recursively collect content hashes for every node in the subtree.
648///
649/// The resulting map is stored in [`A11yTree::hashes`] and consulted by
650/// [`A11yTree::diff`] to detect per-node changes without serialising
651/// `accesskit::Node` to a Debug string.
652fn collect_hashes(node: &A11yNode, out: &mut std::collections::HashMap<NodeId, u64>) {
653 out.insert(node.id, node.content_hash());
654 for child in &node.children {
655 collect_hashes(child, out);
656 }
657}
658
659/// Recursively collect [`A11yNode`] entries into `(NodeId, Node)` pairs.
660///
661/// Pre-order DFS: the parent is emitted before its children, which is the
662/// ordering required by accesskit platform adapters.
663fn collect_nodes(node: &A11yNode, out: &mut Vec<(NodeId, Node)>) {
664 let child_ids: Vec<NodeId> = node.children.iter().map(|c| c.id).collect();
665
666 let mut ak_node = Node::new(Role::from(node.role));
667
668 if let Some(label) = &node.label {
669 ak_node.set_label(label.as_str());
670 }
671
672 for &child_id in &child_ids {
673 ak_node.push_child(child_id);
674 }
675
676 // Apply rich props
677 apply_props(&mut ak_node, &node.props);
678
679 // Text content + text-run subnode
680 if let Some(text) = &node.text_content {
681 ak_node.set_value(text.as_str());
682 }
683
684 out.push((node.id, ak_node));
685
686 for child in &node.children {
687 collect_nodes(child, out);
688 }
689}
690
691/// Apply [`A11yNodeProps`] fields onto an accesskit [`Node`].
692fn apply_props(ak: &mut Node, props: &A11yNodeProps) {
693 if let Some(ref desc) = props.description {
694 ak.set_description(desc.as_str());
695 }
696 if let Some(ref ph) = props.placeholder {
697 ak.set_placeholder(ph.as_str());
698 }
699 if let Some(ref ks) = props.key_shortcut {
700 ak.set_keyboard_shortcut(ks.as_str());
701 }
702
703 if props.disabled {
704 ak.set_disabled();
705 }
706 if let Some(expanded) = props.expanded {
707 ak.set_expanded(expanded);
708 }
709 if let Some(selected) = props.selected {
710 ak.set_selected(selected);
711 }
712 if let Some(ref checked) = props.checked {
713 use accesskit::Toggled;
714 ak.set_toggled(Toggled::from(Toggled3::from(checked)));
715 }
716
717 if let Some(value) = props.value_now {
718 ak.set_numeric_value(value);
719 }
720 if let Some(min) = props.value_min {
721 ak.set_min_numeric_value(min);
722 }
723 if let Some(max) = props.value_max {
724 ak.set_max_numeric_value(max);
725 }
726 if let Some(step) = props.value_step {
727 ak.set_numeric_value_step(step);
728 }
729
730 if !props.labelled_by.is_empty() {
731 ak.set_labelled_by(props.labelled_by.clone());
732 }
733 if !props.described_by.is_empty() {
734 ak.set_described_by(props.described_by.clone());
735 }
736 if !props.controlled_by.is_empty() {
737 ak.set_controls(props.controlled_by.clone());
738 }
739 if !props.owns.is_empty() {
740 ak.set_owns(props.owns.clone());
741 }
742}