Skip to main content

agpu/
accessibility.rs

1//! Accessibility — platform accessibility integration.
2//!
3//! Provides [`AccessibilityNode`], [`Role`], and [`AccessibilityTree`]
4//! for building the accessibility tree exposed to platform APIs
5//! (Windows UI Automation, macOS Accessibility, AT-SPI on Linux).
6
7use serde::{Deserialize, Serialize};
8
9/// Platform-independent accessible role.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum Role {
12    Window,
13    Button,
14    TextInput,
15    Label,
16    Checkbox,
17    RadioButton,
18    Slider,
19    ProgressBar,
20    List,
21    ListItem,
22    Table,
23    TableRow,
24    TableCell,
25    Menu,
26    MenuItem,
27    MenuBar,
28    Tab,
29    TabPanel,
30    Tree,
31    TreeItem,
32    Dialog,
33    Tooltip,
34    ScrollBar,
35    Toolbar,
36    Group,
37    Image,
38    Link,
39    Separator,
40    Custom,
41}
42
43impl std::fmt::Display for Role {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(f, "{self:?}")
46    }
47}
48
49/// ARIA-style state flags for an accessible element.
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51pub struct AccessibleState {
52    pub checked: Option<bool>,
53    pub selected: bool,
54    pub expanded: Option<bool>,
55    pub disabled: bool,
56    pub focused: bool,
57    pub read_only: bool,
58    pub required: bool,
59    pub value_now: Option<f64>,
60    pub value_min: Option<f64>,
61    pub value_max: Option<f64>,
62    pub value_text: Option<String>,
63}
64
65/// A single node in the accessibility tree.
66#[derive(Debug, Clone)]
67pub struct AccessibilityNode {
68    pub id: String,
69    pub role: Role,
70    pub label: Option<String>,
71    pub description: Option<String>,
72    pub state: AccessibleState,
73    pub children: Vec<AccessibilityNode>,
74}
75
76impl AccessibilityNode {
77    pub fn new(id: impl Into<String>, role: Role) -> Self {
78        Self {
79            id: id.into(),
80            role,
81            label: None,
82            description: None,
83            state: AccessibleState::default(),
84            children: Vec::new(),
85        }
86    }
87
88    pub fn label(mut self, label: impl Into<String>) -> Self {
89        self.label = Some(label.into());
90        self
91    }
92
93    pub fn description(mut self, desc: impl Into<String>) -> Self {
94        self.description = Some(desc.into());
95        self
96    }
97
98    pub fn state(mut self, state: AccessibleState) -> Self {
99        self.state = state;
100        self
101    }
102
103    pub fn child(mut self, child: AccessibilityNode) -> Self {
104        self.children.push(child);
105        self
106    }
107
108    /// Find a node by ID (depth-first).
109    pub fn find(&self, id: &str) -> Option<&AccessibilityNode> {
110        if self.id == id {
111            return Some(self);
112        }
113        for child in &self.children {
114            if let Some(found) = child.find(id) {
115                return Some(found);
116            }
117        }
118        None
119    }
120
121    /// Find all nodes with a given role.
122    pub fn find_by_role(&self, role: Role) -> Vec<&AccessibilityNode> {
123        let mut results = Vec::new();
124        if self.role == role {
125            results.push(self);
126        }
127        for child in &self.children {
128            results.extend(child.find_by_role(role));
129        }
130        results
131    }
132
133    /// Total number of nodes (including self).
134    pub fn count(&self) -> usize {
135        1 + self.children.iter().map(|c| c.count()).sum::<usize>()
136    }
137}
138
139/// The root accessibility tree for the application.
140pub struct AccessibilityTree {
141    root: Option<AccessibilityNode>,
142}
143
144impl AccessibilityTree {
145    pub fn new() -> Self {
146        Self { root: None }
147    }
148
149    pub fn set_root(&mut self, root: AccessibilityNode) {
150        self.root = Some(root);
151    }
152
153    pub fn root(&self) -> Option<&AccessibilityNode> {
154        self.root.as_ref()
155    }
156
157    pub fn find(&self, id: &str) -> Option<&AccessibilityNode> {
158        self.root.as_ref()?.find(id)
159    }
160
161    pub fn find_by_role(&self, role: Role) -> Vec<&AccessibilityNode> {
162        match &self.root {
163            Some(root) => root.find_by_role(role),
164            None => Vec::new(),
165        }
166    }
167
168    pub fn node_count(&self) -> usize {
169        self.root.as_ref().map_or(0, |r| r.count())
170    }
171}
172
173impl Default for AccessibilityTree {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn role_display() {
185        assert_eq!(format!("{}", Role::Button), "Button");
186    }
187
188    #[test]
189    fn node_builder() {
190        let node = AccessibilityNode::new("btn1", Role::Button)
191            .label("Submit")
192            .description("Submit the form");
193        assert_eq!(node.label.as_deref(), Some("Submit"));
194        assert_eq!(node.role, Role::Button);
195    }
196
197    #[test]
198    fn node_children() {
199        let tree = AccessibilityNode::new("root", Role::Window)
200            .child(AccessibilityNode::new("btn1", Role::Button).label("OK"))
201            .child(AccessibilityNode::new("btn2", Role::Button).label("Cancel"));
202        assert_eq!(tree.children.len(), 2);
203    }
204
205    #[test]
206    fn node_find() {
207        let tree = AccessibilityNode::new("root", Role::Window).child(
208            AccessibilityNode::new("toolbar", Role::Toolbar)
209                .child(AccessibilityNode::new("btn1", Role::Button)),
210        );
211        assert!(tree.find("btn1").is_some());
212        assert!(tree.find("nope").is_none());
213    }
214
215    #[test]
216    fn node_find_by_role() {
217        let tree = AccessibilityNode::new("root", Role::Window)
218            .child(AccessibilityNode::new("b1", Role::Button))
219            .child(AccessibilityNode::new("t1", Role::TextInput))
220            .child(AccessibilityNode::new("b2", Role::Button));
221        let buttons = tree.find_by_role(Role::Button);
222        assert_eq!(buttons.len(), 2);
223    }
224
225    #[test]
226    fn node_count() {
227        let tree = AccessibilityNode::new("root", Role::Window)
228            .child(AccessibilityNode::new("a", Role::Button))
229            .child(
230                AccessibilityNode::new("b", Role::Group)
231                    .child(AccessibilityNode::new("c", Role::Label)),
232            );
233        assert_eq!(tree.count(), 4);
234    }
235
236    #[test]
237    fn accessible_state_defaults() {
238        let state = AccessibleState::default();
239        assert!(!state.selected);
240        assert!(!state.disabled);
241        assert!(state.checked.is_none());
242    }
243
244    #[test]
245    fn tree_empty() {
246        let tree = AccessibilityTree::new();
247        assert_eq!(tree.node_count(), 0);
248        assert!(tree.root().is_none());
249    }
250
251    #[test]
252    fn tree_with_root() {
253        let mut tree = AccessibilityTree::new();
254        tree.set_root(
255            AccessibilityNode::new("app", Role::Window)
256                .child(AccessibilityNode::new("btn", Role::Button)),
257        );
258        assert_eq!(tree.node_count(), 2);
259        assert!(tree.find("btn").is_some());
260    }
261
262    #[test]
263    fn tree_find_by_role() {
264        let mut tree = AccessibilityTree::new();
265        tree.set_root(
266            AccessibilityNode::new("app", Role::Window)
267                .child(AccessibilityNode::new("b1", Role::Button))
268                .child(AccessibilityNode::new("b2", Role::Button)),
269        );
270        assert_eq!(tree.find_by_role(Role::Button).len(), 2);
271    }
272}