Skip to main content

stipple_core/
a11y.rs

1//! Accessibility tree: a semantic view of the UI for assistive technologies.
2//!
3//! [`accessibility_tree`] walks the retained [`LayoutNode`] tree and produces a
4//! pruned [`AccessNode`] tree — the platform-neutral abstraction a future
5//! AT-SPI (Linux), UI Automation (Windows), or `NSAccessibility` (macOS) backend
6//! would expose to screen readers. Each node carries a [`Role`], an accessible
7//! `name`, its `bounds`, and whether it currently holds focus.
8//!
9//! Roles are inferred from how an element behaves: a tap handler makes it a
10//! [`Role::Button`], a keyboard/focus handle a [`Role::TextField`], bare text a
11//! [`Role::Text`]; the rest are [`Role::Group`] containers (with the tree root
12//! reported as the [`Role::Window`]). Interactive nodes absorb their text
13//! descendants as their `name`, and purely decorative containers (no name, no
14//! children) are dropped, so the tree stays small and meaningful.
15//!
16//! This is the data model only; wiring it to each OS accessibility API is
17//! future work (ROADMAP.md).
18
19use crate::runtime::{FocusId, LayoutNode, NodeContent, first_text};
20use stipple_geometry::Rect;
21
22/// The semantic kind of an [`AccessNode`].
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum Role {
25    /// The top-level window (tree root).
26    Window,
27    /// A non-interactive container.
28    Group,
29    /// An activatable control (has a tap handler).
30    Button,
31    /// An editable text field (focusable / keyboard target).
32    TextField,
33    /// A run of static text.
34    Text,
35}
36
37/// A node in the accessibility tree.
38#[derive(Clone, Debug, PartialEq)]
39pub struct AccessNode {
40    pub role: Role,
41    /// The accessible name (a control's label, or the text itself).
42    pub name: String,
43    pub bounds: Rect,
44    /// Whether this node currently holds keyboard focus.
45    pub focused: bool,
46    pub children: Vec<AccessNode>,
47}
48
49/// The accessible text at or under `node` (a control's label / a field's value).
50fn text_of(node: &LayoutNode) -> String {
51    match first_text(node).map(|n| &n.content) {
52        Some(NodeContent::Text { text, .. }) => text.clone(),
53        _ => String::new(),
54    }
55}
56
57/// Infer a node's role from its interaction handles and content.
58fn role_of(node: &LayoutNode) -> Role {
59    if node.action.is_some() {
60        Role::Button
61    } else if node.focus.is_some() {
62        Role::TextField
63    } else if matches!(node.content, NodeContent::Text { .. }) {
64        Role::Text
65    } else {
66        Role::Group
67    }
68}
69
70fn build(node: &LayoutNode, focused: Option<FocusId>, is_root: bool) -> AccessNode {
71    let role = if is_root { Role::Window } else { role_of(node) };
72    // Interactive/text roles take their name from their text and absorb their
73    // descendants; containers recurse and expose their meaningful children.
74    let (name, children) = match role {
75        Role::Button | Role::TextField | Role::Text => (text_of(node), Vec::new()),
76        Role::Window | Role::Group => (
77            String::new(),
78            node.children
79                .iter()
80                .map(|c| build(c, focused, false))
81                .filter(|a| !a.is_prunable())
82                .collect(),
83        ),
84    };
85    AccessNode {
86        role,
87        name,
88        bounds: node.bounds,
89        focused: node.focus.is_some() && node.focus == focused,
90        children,
91    }
92}
93
94impl AccessNode {
95    /// A container with no name and no children carries no semantics, so it can
96    /// be dropped from the tree.
97    fn is_prunable(&self) -> bool {
98        self.role == Role::Group && self.name.is_empty() && self.children.is_empty()
99    }
100
101    /// Total node count (including this one), for tests/inspection.
102    pub fn count(&self) -> usize {
103        1 + self.children.iter().map(AccessNode::count).sum::<usize>()
104    }
105
106    /// Depth-first iterator visiting this node and all descendants.
107    pub fn descendants(&self) -> Vec<&AccessNode> {
108        let mut out = vec![self];
109        for c in &self.children {
110            out.extend(c.descendants());
111        }
112        out
113    }
114}
115
116/// Build the accessibility tree for a laid-out UI, marking the node that holds
117/// `focused` (if any). The root is reported as a [`Role::Window`].
118pub fn accessibility_tree(root: &LayoutNode, focused: Option<FocusId>) -> AccessNode {
119    build(root, focused, true)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::{Align, Axis, BoxStyle, Cx, Element, layout};
126    use stipple_render::Color;
127    use stipple_style::Theme;
128
129    /// Lay a small interactive view out and return its accessibility tree.
130    fn tree(focused: Option<FocusId>) -> AccessNode {
131        let theme = Theme::light();
132        let mut cx = Cx::<()>::new(&theme);
133        let field = Element::stack(
134            Axis::Horizontal,
135            vec![Element::text("hello", 14.0, Color::BLACK)],
136        )
137        .on_key(&mut cx, |_: &mut (), _| {});
138        let button = Element::stack(
139            Axis::Horizontal,
140            vec![Element::text("OK", 14.0, Color::BLACK)],
141        )
142        .on_tap(&mut cx, |_: &mut ()| {});
143        let root = Element::stack(
144            Axis::Vertical,
145            vec![Element::text("Title", 18.0, Color::BLACK), field, button],
146        )
147        .align(Align::Start, Align::Stretch)
148        .fill(Color::WHITE)
149        .padding(stipple_geometry::Insets::uniform(4.0));
150        let laid = layout(&root, Rect::from_xywh(0.0, 0.0, 200.0, 120.0), None);
151        accessibility_tree(&laid, focused)
152    }
153
154    #[test]
155    fn derives_roles_and_names() {
156        let a = tree(None);
157        assert_eq!(a.role, Role::Window);
158        // The root exposes: a Title (Text), the field (TextField), the button.
159        let roles: Vec<Role> = a.children.iter().map(|c| c.role).collect();
160        assert_eq!(roles, vec![Role::Text, Role::TextField, Role::Button]);
161        assert_eq!(a.children[0].name, "Title");
162        assert_eq!(a.children[1].name, "hello"); // field value
163        assert_eq!(a.children[2].name, "OK"); // button label
164        // Interactive nodes absorbed their text children.
165        assert!(a.children[1].children.is_empty());
166        assert!(a.children[2].children.is_empty());
167    }
168
169    #[test]
170    fn marks_the_focused_field() {
171        // Focus the text field (the only focusable, id 0).
172        let a = tree(Some(FocusId(0)));
173        let field = a
174            .descendants()
175            .into_iter()
176            .find(|n| n.role == Role::TextField)
177            .unwrap();
178        assert!(field.focused);
179        // Nothing else is focused.
180        assert_eq!(a.descendants().iter().filter(|n| n.focused).count(), 1);
181    }
182
183    #[test]
184    fn prunes_decorative_containers() {
185        // A panel wrapping an empty decorative box plus a label.
186        let root = Element::stack(
187            Axis::Vertical,
188            vec![
189                Element::boxed(BoxStyle::default()).width(10.0).height(10.0), // decorative
190                Element::text("Label", 14.0, Color::BLACK),
191            ],
192        );
193        let laid = layout(&root, Rect::from_xywh(0.0, 0.0, 100.0, 100.0), None);
194        let a = accessibility_tree(&laid, None);
195        // Only the text survives; the empty box is pruned.
196        assert_eq!(a.children.len(), 1);
197        assert_eq!(a.children[0].role, Role::Text);
198    }
199}