Skip to main content

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}