Skip to main content

quadraui/a11y/
tree.rs

1//! Frame → accessibility tree: the backend-independent builder that
2//! turns an owned frame snapshot plus the current focus into a tree an
3//! assistive technology can read.
4//!
5//! # Shape
6//!
7//! [`A11yTreeBuilder`] consumes a [`crate::frame::OwnedFrame`] (the
8//! serde-capable per-surface snapshot) and a [`FocusManager`], and
9//! produces either
10//!
11//! - a whole [`A11yTree`] — every node, in document order, root first —
12//!   which is what tests and the snapshot seam assert against, or
13//! - an [`A11yUpdate`] — the first call carries the full tree, every
14//!   later call carries **only the nodes whose role, state, bounds or
15//!   child list changed**. Under `--features a11y` an `A11yUpdate`
16//!   converts to an [`accesskit::TreeUpdate`] via
17//!   [`A11yUpdate::to_tree_update`].
18//!
19//! Nothing here touches a backend. The same tree comes out of the same
20//! frame on every platform, which is why the test seam
21//! ([`crate::testing::a11y`]) can build one with no window, no GTK, and
22//! no terminal.
23//!
24//! # Node ids are stable across frames
25//!
26//! Every surface that carries a [`WidgetId`] gets
27//! [`A11yNodeId::for_widget`] — a hash of the widget id's interned
28//! string. Re-render the same widget at a different position, in a
29//! different z-order, in a frame with different siblings, and its node
30//! id is unchanged, which is exactly what the incremental diff and the
31//! platform adapters need. Sub-nodes (list rows, toolbar buttons) hang
32//! off their parent with [`A11yNodeId::child`], so a row's id depends on
33//! its parent widget and its own key, not on its position in the frame.
34//!
35//! A surface with no `WidgetId` falls back to a key derived from its
36//! index in the frame (`surface.3`). That id is stable only while the
37//! frame's surface order is; primitives that want a durable id should
38//! carry a `WidgetId`.
39//!
40//! # Bounds
41//!
42//! Surface bounds come straight from [`crate::frame::OwnedNode::rect`]
43//! and are in **logical pixels** — the same coordinate space the frame
44//! was laid out in. Sub-node bounds are `None`: a list row's or a
45//! toolbar button's rect is only known after a backend measures text, so
46//! the builder deliberately reports no rect for them rather than
47//! inventing one. A backend that has metrics can fill them in by
48//! supplying bounds on the [`A11yChild`]s it builds.
49//!
50//! # Per-primitive hooks, and the fallback
51//!
52//! A primitive opts into a real mapping by implementing
53//! [`A11yPrimitive`] — role plus state for itself, plus the child nodes
54//! it contains. [`ListView`] (a collection) and [`Toolbar`] (a control
55//! container) are mapped fully. Every other primitive goes through a
56//! generic fallback: it still appears, as a [`A11yRole::Group`] labelled
57//! with its kind ("Diff view", "Minimap", …), so an unmapped primitive
58//! reads as an unhelpful group rather than vanishing from the tree.
59//!
60//! A consumer can override any of this per node:
61//! [`crate::frame::OwnedNode::a11y`] is merged over the primitive's own
62//! info field by field, so setting just a label keeps the derived role
63//! and state.
64//!
65//! # TUI emits no tree
66//!
67//! The TUI backend deliberately publishes **no** accessibility tree. A
68//! terminal application is already read by the screen reader attached to
69//! the terminal emulator, and a second, parallel tree would make the
70//! same content speak twice. The builder is still fully exercised under
71//! the TUI feature set: it is backend-independent, always compiled, and
72//! the headless snapshot seam runs it in tests. GUI backends (GTK,
73//! macOS, Win) are the ones that hand an `accesskit::TreeUpdate` to a
74//! platform adapter.
75//!
76//! # Feature gating
77//!
78//! The tree model, the per-primitive hooks and the snapshot form are
79//! **always compiled** — they are plain data and carry no dependency on
80//! accesskit. Only the conversions into `accesskit` types sit behind
81//! `feature = "a11y"`.
82
83use std::collections::HashMap;
84
85use crate::a11y::{A11yChecked, A11yInfo, A11yLive, A11yRole};
86use crate::event::Rect;
87use crate::focus::FocusManager;
88use crate::frame::{OwnedContent, OwnedFrame};
89use crate::primitives::list::ListView;
90use crate::primitives::toolbar::{Toolbar, ToolbarButton};
91use crate::types::{StyledText, WidgetId};
92
93/// Default label for the synthetic root node.
94const DEFAULT_ROOT_LABEL: &str = "quadraui";
95
96const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
97const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
98
99/// FNV-1a over `bytes`, continuing from `seed`.
100const fn fnv1a(bytes: &[u8], seed: u64) -> u64 {
101    let mut hash = seed;
102    let mut i = 0;
103    while i < bytes.len() {
104        hash ^= bytes[i] as u64;
105        hash = hash.wrapping_mul(FNV_PRIME);
106        i += 1;
107    }
108    hash
109}
110
111/// Identity of a node in the accessibility tree, stable across frames.
112///
113/// Derived from a [`WidgetId`]'s interned string rather than from its
114/// internal index, so the same widget id always produces the same node
115/// id — in this process and the next one.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
117pub struct A11yNodeId(u64);
118
119impl A11yNodeId {
120    /// The synthetic root node every frame hangs off.
121    pub const ROOT: A11yNodeId = A11yNodeId(fnv1a(b"quadraui.a11y.root", FNV_OFFSET));
122
123    /// Node id for a widget, derived from its interned id string.
124    pub fn for_widget(id: &WidgetId) -> Self {
125        Self::for_key(id.as_str())
126    }
127
128    /// Node id for a surface that carries no [`WidgetId`], derived from
129    /// an arbitrary key.
130    pub fn for_key(key: &str) -> Self {
131        Self(fnv1a(
132            key.as_bytes(),
133            fnv1a(b"quadraui.a11y.node.", FNV_OFFSET),
134        ))
135    }
136
137    /// Node id for a sub-node of `self`, identified by `key` within its
138    /// parent (e.g. `"item.3"`, `"button.run"`).
139    pub fn child(self, key: &str) -> Self {
140        Self(fnv1a(
141            key.as_bytes(),
142            fnv1a(&self.0.to_le_bytes(), FNV_OFFSET),
143        ))
144    }
145
146    /// The raw identity, for backends and adapters that need a number.
147    pub fn as_u64(self) -> u64 {
148        self.0
149    }
150
151    /// The matching accesskit node id.
152    #[cfg(feature = "a11y")]
153    #[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
154    pub fn to_accesskit(self) -> accesskit::NodeId {
155        accesskit::NodeId(self.0)
156    }
157}
158
159/// A sub-node a primitive contributes to the tree: a list row, a toolbar
160/// button, a tree item.
161///
162/// `key` identifies the child **within its parent** and must be stable
163/// for the same logical child across frames — prefer a widget id's
164/// string over a positional index where the primitive has one. The
165/// child's node id is `parent.child(&key)`.
166#[derive(Debug, Clone, PartialEq)]
167pub struct A11yChild {
168    /// Stable per-parent key; combined with the parent's id to form the
169    /// child's node id.
170    pub key: String,
171    /// Role, label and state for this child.
172    pub info: A11yInfo,
173    /// Bounds in logical pixels, when the primitive knows them without
174    /// backend text metrics. See the module doc.
175    pub bounds: Option<Rect>,
176    /// Nested children, if any.
177    pub children: Vec<A11yChild>,
178}
179
180impl A11yChild {
181    /// A leaf child with the given key and info.
182    pub fn new(key: impl Into<String>, info: A11yInfo) -> Self {
183        Self {
184            key: key.into(),
185            info,
186            bounds: None,
187            children: Vec::new(),
188        }
189    }
190
191    /// Attaches logical-pixel bounds.
192    pub fn with_bounds(mut self, bounds: Rect) -> Self {
193        self.bounds = Some(bounds);
194        self
195    }
196
197    /// Attaches nested children.
198    pub fn with_children(mut self, children: Vec<A11yChild>) -> Self {
199        self.children = children;
200        self
201    }
202}
203
204/// The per-primitive hook: how a primitive describes itself to an
205/// assistive technology.
206///
207/// Implement this on a primitive to give it a real role, state and
208/// children. Primitives that don't implement it still appear in the
209/// tree, as a labelled [`A11yRole::Group`] — see the module doc.
210pub trait A11yPrimitive {
211    /// This primitive's own role, accessible name and state.
212    fn a11y_info(&self) -> A11yInfo;
213
214    /// The sub-nodes this primitive contains, in reading order.
215    /// Defaults to none.
216    fn a11y_children(&self) -> Vec<A11yChild> {
217        Vec::new()
218    }
219
220    /// The key of the child that should hold accessibility focus while
221    /// *this* primitive is the focused widget — a listbox points at its
222    /// active row, a toolbar at its focused button. `None` keeps focus
223    /// on the primitive itself.
224    fn a11y_focus_key(&self) -> Option<String> {
225        None
226    }
227}
228
229/// One node of a built accessibility tree.
230#[derive(Debug, Clone, PartialEq)]
231pub struct A11yTreeNode {
232    /// This node's stable identity.
233    pub id: A11yNodeId,
234    /// Role, accessible name and state.
235    pub info: A11yInfo,
236    /// Bounds in logical pixels, when known. See the module doc for why
237    /// sub-nodes usually have none.
238    pub bounds: Option<Rect>,
239    /// Child node ids, in reading order.
240    pub children: Vec<A11yNodeId>,
241}
242
243impl A11yTreeNode {
244    /// This node's role, defaulting to [`A11yRole::Group`] when the
245    /// primitive supplied none.
246    pub fn role(&self) -> A11yRole {
247        self.info.a11y_role.unwrap_or(A11yRole::Group)
248    }
249
250    /// This node's accessible name, if it has one.
251    pub fn label(&self) -> Option<&str> {
252        self.info.a11y_label.as_deref()
253    }
254
255    /// The matching accesskit node.
256    ///
257    /// ARIA's one-based `level` and `position_in_set` are converted to
258    /// accesskit's zero-based equivalents.
259    #[cfg(feature = "a11y")]
260    #[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
261    pub fn to_accesskit(&self) -> accesskit::Node {
262        let mut node = accesskit::Node::new(self.role().into());
263        if let Some(label) = self.label() {
264            node.set_label(label.to_string());
265        }
266        if let Some(rect) = self.bounds {
267            node.set_bounds(accesskit::Rect::new(
268                rect.x as f64,
269                rect.y as f64,
270                (rect.x + rect.width) as f64,
271                (rect.y + rect.height) as f64,
272            ));
273        }
274        if let Some(selected) = self.info.selected {
275            node.set_selected(selected);
276        }
277        if let Some(expanded) = self.info.expanded {
278            node.set_expanded(expanded);
279        }
280        if let Some(checked) = self.info.checked {
281            node.set_toggled(checked.into());
282        }
283        if self.info.disabled == Some(true) {
284            node.set_disabled();
285        }
286        if self.info.read_only == Some(true) {
287            node.set_read_only();
288        }
289        if let Some(value) = &self.info.value {
290            node.set_value(value.clone());
291        }
292        if let Some(range) = self.info.value_range {
293            node.set_numeric_value(range.current);
294            node.set_min_numeric_value(range.min);
295            node.set_max_numeric_value(range.max);
296        }
297        if let Some(level) = self.info.level {
298            node.set_level(level.saturating_sub(1) as usize);
299        }
300        if let Some(pos) = self.info.position_in_set {
301            node.set_position_in_set(pos.saturating_sub(1) as usize);
302        }
303        if let Some(size) = self.info.set_size {
304            node.set_size_of_set(size as usize);
305        }
306        if let Some(live) = self.info.live {
307            node.set_live(live.into());
308        }
309        if !self.children.is_empty() {
310            node.set_children(
311                self.children
312                    .iter()
313                    .map(|id| id.to_accesskit())
314                    .collect::<Vec<_>>(),
315            );
316        }
317        node
318    }
319}
320
321/// A whole accessibility tree for one frame: every node, in document
322/// order, root first.
323#[derive(Debug, Clone, PartialEq)]
324pub struct A11yTree {
325    root: A11yNodeId,
326    focus: A11yNodeId,
327    nodes: Vec<A11yTreeNode>,
328}
329
330impl A11yTree {
331    /// The root node's id.
332    pub fn root(&self) -> A11yNodeId {
333        self.root
334    }
335
336    /// The node that holds accessibility focus. Falls back to the root
337    /// when nothing is focused — a tree must always name a focus.
338    pub fn focus(&self) -> A11yNodeId {
339        self.focus
340    }
341
342    /// Every node, in document order, root first.
343    pub fn nodes(&self) -> &[A11yTreeNode] {
344        &self.nodes
345    }
346
347    /// Looks a node up by id.
348    pub fn node(&self, id: A11yNodeId) -> Option<&A11yTreeNode> {
349        self.nodes.iter().find(|node| node.id == id)
350    }
351
352    /// The first node whose accessible name is exactly `label`, in
353    /// document order.
354    pub fn find(&self, label: &str) -> Option<&A11yTreeNode> {
355        self.nodes.iter().find(|node| node.label() == Some(label))
356    }
357
358    /// The children of `id`, in reading order. Unknown child ids are
359    /// skipped.
360    pub fn children_of(&self, id: A11yNodeId) -> Vec<&A11yTreeNode> {
361        match self.node(id) {
362            Some(parent) => parent
363                .children
364                .iter()
365                .filter_map(|child| self.node(*child))
366                .collect(),
367            None => Vec::new(),
368        }
369    }
370
371    /// The focused node, if the tree still contains it.
372    pub fn focused(&self) -> Option<&A11yTreeNode> {
373        self.node(self.focus)
374    }
375
376    /// A stable, assertable text rendering of the whole tree: one line
377    /// per node, two spaces of indent per level, `role "label" [state]`.
378    ///
379    /// Bounds are deliberately **not** included — they depend on the
380    /// frame's layout and would make every snapshot brittle. Assert on
381    /// [`Self::nodes`] for geometry.
382    pub fn to_snapshot(&self) -> String {
383        let mut out = String::new();
384        self.write_snapshot(self.root, 0, &mut out);
385        out
386    }
387
388    fn write_snapshot(&self, id: A11yNodeId, depth: usize, out: &mut String) {
389        let Some(node) = self.node(id) else {
390            return;
391        };
392        for _ in 0..depth {
393            out.push_str("  ");
394        }
395        out.push_str(role_name(node.role()));
396        if let Some(label) = node.label() {
397            out.push_str(" \"");
398            out.push_str(label);
399            out.push('"');
400        }
401        let states = state_tokens(node, self.focus == node.id);
402        if !states.is_empty() {
403            out.push_str(" [");
404            out.push_str(&states.join(" "));
405            out.push(']');
406        }
407        out.push('\n');
408        for child in &node.children {
409            self.write_snapshot(*child, depth + 1, out);
410        }
411    }
412}
413
414/// An incremental tree update: the nodes that changed since the last
415/// one, plus the focus (always) and the root (first update only).
416#[derive(Debug, Clone, PartialEq)]
417pub struct A11yUpdate {
418    /// New or changed nodes. Unchanged nodes are omitted; a node whose
419    /// child list changed is included, which is what lets an adapter
420    /// re-parent or drop the children it no longer names.
421    pub nodes: Vec<A11yTreeNode>,
422    /// The tree root, set only on the first update after a
423    /// [`A11yTreeBuilder::reset`] (or on a brand-new builder).
424    pub root: Option<A11yNodeId>,
425    /// The node holding accessibility focus. Always set — an update must
426    /// name a focus even when nothing changed.
427    pub focus: A11yNodeId,
428    /// Whether this update is a full tree rather than a diff.
429    pub full: bool,
430}
431
432#[cfg(feature = "a11y")]
433#[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
434impl A11yUpdate {
435    /// The matching accesskit tree update, ready to hand to a platform
436    /// adapter.
437    pub fn to_tree_update(&self) -> accesskit::TreeUpdate {
438        accesskit::TreeUpdate {
439            nodes: self
440                .nodes
441                .iter()
442                .map(|node| (node.id.to_accesskit(), node.to_accesskit()))
443                .collect(),
444            tree: self
445                .root
446                .map(|root| accesskit::TreeInfo::new(root.to_accesskit())),
447            tree_id: accesskit::TreeId::ROOT,
448            focus: self.focus.to_accesskit(),
449        }
450    }
451}
452
453/// Builds accessibility trees from frames, and diffs consecutive frames
454/// so only changed nodes are emitted.
455///
456/// One builder belongs to one surface/window: it remembers the tree it
457/// last emitted. [`Self::tree`] is stateless and always returns the full
458/// tree; [`Self::update`] is the incremental path.
459#[derive(Debug, Clone, Default)]
460pub struct A11yTreeBuilder {
461    prev: HashMap<A11yNodeId, A11yTreeNode>,
462    sent_tree: bool,
463    root_label: Option<String>,
464}
465
466impl A11yTreeBuilder {
467    /// A builder that has emitted nothing yet.
468    pub fn new() -> Self {
469        Self::default()
470    }
471
472    /// Sets the accessible name of the synthetic root node (typically
473    /// the window title). Defaults to `"quadraui"`.
474    pub fn with_root_label(mut self, label: impl Into<String>) -> Self {
475        self.root_label = Some(label.into());
476        self
477    }
478
479    /// The full tree for this frame. Does not touch the diff state, so
480    /// it can be called freely from tests and debug tooling.
481    pub fn tree(&self, frame: &OwnedFrame, focus: &FocusManager) -> A11yTree {
482        build_tree(
483            frame,
484            focus,
485            self.root_label.as_deref().unwrap_or(DEFAULT_ROOT_LABEL),
486        )
487    }
488
489    /// The next update for this frame: the whole tree the first time,
490    /// only the changed nodes afterwards.
491    pub fn update(&mut self, frame: &OwnedFrame, focus: &FocusManager) -> A11yUpdate {
492        let tree = self.tree(frame, focus);
493        let full = !self.sent_tree;
494        let nodes: Vec<A11yTreeNode> = if full {
495            tree.nodes().to_vec()
496        } else {
497            tree.nodes()
498                .iter()
499                .filter(|node| self.prev.get(&node.id) != Some(*node))
500                .cloned()
501                .collect()
502        };
503        self.prev = tree
504            .nodes()
505            .iter()
506            .map(|node| (node.id, node.clone()))
507            .collect();
508        self.sent_tree = true;
509        A11yUpdate {
510            nodes,
511            root: if full { Some(tree.root()) } else { None },
512            focus: tree.focus(),
513            full,
514        }
515    }
516
517    /// Forgets the emitted tree, so the next [`Self::update`] is a full
518    /// one again. Call this when the platform adapter is recreated.
519    pub fn reset(&mut self) {
520        self.prev.clear();
521        self.sent_tree = false;
522    }
523
524    /// The next update as an `accesskit::TreeUpdate`.
525    #[cfg(feature = "a11y")]
526    #[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
527    pub fn tree_update(
528        &mut self,
529        frame: &OwnedFrame,
530        focus: &FocusManager,
531    ) -> accesskit::TreeUpdate {
532        self.update(frame, focus).to_tree_update()
533    }
534}
535
536/// The full accessibility tree for one frame, with the default root
537/// label — the stateless convenience form of [`A11yTreeBuilder::tree`].
538pub fn frame_to_tree(frame: &OwnedFrame, focus: &FocusManager) -> A11yTree {
539    build_tree(frame, focus, DEFAULT_ROOT_LABEL)
540}
541
542fn build_tree(frame: &OwnedFrame, focus: &FocusManager, root_label: &str) -> A11yTree {
543    let mut nodes: Vec<A11yTreeNode> = Vec::with_capacity(frame.nodes().len() + 1);
544    let mut root_children: Vec<A11yNodeId> = Vec::new();
545    let mut root_bounds: Option<Rect> = None;
546    let mut focus_id = A11yNodeId::ROOT;
547
548    // Reserve the root's slot; its children and bounds are only known
549    // once every surface has been walked.
550    nodes.push(A11yTreeNode {
551        id: A11yNodeId::ROOT,
552        info: A11yInfo::new()
553            .with_role(A11yRole::Window)
554            .with_label(root_label),
555        bounds: None,
556        children: Vec::new(),
557    });
558
559    for owned in frame.nodes() {
560        let id = match &owned.widget_id {
561            Some(widget) => A11yNodeId::for_widget(widget),
562            None => A11yNodeId::for_key(&format!("surface.{}", owned.idx)),
563        };
564        let described = describe(&owned.content);
565        let info = match &owned.a11y {
566            Some(override_info) => merge(described.info, override_info),
567            None => described.info,
568        };
569        let child_ids: Vec<A11yNodeId> = described
570            .children
571            .iter()
572            .map(|child| id.child(&child.key))
573            .collect();
574
575        if focus.focused() == owned.widget_id.as_ref() && owned.widget_id.is_some() {
576            focus_id = match &described.focus_key {
577                Some(key) if child_ids.contains(&id.child(key)) => id.child(key),
578                _ => id,
579            };
580        }
581
582        root_children.push(id);
583        root_bounds = Some(match root_bounds {
584            Some(acc) => union(acc, owned.rect),
585            None => owned.rect,
586        });
587        nodes.push(A11yTreeNode {
588            id,
589            info,
590            bounds: Some(owned.rect),
591            children: child_ids,
592        });
593        for child in &described.children {
594            push_child(&mut nodes, id, child);
595        }
596    }
597
598    nodes[0].children = root_children;
599    nodes[0].bounds = root_bounds;
600
601    A11yTree {
602        root: A11yNodeId::ROOT,
603        focus: focus_id,
604        nodes,
605    }
606}
607
608fn push_child(nodes: &mut Vec<A11yTreeNode>, parent: A11yNodeId, child: &A11yChild) {
609    let id = parent.child(&child.key);
610    let grandchildren: Vec<A11yNodeId> = child
611        .children
612        .iter()
613        .map(|grandchild| id.child(&grandchild.key))
614        .collect();
615    nodes.push(A11yTreeNode {
616        id,
617        info: child.info.clone(),
618        bounds: child.bounds,
619        children: grandchildren,
620    });
621    for grandchild in &child.children {
622        push_child(nodes, id, grandchild);
623    }
624}
625
626fn union(a: Rect, b: Rect) -> Rect {
627    let x = a.x.min(b.x);
628    let y = a.y.min(b.y);
629    let right = (a.x + a.width).max(b.x + b.width);
630    let bottom = (a.y + a.height).max(b.y + b.height);
631    Rect::new(x, y, right - x, bottom - y)
632}
633
634/// What a primitive contributes to the tree.
635struct Described {
636    info: A11yInfo,
637    children: Vec<A11yChild>,
638    focus_key: Option<String>,
639}
640
641impl Described {
642    fn from_primitive(primitive: &dyn A11yPrimitive) -> Self {
643        Self {
644            info: primitive.a11y_info(),
645            children: primitive.a11y_children(),
646            focus_key: primitive.a11y_focus_key(),
647        }
648    }
649
650    /// The generic fallback: a group labelled with the primitive's kind,
651    /// so an unmapped primitive still reads as *something*.
652    fn fallback(content: &OwnedContent) -> Self {
653        Self {
654            info: A11yInfo::new()
655                .with_role(A11yRole::Group)
656                .with_label(content_kind(content)),
657            children: Vec::new(),
658            focus_key: None,
659        }
660    }
661}
662
663fn describe(content: &OwnedContent) -> Described {
664    match content {
665        OwnedContent::List(list) => Described::from_primitive(list),
666        OwnedContent::Toolbar(bar) => Described::from_primitive(bar),
667        other => Described::fallback(other),
668    }
669}
670
671/// Field-by-field override: anything the consumer set on
672/// [`crate::frame::OwnedNode::a11y`] wins, everything else keeps the
673/// primitive's derived value.
674fn merge(base: A11yInfo, over: &A11yInfo) -> A11yInfo {
675    let mut merged = base;
676    merged.a11y_role = over.a11y_role.or(merged.a11y_role);
677    merged.a11y_label = over.a11y_label.clone().or(merged.a11y_label);
678    merged.selected = over.selected.or(merged.selected);
679    merged.expanded = over.expanded.or(merged.expanded);
680    merged.checked = over.checked.or(merged.checked);
681    merged.disabled = over.disabled.or(merged.disabled);
682    merged.read_only = over.read_only.or(merged.read_only);
683    merged.value = over.value.clone().or(merged.value);
684    merged.value_range = over.value_range.or(merged.value_range);
685    merged.level = over.level.or(merged.level);
686    merged.position_in_set = over.position_in_set.or(merged.position_in_set);
687    merged.set_size = over.set_size.or(merged.set_size);
688    merged.live = over.live.or(merged.live);
689    merged
690}
691
692/// Human-readable kind name for the generic fallback label.
693fn content_kind(content: &OwnedContent) -> &'static str {
694    match content {
695        OwnedContent::Editor(_) => "Editor",
696        OwnedContent::TabBar(_) => "Tab bar",
697        OwnedContent::StatusBar(_) => "Status bar",
698        OwnedContent::ActivityBar(_) => "Activity bar",
699        OwnedContent::CommandLine(_) => "Command line",
700        OwnedContent::Terminal(_) => "Terminal",
701        OwnedContent::TextDisplay(_) => "Text",
702        OwnedContent::MultiSectionView(_) => "Multi-section view",
703        OwnedContent::Tree(_) => "Tree",
704        OwnedContent::List(_) => "List",
705        OwnedContent::Form(_) => "Form",
706        OwnedContent::MenuBar(_) => "Menu bar",
707        OwnedContent::Split(_) => "Split",
708        OwnedContent::Panel(_) => "Panel",
709        OwnedContent::Scrollbar(_) => "Scrollbar",
710        OwnedContent::Palette(_) => "Palette",
711        OwnedContent::Tooltip(_) => "Tooltip",
712        OwnedContent::ContextMenu(_) => "Context menu",
713        OwnedContent::Dialog(_) => "Dialog",
714        OwnedContent::Float(_) => "Floating panel",
715        OwnedContent::Completions(_) => "Completions",
716        OwnedContent::RichTextPopup(_) => "Rich text popup",
717        OwnedContent::Toast(_) => "Notifications",
718        OwnedContent::DataTable(_) => "Table",
719        OwnedContent::Chart(_) => "Chart",
720        OwnedContent::Canvas(_) => "Canvas",
721        OwnedContent::Board(_) => "Board",
722        OwnedContent::CommandCenter(_) => "Command center",
723        OwnedContent::DiffView(_) => "Diff view",
724        OwnedContent::Image(_) => "Image",
725        OwnedContent::MessageList(_) => "Message list",
726        OwnedContent::Minimap(_) => "Minimap",
727        OwnedContent::PipelineView(_) => "Pipeline view",
728        OwnedContent::Progress(_) => "Progress",
729        OwnedContent::SidebarPanel(_) => "Sidebar panel",
730        OwnedContent::Spinner(_) => "Spinner",
731        OwnedContent::SplitTree(_) => "Split tree",
732        OwnedContent::TextInput(_) => "Text input",
733        OwnedContent::Toolbar(_) => "Toolbar",
734        OwnedContent::DropOverlay => "Drop overlay",
735        OwnedContent::FindReplace => "Find and replace",
736        OwnedContent::Opaque => "Content",
737    }
738}
739
740/// Snake-case name of a role, as it appears in a snapshot line.
741fn role_name(role: A11yRole) -> &'static str {
742    match role {
743        A11yRole::Window => "window",
744        A11yRole::Toolbar => "toolbar",
745        A11yRole::Button => "button",
746        A11yRole::Tab => "tab",
747        A11yRole::TabList => "tab_list",
748        A11yRole::Menu => "menu",
749        A11yRole::MenuItem => "menu_item",
750        A11yRole::Tree => "tree",
751        A11yRole::TreeItem => "tree_item",
752        A11yRole::List => "list",
753        A11yRole::ListItem => "list_item",
754        A11yRole::Table => "table",
755        A11yRole::Row => "row",
756        A11yRole::Cell => "cell",
757        A11yRole::TextInput => "text_input",
758        A11yRole::Document => "document",
759        A11yRole::Editor => "editor",
760        A11yRole::Terminal => "terminal",
761        A11yRole::Dialog => "dialog",
762        A11yRole::Status => "status",
763        A11yRole::Progress => "progress",
764        A11yRole::Image => "image",
765        A11yRole::Group => "group",
766        A11yRole::Presentation => "presentation",
767    }
768}
769
770/// The state tokens for a snapshot line, in a fixed order so snapshots
771/// are stable.
772fn state_tokens(node: &A11yTreeNode, focused: bool) -> Vec<String> {
773    let info = &node.info;
774    let mut tokens = Vec::new();
775    if focused {
776        tokens.push("focused".to_string());
777    }
778    if info.selected == Some(true) {
779        tokens.push("selected".to_string());
780    }
781    match info.expanded {
782        Some(true) => tokens.push("expanded".to_string()),
783        Some(false) => tokens.push("collapsed".to_string()),
784        None => {}
785    }
786    match info.checked {
787        Some(A11yChecked::True) => tokens.push("checked".to_string()),
788        Some(A11yChecked::False) => tokens.push("unchecked".to_string()),
789        Some(A11yChecked::Mixed) => tokens.push("mixed".to_string()),
790        None => {}
791    }
792    if info.disabled == Some(true) {
793        tokens.push("disabled".to_string());
794    }
795    if info.read_only == Some(true) {
796        tokens.push("readonly".to_string());
797    }
798    if let Some(value) = &info.value {
799        tokens.push(format!("value=\"{value}\""));
800    }
801    if let Some(range) = info.value_range {
802        tokens.push(format!(
803            "range={}/{}-{}",
804            range.current, range.min, range.max
805        ));
806    }
807    if let Some(level) = info.level {
808        tokens.push(format!("level={level}"));
809    }
810    match (info.position_in_set, info.set_size) {
811        (Some(pos), Some(size)) => tokens.push(format!("pos={pos}/{size}")),
812        (Some(pos), None) => tokens.push(format!("pos={pos}")),
813        (None, Some(size)) => tokens.push(format!("size={size}")),
814        (None, None) => {}
815    }
816    match info.live {
817        Some(A11yLive::Polite) => tokens.push("live=polite".to_string()),
818        Some(A11yLive::Assertive) => tokens.push("live=assertive".to_string()),
819        Some(A11yLive::Off) | None => {}
820    }
821    tokens
822}
823
824/// Plain text of a styled run, for use as an accessible name.
825fn plain_text(text: &StyledText) -> String {
826    text.spans.iter().map(|span| span.text.as_str()).collect()
827}
828
829fn non_empty(text: String) -> Option<String> {
830    if text.trim().is_empty() {
831        None
832    } else {
833        Some(text)
834    }
835}
836
837impl A11yPrimitive for ListView {
838    fn a11y_info(&self) -> A11yInfo {
839        let mut info = A11yInfo::new()
840            .with_role(A11yRole::List)
841            .with_set_size(self.items.len() as u32);
842        if let Some(label) = self.title.as_ref().and_then(|t| non_empty(plain_text(t))) {
843            info = info.with_label(label);
844        }
845        info
846    }
847
848    fn a11y_children(&self) -> Vec<A11yChild> {
849        let set_size = self.items.len() as u32;
850        self.items
851            .iter()
852            .enumerate()
853            .map(|(idx, item)| {
854                let mut info = A11yInfo::new()
855                    .with_role(A11yRole::ListItem)
856                    .with_selected(idx == self.selected_idx)
857                    .with_position_in_set(idx as u32 + 1)
858                    .with_set_size(set_size);
859                if let Some(label) = non_empty(plain_text(&item.text)) {
860                    info = info.with_label(label);
861                }
862                if let Some(detail) = item.detail.as_ref().and_then(|d| non_empty(plain_text(d))) {
863                    info = info.with_value(detail);
864                }
865                A11yChild::new(format!("item.{idx}"), info)
866            })
867            .collect()
868    }
869
870    fn a11y_focus_key(&self) -> Option<String> {
871        if self.selected_idx < self.items.len() {
872            Some(format!("item.{}", self.selected_idx))
873        } else {
874            None
875        }
876    }
877}
878
879impl A11yPrimitive for Toolbar {
880    fn a11y_info(&self) -> A11yInfo {
881        A11yInfo::new().with_role(A11yRole::Toolbar)
882    }
883
884    fn a11y_children(&self) -> Vec<A11yChild> {
885        self.buttons
886            .iter()
887            .enumerate()
888            .map(|(idx, button)| toolbar_child(idx, button))
889            .collect()
890    }
891
892    fn a11y_focus_key(&self) -> Option<String> {
893        let idx = self.focused_index?;
894        let button = self.buttons.get(idx)?;
895        Some(toolbar_child_key(idx, button))
896    }
897}
898
899/// Stable per-toolbar key for a button: its widget id where it has one,
900/// so reordering the bar doesn't rename it.
901fn toolbar_child_key(idx: usize, button: &ToolbarButton) -> String {
902    match button {
903        ToolbarButton::Action { id, .. } => format!("button.{}", id.as_str()),
904        ToolbarButton::Separator => format!("separator.{idx}"),
905        ToolbarButton::Label { .. } => format!("label.{idx}"),
906    }
907}
908
909fn toolbar_child(idx: usize, button: &ToolbarButton) -> A11yChild {
910    let key = toolbar_child_key(idx, button);
911    let info = match button {
912        ToolbarButton::Action {
913            label,
914            enabled,
915            is_active,
916            tooltip,
917            ..
918        } => {
919            let mut info = A11yInfo::new()
920                .with_role(A11yRole::Button)
921                .with_disabled(!*enabled);
922            if let Some(label) = non_empty(label.clone()) {
923                info = info.with_label(label);
924            }
925            if *is_active {
926                info = info.with_checked(A11yChecked::True);
927            }
928            if let Some(tooltip) = non_empty(tooltip.clone()) {
929                info = info.with_value(tooltip);
930            }
931            info
932        }
933        // A separator is pure visual grouping: an assistive technology
934        // should step straight over it.
935        ToolbarButton::Separator => A11yInfo::new().with_role(A11yRole::Presentation),
936        // Static text in a toolbar has no interactive role; it reads as
937        // a labelled group so its text is still announced.
938        ToolbarButton::Label { text, .. } => {
939            let mut info = A11yInfo::new().with_role(A11yRole::Group);
940            if let Some(label) = non_empty(text.clone()) {
941                info = info.with_label(label);
942            }
943            info
944        }
945    };
946    A11yChild::new(key, info)
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952    use crate::frame::{ScreenLayout, Surface};
953    use crate::primitives::list::ListItem;
954    use crate::primitives::scrollbar::Scrollbar;
955    use crate::primitives::toolbar::ToolbarButton;
956    use crate::types::{Decoration, WidgetId};
957
958    fn list(id: &str, title: Option<&str>, items: &[&str], selected: usize) -> ListView {
959        ListView {
960            id: WidgetId::new(id),
961            title: title.map(StyledText::plain),
962            items: items
963                .iter()
964                .map(|text| ListItem {
965                    text: StyledText::plain(*text),
966                    icon: None,
967                    detail: None,
968                    decoration: Decoration::default(),
969                })
970                .collect(),
971            selected_idx: selected,
972            scroll_offset: 0,
973            has_focus: false,
974            bordered: false,
975            h_scroll: 0,
976            max_content_width: None,
977            show_v_scrollbar: false,
978        }
979    }
980
981    fn toolbar(id: &str) -> Toolbar {
982        Toolbar::new(WidgetId::new(id)).with_buttons(vec![
983            ToolbarButton::Action {
984                id: WidgetId::new("run"),
985                label: "Run".to_string(),
986                icon: None,
987                key_hint: None,
988                enabled: true,
989                is_active: false,
990                tooltip: String::new(),
991            },
992            ToolbarButton::Separator,
993            ToolbarButton::Action {
994                id: WidgetId::new("stop"),
995                label: "Stop".to_string(),
996                icon: None,
997                key_hint: None,
998                enabled: false,
999                is_active: false,
1000                tooltip: String::new(),
1001            },
1002        ])
1003    }
1004
1005    fn frame_with(surfaces: Vec<Surface>) -> OwnedFrame {
1006        let mut layout = ScreenLayout::new();
1007        for surface in surfaces {
1008            layout.push(surface);
1009        }
1010        OwnedFrame::from_layout(&layout)
1011    }
1012
1013    fn list_surface() -> Surface {
1014        Surface::List {
1015            rect: Rect::new(0.0, 0.0, 30.0, 10.0),
1016            list: list("files", Some("Files"), &["a.rs", "b.rs", "c.rs"], 1),
1017        }
1018    }
1019
1020    /// A surface whose primitive has no `a11y_info` hook and whose
1021    /// capture carries no `WidgetId` — the generic-fallback case.
1022    fn scrollbar_surface() -> Surface {
1023        let rect = Rect::new(29.0, 0.0, 1.0, 10.0);
1024        Surface::Scrollbar {
1025            rect,
1026            sb: Scrollbar::vertical(WidgetId::new("files.vscroll"), rect, 0.0, 30.0, 10.0, 1.0),
1027        }
1028    }
1029
1030    fn toolbar_surface() -> Surface {
1031        Surface::Toolbar {
1032            rect: Rect::new(0.0, 10.0, 30.0, 2.0),
1033            bar: toolbar("tools"),
1034            hovered: None,
1035            pressed: None,
1036        }
1037    }
1038
1039    #[test]
1040    fn root_is_a_window_covering_every_surface() {
1041        let frame = frame_with(vec![list_surface(), toolbar_surface()]);
1042        let tree = frame_to_tree(&frame, &FocusManager::new());
1043        let root = tree.node(tree.root()).expect("root node");
1044        assert_eq!(root.role(), A11yRole::Window);
1045        assert_eq!(root.label(), Some("quadraui"));
1046        assert_eq!(root.children.len(), 2, "one child per surface");
1047        assert_eq!(root.bounds, Some(Rect::new(0.0, 0.0, 30.0, 12.0)));
1048    }
1049
1050    #[test]
1051    fn list_maps_to_a_list_with_item_children() {
1052        let frame = frame_with(vec![list_surface()]);
1053        let tree = frame_to_tree(&frame, &FocusManager::new());
1054        assert_eq!(
1055            tree.to_snapshot(),
1056            "window \"quadraui\" [focused]\n  \
1057             list \"Files\" [size=3]\n    \
1058             list_item \"a.rs\" [pos=1/3]\n    \
1059             list_item \"b.rs\" [selected pos=2/3]\n    \
1060             list_item \"c.rs\" [pos=3/3]\n"
1061        );
1062    }
1063
1064    #[test]
1065    fn toolbar_maps_buttons_with_disabled_state() {
1066        let frame = frame_with(vec![toolbar_surface()]);
1067        let tree = frame_to_tree(&frame, &FocusManager::new());
1068        assert_eq!(
1069            tree.to_snapshot(),
1070            "window \"quadraui\" [focused]\n  \
1071             toolbar\n    \
1072             button \"Run\"\n    \
1073             presentation\n    \
1074             button \"Stop\" [disabled]\n"
1075        );
1076    }
1077
1078    #[test]
1079    fn unmapped_primitive_is_a_labelled_group() {
1080        let frame = frame_with(vec![scrollbar_surface()]);
1081        let tree = frame_to_tree(&frame, &FocusManager::new());
1082        let node = tree
1083            .children_of(tree.root())
1084            .first()
1085            .copied()
1086            .expect("surface node");
1087        assert_eq!(node.role(), A11yRole::Group);
1088        assert_eq!(node.label(), Some("Scrollbar"));
1089    }
1090
1091    #[test]
1092    fn node_ids_are_stable_across_frames_and_positions() {
1093        let first = frame_with(vec![list_surface(), toolbar_surface()]);
1094        // Same widgets, reordered and moved.
1095        let second = frame_with(vec![
1096            Surface::Toolbar {
1097                rect: Rect::new(0.0, 0.0, 30.0, 2.0),
1098                bar: toolbar("tools"),
1099                hovered: None,
1100                pressed: None,
1101            },
1102            Surface::List {
1103                rect: Rect::new(0.0, 2.0, 30.0, 10.0),
1104                list: list("files", Some("Files"), &["a.rs", "b.rs", "c.rs"], 1),
1105            },
1106        ]);
1107        let fm = FocusManager::new();
1108        let a = frame_to_tree(&first, &fm);
1109        let b = frame_to_tree(&second, &fm);
1110        let list_id = A11yNodeId::for_widget(&WidgetId::new("files"));
1111        assert_eq!(a.node(list_id).map(|n| n.id), Some(list_id));
1112        assert_eq!(b.node(list_id).map(|n| n.id), Some(list_id));
1113        assert_eq!(
1114            a.find("b.rs").map(|n| n.id),
1115            b.find("b.rs").map(|n| n.id),
1116            "a row keeps its id when its parent moves"
1117        );
1118    }
1119
1120    #[test]
1121    fn focus_follows_the_focus_manager_into_the_active_row() {
1122        let frame = frame_with(vec![list_surface(), toolbar_surface()]);
1123        let mut fm = FocusManager::new();
1124        let unfocused = frame_to_tree(&frame, &fm);
1125        assert_eq!(
1126            unfocused.focus(),
1127            unfocused.root(),
1128            "nothing focused parks focus on the root"
1129        );
1130
1131        fm.set_focus(Some(WidgetId::new("files")));
1132        let tree = frame_to_tree(&frame, &fm);
1133        assert_eq!(
1134            tree.focused().and_then(|n| n.label()),
1135            Some("b.rs"),
1136            "a focused list points focus at its selected row"
1137        );
1138        assert!(tree.to_snapshot().contains("list_item \"b.rs\" [focused"));
1139    }
1140
1141    #[test]
1142    fn focus_lands_on_the_focused_toolbar_button() {
1143        let mut bar = toolbar("tools");
1144        bar.focused_index = Some(2);
1145        let frame = frame_with(vec![Surface::Toolbar {
1146            rect: Rect::new(0.0, 0.0, 30.0, 2.0),
1147            bar,
1148            hovered: None,
1149            pressed: None,
1150        }]);
1151        let mut fm = FocusManager::new();
1152        fm.set_focus(Some(WidgetId::new("tools")));
1153        let tree = frame_to_tree(&frame, &fm);
1154        assert_eq!(tree.focused().and_then(|n| n.label()), Some("Stop"));
1155    }
1156
1157    #[test]
1158    fn consumer_a11y_override_wins_field_by_field() {
1159        let mut frame = frame_with(vec![list_surface()]);
1160        frame.nodes_mut()[0].a11y = Some(A11yInfo::new().with_label("Open editors"));
1161        let tree = frame_to_tree(&frame, &FocusManager::new());
1162        let node = tree
1163            .children_of(tree.root())
1164            .first()
1165            .copied()
1166            .expect("surface node");
1167        assert_eq!(node.label(), Some("Open editors"), "label is overridden");
1168        assert_eq!(node.role(), A11yRole::List, "derived role survives");
1169    }
1170
1171    #[test]
1172    fn first_update_is_full_then_only_changed_nodes_are_emitted() {
1173        let mut builder = A11yTreeBuilder::new();
1174        let frame = frame_with(vec![list_surface(), toolbar_surface()]);
1175        let fm = FocusManager::new();
1176
1177        let first = builder.update(&frame, &fm);
1178        assert!(first.full);
1179        assert_eq!(first.root, Some(A11yNodeId::ROOT));
1180        assert_eq!(first.nodes.len(), frame_to_tree(&frame, &fm).nodes().len());
1181
1182        let second = builder.update(&frame, &fm);
1183        assert!(!second.full);
1184        assert_eq!(second.root, None, "the tree is only declared once");
1185        assert!(
1186            second.nodes.is_empty(),
1187            "an unchanged frame emits no nodes, got {:?}",
1188            second.nodes
1189        );
1190        assert_eq!(second.focus, first.focus, "focus is always reported");
1191    }
1192
1193    #[test]
1194    fn changed_selection_emits_only_the_affected_rows() {
1195        let mut builder = A11yTreeBuilder::new();
1196        let fm = FocusManager::new();
1197        builder.update(&frame_with(vec![list_surface()]), &fm);
1198
1199        let moved = frame_with(vec![Surface::List {
1200            rect: Rect::new(0.0, 0.0, 30.0, 10.0),
1201            list: list("files", Some("Files"), &["a.rs", "b.rs", "c.rs"], 2),
1202        }]);
1203        let update = builder.update(&moved, &fm);
1204        let labels: Vec<_> = update.nodes.iter().filter_map(|n| n.label()).collect();
1205        assert_eq!(
1206            labels,
1207            vec!["b.rs", "c.rs"],
1208            "only the deselected and newly selected rows changed"
1209        );
1210    }
1211
1212    #[test]
1213    fn removing_a_row_re_emits_the_parent_without_it() {
1214        let mut builder = A11yTreeBuilder::new();
1215        let fm = FocusManager::new();
1216        builder.update(&frame_with(vec![list_surface()]), &fm);
1217
1218        let shorter = frame_with(vec![Surface::List {
1219            rect: Rect::new(0.0, 0.0, 30.0, 10.0),
1220            list: list("files", Some("Files"), &["a.rs", "b.rs"], 1),
1221        }]);
1222        let update = builder.update(&shorter, &fm);
1223        let list_id = A11yNodeId::for_widget(&WidgetId::new("files"));
1224        let parent = update
1225            .nodes
1226            .iter()
1227            .find(|n| n.id == list_id)
1228            .expect("parent is re-emitted when its child list shrinks");
1229        assert_eq!(parent.children.len(), 2);
1230        let gone = list_id.child("item.2");
1231        assert!(
1232            !update.nodes.iter().any(|n| n.id == gone),
1233            "the removed row is not part of the update"
1234        );
1235    }
1236
1237    #[test]
1238    fn reset_forces_a_full_tree_again() {
1239        let mut builder = A11yTreeBuilder::new();
1240        let frame = frame_with(vec![list_surface()]);
1241        let fm = FocusManager::new();
1242        builder.update(&frame, &fm);
1243        builder.reset();
1244        let again = builder.update(&frame, &fm);
1245        assert!(again.full);
1246        assert_eq!(again.root, Some(A11yNodeId::ROOT));
1247    }
1248
1249    #[test]
1250    fn root_label_is_overridable() {
1251        let builder = A11yTreeBuilder::new().with_root_label("kubeui");
1252        let tree = builder.tree(&frame_with(vec![list_surface()]), &FocusManager::new());
1253        assert_eq!(
1254            tree.node(tree.root()).and_then(|n| n.label()),
1255            Some("kubeui")
1256        );
1257    }
1258
1259    #[test]
1260    fn surfaces_without_a_widget_id_still_appear() {
1261        let frame = frame_with(vec![scrollbar_surface()]);
1262        let tree = frame_to_tree(&frame, &FocusManager::new());
1263        assert_eq!(tree.nodes().len(), 2, "root plus the id-less surface");
1264        assert_eq!(
1265            tree.children_of(tree.root())[0].id,
1266            A11yNodeId::for_key("surface.0")
1267        );
1268    }
1269
1270    #[cfg(feature = "a11y")]
1271    #[test]
1272    fn tree_update_carries_tree_info_focus_and_nodes() {
1273        let mut builder = A11yTreeBuilder::new();
1274        let frame = frame_with(vec![list_surface()]);
1275        let mut fm = FocusManager::new();
1276        fm.set_focus(Some(WidgetId::new("files")));
1277
1278        let update = builder.tree_update(&frame, &fm);
1279        let tree_info = update.tree.expect("first update declares the tree");
1280        assert_eq!(tree_info.root, A11yNodeId::ROOT.to_accesskit());
1281        assert_eq!(update.tree_id, accesskit::TreeId::ROOT);
1282        let list_id = A11yNodeId::for_widget(&WidgetId::new("files"));
1283        assert_eq!(update.focus, list_id.child("item.1").to_accesskit());
1284
1285        let (_, list_node) = update
1286            .nodes
1287            .iter()
1288            .find(|(id, _)| *id == list_id.to_accesskit())
1289            .expect("the list node is in the update");
1290        assert_eq!(list_node.role(), accesskit::Role::List);
1291        assert_eq!(list_node.label(), Some("Files"));
1292        assert_eq!(list_node.children().len(), 3);
1293        assert_eq!(
1294            list_node.bounds(),
1295            Some(accesskit::Rect::new(0.0, 0.0, 30.0, 10.0))
1296        );
1297
1298        let (_, row) = update
1299            .nodes
1300            .iter()
1301            .find(|(id, _)| *id == list_id.child("item.1").to_accesskit())
1302            .expect("the selected row is in the update");
1303        assert_eq!(row.is_selected(), Some(true));
1304        assert_eq!(
1305            row.position_in_set(),
1306            Some(1),
1307            "accesskit's position_in_set is zero-based"
1308        );
1309        assert_eq!(row.size_of_set(), Some(3));
1310
1311        let unchanged = builder.tree_update(&frame, &fm);
1312        assert!(unchanged.nodes.is_empty());
1313        assert!(unchanged.tree.is_none());
1314        assert_eq!(unchanged.focus, update.focus);
1315    }
1316}