Skip to main content

cranpose_app_shell/
accessibility_audit.rs

1//! The checks a screen reader user fails a screen on, run over a placed
2//! semantics tree so a test catches them before a person does.
3
4use std::fmt;
5
6use cranpose_foundation::SemanticsWidgetRole;
7
8use crate::placed_semantics::PlacedSemanticsNode;
9
10/// The smallest target WCAG 2.5.8 accepts, in points. Material asks for 48
11/// and Apple for 44; `Modifier::minimum_interactive_component_size()` gives
12/// a small control the 48.
13pub const MINIMUM_TARGET_SIZE: f32 = 24.0;
14
15/// One kind of thing a reader user trips over.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
17pub enum AccessibilityIssueKind {
18    /// A control a reader can press or type into has nothing to say.
19    NoName,
20    /// Two controls of one role say the same name, so a reader user cannot
21    /// tell them apart.
22    SameName,
23    /// A control's target is under [`MINIMUM_TARGET_SIZE`] on a side.
24    SmallTarget,
25    /// A control sits fully above the one a reader visits before it, with
26    /// no `traversal_index` to say so on purpose.
27    OutOfOrder,
28    /// No node names the screen, so a reader says nothing on arrival.
29    NoPaneTitle,
30    /// A picture a reader stops on with no words for it.
31    UnnamedImage,
32}
33
34impl AccessibilityIssueKind {
35    /// What fixes the issue.
36    pub fn advice(self) -> &'static str {
37        match self {
38            Self::NoName => "give it Modifier::content_description(...), or put a Text inside it",
39            Self::SameName => {
40                "say what each one acts on: \"Delete Milk\" and \"Delete Bread\", not \"Delete\" twice"
41            }
42            Self::SmallTarget => {
43                "add Modifier::minimum_interactive_component_size(), or make the control larger"
44            }
45            Self::OutOfOrder => {
46                "lay the controls out in reading order, or set Modifier::traversal_index(...)"
47            }
48            Self::NoPaneTitle => "put Modifier::pane_title(\"...\") on the screen's root",
49            Self::UnnamedImage => {
50                "give the image a content description, or pass None so a reader skips it"
51            }
52        }
53    }
54}
55
56/// One thing a reader user trips over on a screen.
57#[derive(Clone, Debug, PartialEq)]
58pub struct AccessibilityIssue {
59    /// Which check the screen failed.
60    pub kind: AccessibilityIssueKind,
61    /// The control the issue is about: its role and its spoken name.
62    pub control: String,
63    /// What is wrong with it, when the kind alone does not say.
64    pub detail: String,
65}
66
67impl fmt::Display for AccessibilityIssue {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        write!(f, "{:?}: {}{}", self.kind, self.control, self.detail)
70    }
71}
72
73/// Every issue on the screen, in reading order.
74pub fn audit_accessibility(root: &PlacedSemanticsNode) -> Vec<AccessibilityIssue> {
75    let mut visible = Vec::new();
76    collect_visible(root, &mut visible);
77
78    let mut issues = Vec::new();
79    for node in &visible {
80        let name = spoken_name(node);
81        if is_control(node) {
82            check_control(node, &name, &mut issues);
83        } else if node.widget_role == Some(SemanticsWidgetRole::Image) && name.is_empty() {
84            issues.push(issue(
85                AccessibilityIssueKind::UnnamedImage,
86                node,
87                &name,
88                &where_it_is(node),
89            ));
90        }
91        check_order(node, &mut issues);
92    }
93    check_same_names(&visible, &mut issues);
94    if !visible.iter().any(|node| node.pane_title.is_some()) {
95        issues.push(AccessibilityIssue {
96            kind: AccessibilityIssueKind::NoPaneTitle,
97            control: "the screen".to_string(),
98            detail: String::new(),
99        });
100    }
101    issues
102}
103
104/// Fails the test with every issue on the screen and what fixes each kind.
105pub fn assert_accessible(root: &PlacedSemanticsNode) {
106    let issues = audit_accessibility(root);
107    if issues.is_empty() {
108        return;
109    }
110    let mut report = format!("{} accessibility issue(s):\n", issues.len());
111    for issue in &issues {
112        report.push_str(&format!("  {issue}\n"));
113    }
114    let mut kinds: Vec<AccessibilityIssueKind> = issues.iter().map(|issue| issue.kind).collect();
115    kinds.dedup();
116    kinds.sort_by_key(|kind| *kind as u8);
117    kinds.dedup();
118    for kind in kinds {
119        report.push_str(&format!("  {kind:?}: {}\n", kind.advice()));
120    }
121    panic!("{report}");
122}
123
124/// The name a reader speaks for a node: its own label, else the title it
125/// gives the screen, else the words under it.
126pub fn spoken_name(node: &PlacedSemanticsNode) -> String {
127    if let Some(label) = node.label.as_deref() {
128        return label.trim().to_string();
129    }
130    if let Some(title) = node
131        .pane_title
132        .as_deref()
133        .filter(|title| !title.trim().is_empty())
134    {
135        return title.trim().to_string();
136    }
137    let mut words = Vec::new();
138    for child in &node.children {
139        if child.hidden || is_control(child) {
140            continue;
141        }
142        let name = spoken_name(child);
143        if !name.is_empty() {
144            words.push(name);
145        }
146    }
147    words.join(" ")
148}
149
150fn collect_visible<'a>(node: &'a PlacedSemanticsNode, out: &mut Vec<&'a PlacedSemanticsNode>) {
151    if node.hidden {
152        return;
153    }
154    out.push(node);
155    for child in &node.children {
156        collect_visible(child, out);
157    }
158}
159
160fn is_control(node: &PlacedSemanticsNode) -> bool {
161    node.clickable || node.editable_text || node.focusable
162}
163
164fn holds_control(node: &PlacedSemanticsNode) -> bool {
165    !node.hidden && (is_control(node) || node.children.iter().any(holds_control))
166}
167
168fn check_control(node: &PlacedSemanticsNode, name: &str, issues: &mut Vec<AccessibilityIssue>) {
169    if name.is_empty() {
170        issues.push(issue(
171            AccessibilityIssueKind::NoName,
172            node,
173            name,
174            &where_it_is(node),
175        ));
176    }
177    let target = node.target_bounds();
178    if target.width > 0.0
179        && target.height > 0.0
180        && (target.width < MINIMUM_TARGET_SIZE || target.height < MINIMUM_TARGET_SIZE)
181    {
182        let detail = format!(
183            " is {}x{} points",
184            target.width.round(),
185            target.height.round()
186        );
187        issues.push(issue(
188            AccessibilityIssueKind::SmallTarget,
189            node,
190            name,
191            &detail,
192        ));
193    }
194}
195
196fn check_order(parent: &PlacedSemanticsNode, issues: &mut Vec<AccessibilityIssue>) {
197    let in_order: Vec<&PlacedSemanticsNode> = parent
198        .children
199        .iter()
200        .filter(|child| {
201            holds_control(child)
202                && child.traversal_index == 0.0
203                && child.layout_bounds.width > 0.0
204                && child.layout_bounds.height > 0.0
205        })
206        .collect();
207    for pair in in_order.windows(2) {
208        let (before, after) = (pair[0], pair[1]);
209        let after_bottom = after.layout_bounds.y + after.layout_bounds.height;
210        if after_bottom <= before.layout_bounds.y {
211            let detail = format!(" sits above {:?}", spoken_name(before));
212            issues.push(issue(
213                AccessibilityIssueKind::OutOfOrder,
214                after,
215                &spoken_name(after),
216                &detail,
217            ));
218        }
219    }
220}
221
222/// Two controls of one role with one name. Rows of a list at different
223/// places are apart: a reader speaks their place.
224fn check_same_names(visible: &[&PlacedSemanticsNode], issues: &mut Vec<AccessibilityIssue>) {
225    let mut seen: Vec<(String, Option<SemanticsWidgetRole>, Option<usize>, usize)> = Vec::new();
226    for node in visible.iter().filter(|node| is_control(node)) {
227        let name = spoken_name(node);
228        if name.is_empty() {
229            continue;
230        }
231        match seen.iter_mut().find(|(seen_name, role, position, _)| {
232            *seen_name == name && *role == node.widget_role && *position == node.list_position
233        }) {
234            Some(entry) => entry.3 += 1,
235            None => seen.push((name, node.widget_role, node.list_position, 1)),
236        }
237    }
238    for (name, role, _, count) in seen.into_iter().filter(|(_, _, _, count)| *count > 1) {
239        issues.push(AccessibilityIssue {
240            kind: AccessibilityIssueKind::SameName,
241            control: format!("{} {name:?}", role_word(role)),
242            detail: format!(" appears {count} times"),
243        });
244    }
245}
246
247fn issue(
248    kind: AccessibilityIssueKind,
249    node: &PlacedSemanticsNode,
250    name: &str,
251    detail: &str,
252) -> AccessibilityIssue {
253    AccessibilityIssue {
254        kind,
255        control: format!("{} {name:?}", role_word(node.widget_role)),
256        detail: detail.to_string(),
257    }
258}
259
260fn where_it_is(node: &PlacedSemanticsNode) -> String {
261    let bounds = node.layout_bounds;
262    format!(
263        " is {}x{} points at ({}, {})",
264        bounds.width.round(),
265        bounds.height.round(),
266        bounds.x.round(),
267        bounds.y.round()
268    )
269}
270
271fn role_word(role: Option<SemanticsWidgetRole>) -> String {
272    match role {
273        Some(role) => format!("{role:?}").to_lowercase(),
274        None => "control".to_string(),
275    }
276}
277
278/// An issue a test leaves as it is: the screen it is on, `*` for every
279/// screen; the start of the issue line; the reason it stays.
280pub type KnownIssue = (&'static str, &'static str, &'static str);
281
282/// Compares the issues found on `screen` with the list a test keeps and
283/// reports what changed: the lines that are new, and the listed issues that
284/// went away. A test fails on either, so the list only shrinks. An issue
285/// matches a listed one when its line starts with the listed text; a `*`
286/// entry matches on every screen and is never reported as gone.
287pub fn audit_changes(screen: &str, issues: &[String], known: &[KnownIssue]) -> Result<(), String> {
288    let listed = |issue: &str| {
289        known
290            .iter()
291            .any(|(on, text, _)| (*on == "*" || *on == screen) && issue.starts_with(text))
292    };
293    let new: Vec<&str> = issues
294        .iter()
295        .map(String::as_str)
296        .filter(|issue| !listed(issue))
297        .collect();
298    let gone: Vec<&str> = known
299        .iter()
300        .filter(|(on, text, _)| {
301            *on == screen && !issues.iter().any(|issue| issue.starts_with(text))
302        })
303        .map(|(_, text, _)| *text)
304        .collect();
305    if new.is_empty() && gone.is_empty() {
306        return Ok(());
307    }
308    let mut report = String::new();
309    if !new.is_empty() {
310        report.push_str(&format!(
311            "new accessibility issues on {screen}:\n  {}\n",
312            new.join("\n  ")
313        ));
314    }
315    if !gone.is_empty() {
316        report.push_str(&format!(
317            "issues listed for {screen} went away; take them off the list:\n  {}\n",
318            gone.join("\n  ")
319        ));
320    }
321    Err(report)
322}
323
324#[cfg(test)]
325#[path = "tests/accessibility_audit.rs"]
326mod tests;