Skip to main content

ravenrs_app_a11y/
audit.rs

1//! An audit that walks an application the way a keyboard and a screen reader would, and says
2//! what keeps someone out.
3//!
4//! [`Audit::app`] opens every item of the navigation, and on each screen the buttons it was told
5//! to press, such as the one that starts a new record. On each one it looks for:
6//!
7//! * a control screen readers cannot name, and an id that appears twice;
8//! * keyboard focus on a node that is not there;
9//! * focus held: Tab stays where it is, or goes round some of the controls and never reaches the
10//!   others;
11//! * two commands on the same keys.
12//!
13//! It also checks the contrast of text and of the focus ring on every theme it is given, and on
14//! the one on screen. It runs with no window, so a test of any example can call it.
15
16use std::collections::{BTreeMap, HashSet};
17use std::fmt;
18
19use ravenrs_core::{AccessAction, AccessTree, Color, Event, Key, Modifiers, Rect, Role};
20use ravenrs_style::{Semantic, Theme};
21use ravenrs_widgets::Ui;
22
23/// The least contrast for text, as WCAG asks of normal text (AA).
24pub const TEXT_CONTRAST: f32 = 4.5;
25/// The least contrast for what is not text but has to be seen, such as the focus ring (AA).
26pub const UI_CONTRAST: f32 = 3.0;
27/// The most Tab presses tried on one screen.
28const MOST_TABS: usize = 400;
29
30/// What keeps someone out.
31#[derive(Clone, Debug, PartialEq)]
32#[non_exhaustive]
33pub enum Problem {
34    /// A control with no name: a screen reader says only what it is.
35    Unnamed {
36        /// What it is.
37        role: Role,
38        /// Where it is.
39        bounds: Rect,
40    },
41    /// Two nodes with the same id, which assistive technology takes for one.
42    RepeatedId {
43        /// The id.
44        id: u64,
45    },
46    /// Keyboard focus is on a node the tree does not have.
47    FocusOnNothing,
48    /// Tab does not move the focus.
49    FocusStuck {
50        /// What it stays on.
51        on: Option<String>,
52    },
53    /// Tab goes round some of the controls and never reaches the others.
54    FocusTrapped {
55        /// How many it reaches.
56        reached: usize,
57        /// How many there are.
58        of: usize,
59        /// The first one it never reaches.
60        missed: Option<String>,
61    },
62    /// Two colors of a theme are too close to read one on the other.
63    LowContrast {
64        /// The theme.
65        theme: String,
66        /// The color in front and the one behind, by their tokens.
67        pair: String,
68        /// Their contrast.
69        ratio: f32,
70        /// The least it should be.
71        least: f32,
72    },
73    /// A window over the screen that Escape does not close, so the keyboard cannot leave it.
74    WindowHeld {
75        /// What the window is called.
76        name: Option<String>,
77    },
78    /// More than one command answers to the same keys.
79    SharedShortcut {
80        /// The keys, as they are written next to a command.
81        keys: String,
82        /// How many commands.
83        times: usize,
84    },
85}
86
87impl fmt::Display for Problem {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::Unnamed { role, bounds } => write!(f, "a {role:?} has no name, at {bounds:?}"),
91            Self::RepeatedId { id } => write!(f, "the id {id} appears more than once"),
92            Self::FocusOnNothing => f.write_str("the focus is on a node the tree does not have"),
93            Self::FocusStuck { on } => {
94                write!(
95                    f,
96                    "Tab does not move the focus from {}",
97                    on.as_deref().unwrap_or("an unnamed control")
98                )
99            }
100            Self::FocusTrapped { reached, of, missed } => write!(
101                f,
102                "Tab goes round {reached} of {of} controls and never reaches {}",
103                missed.as_deref().unwrap_or("the others")
104            ),
105            Self::LowContrast { theme, pair, ratio, least } => {
106                write!(f, "{theme}: {pair} is {ratio:.2}:1, under {least}:1")
107            }
108            Self::WindowHeld { name } => {
109                write!(f, "Escape does not close the window {}", name.as_deref().unwrap_or("with no name"))
110            }
111            Self::SharedShortcut { keys, times } => write!(f, "{times} commands answer to {keys}"),
112        }
113    }
114}
115
116/// A problem, and where it was found.
117#[derive(Clone, Debug, PartialEq)]
118#[non_exhaustive]
119pub struct Finding {
120    /// The screen, as the navigation calls it, or the theme.
121    pub place: String,
122    /// What is wrong.
123    pub problem: Problem,
124}
125
126impl Finding {
127    /// This problem, here.
128    pub fn new(place: impl Into<String>, problem: Problem) -> Self {
129        Self { place: place.into(), problem }
130    }
131}
132
133impl fmt::Display for Finding {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        write!(f, "{}: {}", self.place, self.problem)
136    }
137}
138
139/// What an audit found, and where it looked.
140#[derive(Clone, Debug, Default, PartialEq)]
141#[non_exhaustive]
142pub struct Report {
143    /// What is wrong.
144    pub findings: Vec<Finding>,
145    /// The screens it went through.
146    pub places: Vec<String>,
147}
148
149impl Report {
150    /// Nothing is wrong.
151    pub fn is_clean(&self) -> bool {
152        self.findings.is_empty()
153    }
154
155    /// Fails, saying everything found, unless nothing was.
156    ///
157    /// # Panics
158    ///
159    /// When anything was found.
160    #[track_caller]
161    pub fn assert_clean(&self) {
162        assert!(self.is_clean(), "{self}");
163    }
164
165    fn add(&mut self, found: Vec<Finding>) {
166        for finding in found {
167            if !self.findings.contains(&finding) {
168                self.findings.push(finding);
169            }
170        }
171    }
172}
173
174impl fmt::Display for Report {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        writeln!(f, "accessibility: {} problems in {} screens", self.findings.len(), self.places.len())?;
177        for finding in &self.findings {
178            writeln!(f, "- {finding}")?;
179        }
180        Ok(())
181    }
182}
183
184/// Roles that are operated, so they must say what they are.
185pub fn is_control(role: Role) -> bool {
186    matches!(
187        role,
188        Role::Button
189            | Role::Link
190            | Role::CheckBox
191            | Role::Switch
192            | Role::RadioButton
193            | Role::TextInput
194            | Role::MultilineTextInput
195            | Role::Slider
196            | Role::SpinButton
197            | Role::ComboBox
198            | Role::Tab
199            | Role::MenuItem
200            | Role::ListBoxOption
201            | Role::TreeItem
202    )
203}
204
205/// What is wrong with the names and ids of a tree.
206pub fn check_tree(tree: &AccessTree, place: &str) -> Vec<Finding> {
207    let mut found = Vec::new();
208    let mut ids = HashSet::new();
209    for node in &tree.nodes {
210        if !ids.insert(node.id) {
211            found.push(Finding::new(place, Problem::RepeatedId { id: node.id }));
212        }
213        let named = node.name.as_deref().is_some_and(|name| !name.trim().is_empty());
214        if is_control(node.role) && !named {
215            found.push(Finding::new(place, Problem::Unnamed { role: node.role, bounds: node.bounds }));
216        }
217    }
218    if tree.focus.is_some_and(|focus| tree.get(focus).is_none()) {
219        found.push(Finding::new(place, Problem::FocusOnNothing));
220    }
221    found
222}
223
224fn press(ui: &mut Ui, key: Key, modifiers: Modifiers) {
225    ui.handle_event(&Event::KeyDown { key: key.clone(), modifiers });
226    ui.handle_event(&Event::KeyUp { key, modifiers });
227    ui.update();
228}
229
230/// The windows over the screen, by id.
231fn windows(tree: &AccessTree) -> Vec<u64> {
232    tree.nodes.iter().filter(|n| n.modal || n.role == Role::Dialog).map(|n| n.id).collect()
233}
234
235/// Presses Tab across the screen and says whether the focus moves and reaches every control.
236/// The focus is left where the last press put it.
237///
238/// What the application changes by itself on the way, such as a reminder that comes due, is not
239/// held against the screen: a control counts as never reached only when it is there from the
240/// first press to the last, and a window that opens on the way ends the walk.
241pub fn check_focus(ui: &mut Ui, place: &str) -> Vec<Finding> {
242    let tree = ui.access_tree();
243    let names: BTreeMap<u64, Option<String>> =
244        tree.nodes.iter().filter(|n| n.focusable).map(|n| (n.id, n.name.clone())).collect();
245    if names.len() < 2 {
246        return Vec::new();
247    }
248    let opened = windows(&tree);
249    let mut seen: Vec<u64> = Vec::new();
250    let mut closed = false;
251    let mut before = tree.focus;
252    let mut last = tree;
253    for _ in 0..(names.len() + 2).min(MOST_TABS) {
254        press(ui, Key::Tab, Modifiers::default());
255        let now = ui.access_tree();
256        if windows(&now).iter().any(|w| !opened.contains(w)) {
257            return Vec::new();
258        }
259        let Some(focus) = now.focus else { break };
260        if before == Some(focus) {
261            let on = now.get(focus).and_then(|n| n.name.clone());
262            return vec![Finding::new(place, Problem::FocusStuck { on })];
263        }
264        before = Some(focus);
265        last = now;
266        if seen.contains(&focus) {
267            closed = true;
268            break;
269        }
270        seen.push(focus);
271    }
272    let stayed: Vec<(&u64, &Option<String>)> =
273        names.iter().filter(|(id, _)| last.get(**id).is_some_and(|n| n.focusable)).collect();
274    let reached = stayed.iter().filter(|(id, _)| seen.contains(id)).count();
275    if closed && reached < stayed.len() {
276        let missed = stayed.iter().find(|(id, _)| !seen.contains(id)).and_then(|(_, name)| (*name).clone());
277        return vec![Finding::new(place, Problem::FocusTrapped { reached, of: stayed.len(), missed })];
278    }
279    Vec::new()
280}
281
282/// Closes with Escape what stays over the screen, and says so when Escape does not.
283fn close_windows(ui: &mut Ui, place: &str) -> Vec<Finding> {
284    for _ in 0..4 {
285        if windows(&ui.access_tree()).is_empty() {
286            return Vec::new();
287        }
288        press(ui, Key::Escape, Modifiers::default());
289        settle(ui);
290    }
291    let tree = ui.access_tree();
292    let held = tree.nodes.iter().find(|n| n.modal || n.role == Role::Dialog);
293    held.map(|n| vec![Finding::new(place, Problem::WindowHeld { name: n.name.clone() })]).unwrap_or_default()
294}
295
296/// Commands of the latest frame that answer to the same keys.
297pub fn check_shortcuts(ui: &Ui, place: &str) -> Vec<Finding> {
298    let mut counted: Vec<(ravenrs_widgets::Hotkey, usize)> = Vec::new();
299    for keys in ui.shortcuts() {
300        match counted.iter_mut().find(|(known, _)| *known == keys) {
301            Some((_, times)) => *times += 1,
302            None => counted.push((keys, 1)),
303        }
304    }
305    counted
306        .into_iter()
307        .filter(|(_, times)| *times > 1)
308        .map(|(keys, times)| Finding::new(place, Problem::SharedShortcut { keys: keys.label(), times }))
309        .collect()
310}
311
312/// A color with some transparency as it shows over `ground`.
313fn over(color: Color, ground: Color) -> Color {
314    ground.lerp(color.with_alpha(1.0), color.a)
315}
316
317/// The pairs of tokens a theme has to keep apart: text on what it sits on, and the focus ring on
318/// the window.
319const PAIRS: &[(Semantic, Semantic, f32)] = &[
320    (Semantic::Ink, Semantic::Canvas, TEXT_CONTRAST),
321    (Semantic::Ink, Semantic::Surface, TEXT_CONTRAST),
322    (Semantic::Ink, Semantic::SurfaceElevated, TEXT_CONTRAST),
323    (Semantic::InkMuted, Semantic::Canvas, TEXT_CONTRAST),
324    (Semantic::InkMuted, Semantic::Surface, TEXT_CONTRAST),
325    (Semantic::OnAccent, Semantic::Accent, TEXT_CONTRAST),
326    (Semantic::FocusRing, Semantic::Canvas, UI_CONTRAST),
327    (Semantic::FocusRing, Semantic::Surface, UI_CONTRAST),
328];
329
330/// The pairs of colors of `theme` that are too close to read.
331pub fn check_theme(theme: &Theme) -> Vec<Finding> {
332    let canvas = theme.color(Semantic::Canvas).with_alpha(1.0);
333    PAIRS
334        .iter()
335        .filter_map(|&(front, back, least)| {
336            let ground = over(theme.color(back), canvas);
337            let ratio = over(theme.color(front), ground).contrast_ratio(ground);
338            (ratio < least).then(|| {
339                Finding::new(
340                    theme.name,
341                    Problem::LowContrast {
342                        theme: theme.name.to_owned(),
343                        pair: format!("{front:?} on {back:?}"),
344                        ratio,
345                        least,
346                    },
347                )
348            })
349        })
350        .collect()
351}
352
353/// The items of the navigation landmarks of a tree: what opens a screen.
354fn navigation_items(tree: &AccessTree) -> Vec<(Role, String)> {
355    let mut items = Vec::new();
356    let mut stack: Vec<u64> =
357        tree.nodes.iter().filter(|n| n.role == Role::Navigation).map(|n| n.id).collect();
358    let mut visited = HashSet::new();
359    while let Some(id) = stack.pop() {
360        if !visited.insert(id) {
361            continue;
362        }
363        let Some(node) = tree.get(id) else { continue };
364        let opens = matches!(node.role, Role::Button | Role::Link | Role::TreeItem)
365            && node.actions.contains(&AccessAction::Click)
366            && node.expanded.is_none()
367            && !node.disabled;
368        if opens && let Some(name) = node.name.clone().filter(|n| !n.trim().is_empty()) {
369            let item = (node.role, name);
370            if !items.contains(&item) {
371                items.push(item);
372            }
373        }
374        stack.extend(node.children.iter().rev());
375    }
376    // In reading order: the stack took children last first.
377    items.sort_by_key(|(role, name)| {
378        tree.nodes.iter().position(|n| n.role == *role && n.name.as_deref() == Some(name.as_str()))
379    });
380    items
381}
382
383fn settle(ui: &mut Ui) {
384    for _ in 0..3 {
385        ui.update();
386    }
387}
388
389/// How to walk an application, and what to look at.
390///
391/// ```
392/// use ravenrs_app_a11y::Audit;
393/// use ravenrs_widgets::{Button, Ui};
394///
395/// let mut ui = Ui::new(Button::new("Salvar"), ravenrs_style::Theme::raven_dark());
396/// ui.update();
397/// Audit::new().screen(&mut ui, "início").assert_clean();
398/// ```
399#[derive(Clone, Debug, Default)]
400pub struct Audit {
401    themes: Vec<Theme>,
402    open: Vec<String>,
403    skip: Vec<String>,
404    focus: bool,
405}
406
407impl Audit {
408    /// Looks at names, ids, focus, shortcuts and the theme on screen.
409    pub fn new() -> Self {
410        Self { focus: true, ..Self::default() }
411    }
412
413    /// Also checks the contrast of this theme, which the application offers.
414    pub fn theme(mut self, theme: Theme) -> Self {
415        self.themes.push(theme);
416        self
417    }
418
419    /// On every screen, also presses the button with this name when there is one, as `New`, and
420    /// looks at what it opens.
421    pub fn open(mut self, button: impl Into<String>) -> Self {
422        self.open.push(button.into());
423        self
424    }
425
426    /// Leaves out the item of the navigation with this name, as one that leaves the application.
427    pub fn skip(mut self, item: impl Into<String>) -> Self {
428        self.skip.push(item.into());
429        self
430    }
431
432    /// Does not press Tab, for a screen that changes as the focus moves.
433    pub fn without_focus(mut self) -> Self {
434        self.focus = false;
435        self
436    }
437
438    /// Looks at the screen as it is.
439    pub fn screen(&self, ui: &mut Ui, place: &str) -> Report {
440        let mut report = Report::default();
441        self.look(ui, place, &mut report);
442        report.add(self.contrast(ui));
443        report
444    }
445
446    fn contrast(&self, ui: &Ui) -> Vec<Finding> {
447        let shown = ui.theme();
448        let mut found = check_theme(&shown);
449        for theme in self.themes.iter().filter(|t| t.name != shown.name) {
450            found.extend(check_theme(theme));
451        }
452        found
453    }
454
455    fn look(&self, ui: &mut Ui, place: &str, report: &mut Report) {
456        settle(ui);
457        report.places.push(place.to_owned());
458        report.add(check_tree(&ui.access_tree(), place));
459        report.add(check_shortcuts(ui, place));
460        if self.focus {
461            report.add(check_focus(ui, place));
462        }
463    }
464
465    /// Walks the whole application: the screen it opens on, every item of its navigation, and on
466    /// each, the buttons given to [`open`](Self::open).
467    pub fn app(&self, ui: &mut Ui) -> Report {
468        let mut report = Report::default();
469        report.add(self.contrast(ui));
470        settle(ui);
471        let start = ui.access_tree();
472        let items = navigation_items(&start);
473        self.look(ui, "start", &mut report);
474        report.add(close_windows(ui, "start"));
475        for (role, name) in items.iter().filter(|(_, name)| !self.skip.contains(name)) {
476            if !click(ui, *role, name) {
477                continue;
478            }
479            self.look(ui, name, &mut report);
480            // Whatever it opened over the screen goes away before the next item.
481            report.add(close_windows(ui, name));
482            for button in &self.open {
483                if click(ui, Role::Button, button) {
484                    let place = format!("{name} > {button}");
485                    self.look(ui, &place, &mut report);
486                    report.add(close_windows(ui, &place));
487                }
488            }
489        }
490        report
491    }
492}
493
494/// Clicks the enabled node with this role and name, if the screen has one.
495fn click(ui: &mut Ui, role: Role, name: &str) -> bool {
496    let tree = ui.access_tree();
497    let Some(node) = tree
498        .nodes
499        .iter()
500        .find(|n| n.role == role && n.name.as_deref() == Some(name) && !n.disabled)
501        .filter(|n| n.actions.contains(&AccessAction::Click))
502    else {
503        return false;
504    };
505    ui.perform_action(node.id, AccessAction::Click);
506    settle(ui);
507    true
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use ravenrs_core::AccessNode;
514
515    #[test]
516    fn a_control_without_a_name_and_a_repeated_id_are_found() {
517        let mut tree = AccessTree::default();
518        tree.nodes.push(AccessNode::new(1, Role::Window, Rect::ZERO));
519        tree.nodes.push(AccessNode::new(2, Role::Button, Rect::ZERO));
520        let mut named = AccessNode::new(2, Role::Label, Rect::ZERO);
521        named.name = Some("Nome".into());
522        tree.nodes.push(named);
523        tree.focus = Some(9);
524        let found = check_tree(&tree, "teste");
525        assert!(found.iter().any(|f| matches!(f.problem, Problem::Unnamed { role: Role::Button, .. })));
526        assert!(found.iter().any(|f| f.problem == Problem::RepeatedId { id: 2 }));
527        assert!(found.iter().any(|f| f.problem == Problem::FocusOnNothing));
528    }
529
530    #[test]
531    fn the_themes_that_come_with_the_library_read_well() {
532        for theme in [Theme::raven_dark(), Theme::raven_light(), Theme::raven_glass()] {
533            let found = check_theme(&theme);
534            assert!(found.is_empty(), "{found:?}");
535        }
536    }
537
538    #[test]
539    fn a_theme_with_faint_text_is_found() {
540        let mut theme = Theme::raven_dark();
541        theme.palette.ink_muted = theme.palette.bg_000;
542        let found = check_theme(&theme);
543        assert!(
544            found.iter().any(
545                |f| matches!(&f.problem, Problem::LowContrast { pair, .. } if pair.contains("InkMuted"))
546            )
547        );
548    }
549}