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    pub kind: AccessibilityIssueKind,
60    /// The control the issue is about: its role and its spoken name.
61    pub control: String,
62    /// What is wrong with it, when the kind alone does not say.
63    pub detail: String,
64}
65
66impl fmt::Display for AccessibilityIssue {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(f, "{:?}: {}{}", self.kind, self.control, self.detail)
69    }
70}
71
72/// Every issue on the screen, in reading order.
73pub fn audit_accessibility(root: &PlacedSemanticsNode) -> Vec<AccessibilityIssue> {
74    let mut visible = Vec::new();
75    collect_visible(root, &mut visible);
76
77    let mut issues = Vec::new();
78    for node in &visible {
79        let name = spoken_name(node);
80        if is_control(node) {
81            check_control(node, &name, &mut issues);
82        } else if node.widget_role == Some(SemanticsWidgetRole::Image) && name.is_empty() {
83            issues.push(issue(
84                AccessibilityIssueKind::UnnamedImage,
85                node,
86                &name,
87                &where_it_is(node),
88            ));
89        }
90        check_order(node, &mut issues);
91    }
92    check_same_names(&visible, &mut issues);
93    if !visible.iter().any(|node| node.pane_title.is_some()) {
94        issues.push(AccessibilityIssue {
95            kind: AccessibilityIssueKind::NoPaneTitle,
96            control: "the screen".to_string(),
97            detail: String::new(),
98        });
99    }
100    issues
101}
102
103/// Fails the test with every issue on the screen and what fixes each kind.
104pub fn assert_accessible(root: &PlacedSemanticsNode) {
105    let issues = audit_accessibility(root);
106    if issues.is_empty() {
107        return;
108    }
109    let mut report = format!("{} accessibility issue(s):\n", issues.len());
110    for issue in &issues {
111        report.push_str(&format!("  {issue}\n"));
112    }
113    let mut kinds: Vec<AccessibilityIssueKind> = issues.iter().map(|issue| issue.kind).collect();
114    kinds.dedup();
115    kinds.sort_by_key(|kind| *kind as u8);
116    kinds.dedup();
117    for kind in kinds {
118        report.push_str(&format!("  {kind:?}: {}\n", kind.advice()));
119    }
120    panic!("{report}");
121}
122
123/// The name a reader speaks for a node: its own label, else the title it
124/// gives the screen, else the words under it.
125pub fn spoken_name(node: &PlacedSemanticsNode) -> String {
126    if let Some(label) = node.label.as_deref() {
127        return label.trim().to_string();
128    }
129    if let Some(title) = node
130        .pane_title
131        .as_deref()
132        .filter(|title| !title.trim().is_empty())
133    {
134        return title.trim().to_string();
135    }
136    let mut words = Vec::new();
137    for child in &node.children {
138        if child.hidden || is_control(child) {
139            continue;
140        }
141        let name = spoken_name(child);
142        if !name.is_empty() {
143            words.push(name);
144        }
145    }
146    words.join(" ")
147}
148
149fn collect_visible<'a>(node: &'a PlacedSemanticsNode, out: &mut Vec<&'a PlacedSemanticsNode>) {
150    if node.hidden {
151        return;
152    }
153    out.push(node);
154    for child in &node.children {
155        collect_visible(child, out);
156    }
157}
158
159fn is_control(node: &PlacedSemanticsNode) -> bool {
160    node.clickable || node.editable_text || node.focusable
161}
162
163fn holds_control(node: &PlacedSemanticsNode) -> bool {
164    !node.hidden && (is_control(node) || node.children.iter().any(holds_control))
165}
166
167fn check_control(node: &PlacedSemanticsNode, name: &str, issues: &mut Vec<AccessibilityIssue>) {
168    if name.is_empty() {
169        issues.push(issue(
170            AccessibilityIssueKind::NoName,
171            node,
172            name,
173            &where_it_is(node),
174        ));
175    }
176    let target = node.target_bounds();
177    if target.width > 0.0
178        && target.height > 0.0
179        && (target.width < MINIMUM_TARGET_SIZE || target.height < MINIMUM_TARGET_SIZE)
180    {
181        let detail = format!(
182            " is {}x{} points",
183            target.width.round(),
184            target.height.round()
185        );
186        issues.push(issue(
187            AccessibilityIssueKind::SmallTarget,
188            node,
189            name,
190            &detail,
191        ));
192    }
193}
194
195fn check_order(parent: &PlacedSemanticsNode, issues: &mut Vec<AccessibilityIssue>) {
196    let in_order: Vec<&PlacedSemanticsNode> = parent
197        .children
198        .iter()
199        .filter(|child| {
200            holds_control(child)
201                && child.traversal_index == 0.0
202                && child.layout_bounds.width > 0.0
203                && child.layout_bounds.height > 0.0
204        })
205        .collect();
206    for pair in in_order.windows(2) {
207        let (before, after) = (pair[0], pair[1]);
208        let after_bottom = after.layout_bounds.y + after.layout_bounds.height;
209        if after_bottom <= before.layout_bounds.y {
210            let detail = format!(" sits above {:?}", spoken_name(before));
211            issues.push(issue(
212                AccessibilityIssueKind::OutOfOrder,
213                after,
214                &spoken_name(after),
215                &detail,
216            ));
217        }
218    }
219}
220
221/// Two controls of one role with one name. Rows of a list at different
222/// places are apart: a reader speaks their place.
223fn check_same_names(visible: &[&PlacedSemanticsNode], issues: &mut Vec<AccessibilityIssue>) {
224    let mut seen: Vec<(String, Option<SemanticsWidgetRole>, Option<usize>, usize)> = Vec::new();
225    for node in visible.iter().filter(|node| is_control(node)) {
226        let name = spoken_name(node);
227        if name.is_empty() {
228            continue;
229        }
230        match seen.iter_mut().find(|(seen_name, role, position, _)| {
231            *seen_name == name && *role == node.widget_role && *position == node.list_position
232        }) {
233            Some(entry) => entry.3 += 1,
234            None => seen.push((name, node.widget_role, node.list_position, 1)),
235        }
236    }
237    for (name, role, _, count) in seen.into_iter().filter(|(_, _, _, count)| *count > 1) {
238        issues.push(AccessibilityIssue {
239            kind: AccessibilityIssueKind::SameName,
240            control: format!("{} {name:?}", role_word(role)),
241            detail: format!(" appears {count} times"),
242        });
243    }
244}
245
246fn issue(
247    kind: AccessibilityIssueKind,
248    node: &PlacedSemanticsNode,
249    name: &str,
250    detail: &str,
251) -> AccessibilityIssue {
252    AccessibilityIssue {
253        kind,
254        control: format!("{} {name:?}", role_word(node.widget_role)),
255        detail: detail.to_string(),
256    }
257}
258
259fn where_it_is(node: &PlacedSemanticsNode) -> String {
260    let bounds = node.layout_bounds;
261    format!(
262        " is {}x{} points at ({}, {})",
263        bounds.width.round(),
264        bounds.height.round(),
265        bounds.x.round(),
266        bounds.y.round()
267    )
268}
269
270fn role_word(role: Option<SemanticsWidgetRole>) -> String {
271    match role {
272        Some(role) => format!("{role:?}").to_lowercase(),
273        None => "control".to_string(),
274    }
275}
276
277/// An issue a test leaves as it is: the screen it is on, `*` for every
278/// screen; the start of the issue line; the reason it stays.
279pub type KnownIssue = (&'static str, &'static str, &'static str);
280
281/// Compares the issues found on `screen` with the list a test keeps and
282/// reports what changed: the lines that are new, and the listed issues that
283/// went away. A test fails on either, so the list only shrinks. An issue
284/// matches a listed one when its line starts with the listed text; a `*`
285/// entry matches on every screen and is never reported as gone.
286pub fn audit_changes(screen: &str, issues: &[String], known: &[KnownIssue]) -> Result<(), String> {
287    let listed = |issue: &str| {
288        known
289            .iter()
290            .any(|(on, text, _)| (*on == "*" || *on == screen) && issue.starts_with(text))
291    };
292    let new: Vec<&str> = issues
293        .iter()
294        .map(String::as_str)
295        .filter(|issue| !listed(issue))
296        .collect();
297    let gone: Vec<&str> = known
298        .iter()
299        .filter(|(on, text, _)| {
300            *on == screen && !issues.iter().any(|issue| issue.starts_with(text))
301        })
302        .map(|(_, text, _)| *text)
303        .collect();
304    if new.is_empty() && gone.is_empty() {
305        return Ok(());
306    }
307    let mut report = String::new();
308    if !new.is_empty() {
309        report.push_str(&format!(
310            "new accessibility issues on {screen}:\n  {}\n",
311            new.join("\n  ")
312        ));
313    }
314    if !gone.is_empty() {
315        report.push_str(&format!(
316            "issues listed for {screen} went away; take them off the list:\n  {}\n",
317            gone.join("\n  ")
318        ));
319    }
320    Err(report)
321}
322
323#[cfg(test)]
324#[path = "tests/accessibility_audit.rs"]
325mod tests;