Skip to main content

ravenrs_widgets/ui/
access.rs

1//! Builds the accessibility tree from the element tree and performs the
2//! actions assistive technology requests.
3
4use std::collections::HashMap;
5
6use ravenrs_core::{AccessAction, AccessNode, AccessTree, Key, Modifiers, Point, Rect, Role};
7
8use super::Ui;
9use crate::context::{NodeId, WidgetId};
10
11/// Id of the window node; widget-derived ids never collide with it in practice.
12const ROOT_ID: u64 = 1;
13
14/// Maps accessibility ids back to elements for [`Ui::perform_action`].
15#[derive(Debug, Default)]
16pub(crate) struct AccessMap {
17    nodes: HashMap<u64, NodeId>,
18}
19
20#[cfg_attr(feature = "live", ravenrs_live::expose)]
21impl Ui {
22    /// The accessibility tree of the latest frame: roles, names, states and
23    /// frames of everything on screen, for screen readers. Call it after
24    /// [`Ui::update`]; platform layers send it to the operating system when it
25    /// changes. While a modal layer is open, only that layer is exposed.
26    pub fn access_tree(&mut self) -> AccessTree {
27        let mut builder = Builder {
28            ui: self,
29            nodes: Vec::new(),
30            seen: HashMap::new(),
31            map: HashMap::new(),
32            focus: None,
33            active: None,
34        };
35        builder.nodes.push(AccessNode::new(
36            ROOT_ID,
37            Role::Window,
38            Rect::from_origin_size(Point::ZERO, self.viewport),
39        ));
40        let mut roots = Vec::new();
41        match self.modal_root() {
42            Some(modal) => {
43                let from = self.overlays.iter().position(|o| o.root == modal).unwrap_or(0);
44                roots.extend(self.overlays[from..].iter().map(|o| o.root));
45            }
46            None => {
47                roots.extend(self.root_node);
48                roots.extend(self.overlays.iter().map(|o| o.root));
49            }
50        }
51        let mut children = Vec::new();
52        for root in roots {
53            builder.visit(root, &mut children, None);
54        }
55        let Builder { mut nodes, map, focus, active, .. } = builder;
56        nodes[0].children = children;
57        if let (Some(focus), Some(active)) = (focus, active)
58            && let Some(node) = nodes.iter_mut().find(|n| n.id == focus)
59        {
60            node.active_descendant = Some(active);
61        }
62        self.access_map = AccessMap { nodes: map };
63        AccessTree { root: ROOT_ID, nodes, focus }
64    }
65
66    /// The keyboard shortcuts the latest frame answers to, in the order they were registered,
67    /// for tools that look for two commands on the same keys.
68    pub fn shortcuts(&self) -> Vec<crate::Hotkey> {
69        self.shortcuts
70            .iter()
71            .map(|s| {
72                let key = match &s.key {
73                    Key::Character(c) => Key::Character(c.to_lowercase()),
74                    other => other.clone(),
75                };
76                crate::Hotkey::of(key, s.command, s.shift)
77            })
78            .collect()
79    }
80
81    /// Performs an action assistive technology asked for on the node with
82    /// this id (from the latest [`Ui::access_tree`]). Returns true if the UI
83    /// needs a redraw.
84    pub fn perform_action(&mut self, id: u64, action: AccessAction) -> bool {
85        let Some(node) = self.access_map.nodes.get(&id).copied() else { return false };
86        let Some(element) = self.elements.get(node.0 as usize) else { return false };
87        let widget = element.widget;
88        match action {
89            AccessAction::Click => {
90                if let Some(handler) = element.on_click.clone() {
91                    handler();
92                } else if element.focus_radius.is_some() {
93                    self.focus_widget(widget);
94                }
95            }
96            AccessAction::Focus => {
97                if element.focus_radius.is_some() {
98                    self.focus_widget(widget);
99                }
100            }
101            AccessAction::Increment | AccessAction::Decrement => {
102                let key = if action == AccessAction::Increment { Key::ArrowUp } else { Key::ArrowDown };
103                if let Some(handler) = element.on_key.clone() {
104                    handler(&key, Modifiers::default());
105                }
106            }
107            _ => return false,
108        }
109        self.needs_redraw()
110    }
111
112    fn focus_widget(&mut self, widget: WidgetId) {
113        if self.interaction.focused != Some(widget) {
114            self.interaction.focused = Some(widget);
115            self.interaction.focus_visible = true;
116            self.needs_build = true;
117        }
118    }
119}
120
121struct Builder<'a> {
122    ui: &'a Ui,
123    nodes: Vec<AccessNode>,
124    /// How many nodes each widget produced so far, to tell them apart.
125    seen: HashMap<WidgetId, u64>,
126    map: HashMap<u64, NodeId>,
127    focus: Option<u64>,
128    /// The highlighted option of a combobox-style list.
129    active: Option<u64>,
130}
131
132#[cfg_attr(feature = "live", ravenrs_live::expose)]
133impl Builder<'_> {
134    fn id_for(&mut self, widget: WidgetId) -> u64 {
135        let n = self.seen.entry(widget).or_insert(0);
136        *n += 1;
137        let mut h = widget.raw() ^ n.wrapping_mul(0x9e37_79b9_7f4a_7c15);
138        h = (h ^ (h >> 31)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
139        // Keep ids away from the root's.
140        (h ^ (h >> 29)).max(ROOT_ID + 1)
141    }
142
143    /// Adds `node`'s accessible content to `out`. Text goes to `name` instead
144    /// when an ancestor takes its name from its content.
145    fn visit(&mut self, node: NodeId, out: &mut Vec<u64>, mut name: Option<&mut String>) {
146        let ui = self.ui;
147        let i = node.0 as usize;
148        let e = &ui.elements[i];
149        let semantics = e.semantics.as_deref();
150        if semantics.is_some_and(|s| s.hidden) {
151            return;
152        }
153        if semantics.is_none()
154            && let Some(text) = &e.text
155        {
156            let text = readable(text);
157            if text.is_empty() {
158                return;
159            }
160            match name {
161                Some(name) => {
162                    if !name.is_empty() {
163                        name.push(' ');
164                    }
165                    name.push_str(&text);
166                }
167                None => {
168                    let id = self.id_for(e.widget);
169                    let mut label = AccessNode::new(id, Role::Label, ui.frames[i]);
170                    label.name = Some(text);
171                    self.map.insert(id, node);
172                    self.nodes.push(label);
173                    out.push(id);
174                }
175            }
176            return;
177        }
178        let focusable = e.focus_radius.is_some();
179        if semantics.is_none() && !focusable {
180            for child in &e.children {
181                self.visit(*child, out, name.as_deref_mut());
182            }
183            return;
184        }
185        let role = semantics.map_or(Role::Button, |s| s.role);
186        let id = self.id_for(e.widget);
187        let index = self.nodes.len();
188        let mut a = AccessNode::new(id, role, ui.frames[i]);
189        a.focusable = focusable;
190        if let Some(s) = semantics {
191            a.name = s.name.clone();
192            a.description = s.description.clone();
193            a.value = s.value.clone();
194            a.numeric = s.numeric;
195            a.toggled = s.toggled;
196            a.selected = s.selected;
197            a.expanded = s.expanded;
198            a.disabled = s.disabled;
199            a.read_only = s.read_only;
200            a.modal = s.modal;
201            a.level = s.level;
202            if s.active {
203                self.active = Some(id);
204            }
205        }
206        if e.on_click.is_some() {
207            a.actions.push(AccessAction::Click);
208        }
209        if focusable {
210            a.actions.push(AccessAction::Focus);
211            if ui.interaction.focused == Some(e.widget) {
212                self.focus = Some(id);
213            }
214        }
215        if e.on_key.is_some() && matches!(role, Role::Slider | Role::SpinButton) {
216            a.actions.extend([AccessAction::Increment, AccessAction::Decrement]);
217        }
218        self.map.insert(id, node);
219        self.nodes.push(a);
220        let mut content = e.text.as_deref().map(readable).unwrap_or_default();
221        // Text inside a control that takes its name from content is part of
222        // that name, even when an explicit name replaces it.
223        let from_content = role.takes_name_from_content();
224        let mut children = Vec::new();
225        for child in &e.children {
226            self.visit(*child, &mut children, from_content.then_some(&mut content));
227        }
228        let node = &mut self.nodes[index];
229        node.children = children;
230        if from_content && node.name.is_none() && !content.is_empty() {
231            node.name = Some(content.clone());
232        }
233        // A named control inside a named control (a button in a grid cell)
234        // still reads as part of the outer name.
235        if let Some(name) = name
236            && let Some(own) = &node.name
237        {
238            if !name.is_empty() {
239                name.push(' ');
240            }
241            name.push_str(own);
242        }
243        out.push(id);
244    }
245}
246
247/// Text as a screen reader should read it: icon glyphs (private-use code
248/// points) dropped and whitespace collapsed.
249fn readable(text: &str) -> String {
250    let kept: String =
251        text.chars().filter(|c| !matches!(*c as u32, 0xE000..=0xF8FF | 0xF0000..=0x10FFFF)).collect();
252    kept.split_whitespace().collect::<Vec<_>>().join(" ")
253}
254
255#[cfg(test)]
256mod tests {
257    use super::readable;
258
259    #[test]
260    fn readable_text_drops_icons_and_collapses_spaces() {
261        assert_eq!(readable("\u{e001}  Save\n file "), "Save file");
262        assert_eq!(readable("\u{e001}"), "");
263    }
264}