1use std::fmt;
5
6use cranpose_foundation::SemanticsWidgetRole;
7
8use crate::placed_semantics::PlacedSemanticsNode;
9
10pub const MINIMUM_TARGET_SIZE: f32 = 24.0;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
17pub enum AccessibilityIssueKind {
18 NoName,
20 SameName,
23 SmallTarget,
25 OutOfOrder,
28 NoPaneTitle,
30 UnnamedImage,
32}
33
34impl AccessibilityIssueKind {
35 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#[derive(Clone, Debug, PartialEq)]
58pub struct AccessibilityIssue {
59 pub kind: AccessibilityIssueKind,
60 pub control: String,
62 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
72pub 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
103pub 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
123pub fn spoken_name(node: &PlacedSemanticsNode) -> String {
126 if let Some(label) = node
127 .label
128 .as_deref()
129 .filter(|label| !label.trim().is_empty())
130 {
131 return label.trim().to_string();
132 }
133 if let Some(title) = node
134 .pane_title
135 .as_deref()
136 .filter(|title| !title.trim().is_empty())
137 {
138 return title.trim().to_string();
139 }
140 let mut words = Vec::new();
141 for child in &node.children {
142 if child.hidden {
143 continue;
144 }
145 let name = spoken_name(child);
146 if !name.is_empty() {
147 words.push(name);
148 }
149 }
150 words.join(" ")
151}
152
153fn collect_visible<'a>(node: &'a PlacedSemanticsNode, out: &mut Vec<&'a PlacedSemanticsNode>) {
154 if node.hidden {
155 return;
156 }
157 out.push(node);
158 for child in &node.children {
159 collect_visible(child, out);
160 }
161}
162
163fn is_control(node: &PlacedSemanticsNode) -> bool {
164 node.clickable || node.editable_text || node.focusable
165}
166
167fn holds_control(node: &PlacedSemanticsNode) -> bool {
168 !node.hidden && (is_control(node) || node.children.iter().any(holds_control))
169}
170
171fn check_control(node: &PlacedSemanticsNode, name: &str, issues: &mut Vec<AccessibilityIssue>) {
172 if name.is_empty() {
173 issues.push(issue(
174 AccessibilityIssueKind::NoName,
175 node,
176 name,
177 &where_it_is(node),
178 ));
179 }
180 let target = node.target_bounds();
181 if target.width > 0.0
182 && target.height > 0.0
183 && (target.width < MINIMUM_TARGET_SIZE || target.height < MINIMUM_TARGET_SIZE)
184 {
185 let detail = format!(
186 " is {}x{} points",
187 target.width.round(),
188 target.height.round()
189 );
190 issues.push(issue(
191 AccessibilityIssueKind::SmallTarget,
192 node,
193 name,
194 &detail,
195 ));
196 }
197}
198
199fn check_order(parent: &PlacedSemanticsNode, issues: &mut Vec<AccessibilityIssue>) {
200 let in_order: Vec<&PlacedSemanticsNode> = parent
201 .children
202 .iter()
203 .filter(|child| {
204 holds_control(child)
205 && child.traversal_index == 0.0
206 && child.layout_bounds.width > 0.0
207 && child.layout_bounds.height > 0.0
208 })
209 .collect();
210 for pair in in_order.windows(2) {
211 let (before, after) = (pair[0], pair[1]);
212 let after_bottom = after.layout_bounds.y + after.layout_bounds.height;
213 if after_bottom <= before.layout_bounds.y {
214 let detail = format!(" sits above {:?}", spoken_name(before));
215 issues.push(issue(
216 AccessibilityIssueKind::OutOfOrder,
217 after,
218 &spoken_name(after),
219 &detail,
220 ));
221 }
222 }
223}
224
225fn check_same_names(visible: &[&PlacedSemanticsNode], issues: &mut Vec<AccessibilityIssue>) {
228 let mut seen: Vec<(String, Option<SemanticsWidgetRole>, Option<usize>, usize)> = Vec::new();
229 for node in visible.iter().filter(|node| is_control(node)) {
230 let name = spoken_name(node);
231 if name.is_empty() {
232 continue;
233 }
234 match seen.iter_mut().find(|(seen_name, role, position, _)| {
235 *seen_name == name && *role == node.widget_role && *position == node.list_position
236 }) {
237 Some(entry) => entry.3 += 1,
238 None => seen.push((name, node.widget_role, node.list_position, 1)),
239 }
240 }
241 for (name, role, _, count) in seen.into_iter().filter(|(_, _, _, count)| *count > 1) {
242 issues.push(AccessibilityIssue {
243 kind: AccessibilityIssueKind::SameName,
244 control: format!("{} {name:?}", role_word(role)),
245 detail: format!(" appears {count} times"),
246 });
247 }
248}
249
250fn issue(
251 kind: AccessibilityIssueKind,
252 node: &PlacedSemanticsNode,
253 name: &str,
254 detail: &str,
255) -> AccessibilityIssue {
256 AccessibilityIssue {
257 kind,
258 control: format!("{} {name:?}", role_word(node.widget_role)),
259 detail: detail.to_string(),
260 }
261}
262
263fn where_it_is(node: &PlacedSemanticsNode) -> String {
264 let bounds = node.layout_bounds;
265 format!(
266 " is {}x{} points at ({}, {})",
267 bounds.width.round(),
268 bounds.height.round(),
269 bounds.x.round(),
270 bounds.y.round()
271 )
272}
273
274fn role_word(role: Option<SemanticsWidgetRole>) -> String {
275 match role {
276 Some(role) => format!("{role:?}").to_lowercase(),
277 None => "control".to_string(),
278 }
279}
280
281pub type KnownIssue = (&'static str, &'static str, &'static str);
284
285pub fn audit_changes(screen: &str, issues: &[String], known: &[KnownIssue]) -> Result<(), String> {
291 let listed = |issue: &str| {
292 known
293 .iter()
294 .any(|(on, text, _)| (*on == "*" || *on == screen) && issue.starts_with(text))
295 };
296 let new: Vec<&str> = issues
297 .iter()
298 .map(String::as_str)
299 .filter(|issue| !listed(issue))
300 .collect();
301 let gone: Vec<&str> = known
302 .iter()
303 .filter(|(on, text, _)| {
304 *on == screen && !issues.iter().any(|issue| issue.starts_with(text))
305 })
306 .map(|(_, text, _)| *text)
307 .collect();
308 if new.is_empty() && gone.is_empty() {
309 return Ok(());
310 }
311 let mut report = String::new();
312 if !new.is_empty() {
313 report.push_str(&format!(
314 "new accessibility issues on {screen}:\n {}\n",
315 new.join("\n ")
316 ));
317 }
318 if !gone.is_empty() {
319 report.push_str(&format!(
320 "issues listed for {screen} went away; take them off the list:\n {}\n",
321 gone.join("\n ")
322 ));
323 }
324 Err(report)
325}
326
327#[cfg(test)]
328mod tests {
329 use cranpose_ui::{Rect, SemanticsRole};
330
331 use super::*;
332
333 fn node(label: Option<&str>, rect: Rect) -> PlacedSemanticsNode {
334 PlacedSemanticsNode {
335 node_id: 0,
336 role: SemanticsRole::Unknown,
337 widget_role: None,
338 label: label.map(str::to_string),
339 state_description: None,
340 clickable: false,
341 interactive: false,
342 toggled: None,
343 selected: None,
344 enabled: true,
345 editable_text: false,
346 focusable: false,
347 hidden: false,
348 pane_title: None,
349 traversal_index: 0.0,
350 list_position: None,
351 layout_bounds: rect,
352 touch_bounds: None,
353 children: Vec::new(),
354 }
355 }
356
357 fn button(label: Option<&str>, rect: Rect) -> PlacedSemanticsNode {
358 PlacedSemanticsNode {
359 widget_role: Some(SemanticsWidgetRole::Button),
360 clickable: true,
361 focusable: true,
362 ..node(label, rect)
363 }
364 }
365
366 fn rect(x: f32, y: f32, width: f32, height: f32) -> Rect {
367 Rect {
368 x,
369 y,
370 width,
371 height,
372 }
373 }
374
375 fn screen(children: Vec<PlacedSemanticsNode>) -> PlacedSemanticsNode {
376 PlacedSemanticsNode {
377 pane_title: Some("Library".to_string()),
378 children,
379 ..node(None, rect(0.0, 0.0, 400.0, 800.0))
380 }
381 }
382
383 fn kinds(root: &PlacedSemanticsNode) -> Vec<AccessibilityIssueKind> {
384 audit_accessibility(root)
385 .into_iter()
386 .map(|issue| issue.kind)
387 .collect()
388 }
389
390 #[test]
391 fn a_named_screen_with_named_large_controls_in_order_passes() {
392 let root = screen(vec![
393 button(Some("Send"), rect(0.0, 0.0, 120.0, 48.0)),
394 button(Some("Cancel"), rect(0.0, 60.0, 120.0, 48.0)),
395 ]);
396 assert!(audit_accessibility(&root).is_empty());
397 assert_accessible(&root);
398 }
399
400 #[test]
401 fn a_control_with_nothing_to_say_is_named_as_such() {
402 let root = screen(vec![button(None, rect(0.0, 0.0, 48.0, 48.0))]);
403 let issues = audit_accessibility(&root);
404 assert_eq!(issues.len(), 1);
405 assert_eq!(
406 issues[0].to_string(),
407 "NoName: button \"\" is 48x48 points at (0, 0)"
408 );
409 }
410
411 #[test]
412 fn the_words_inside_a_control_are_its_name() {
413 let mut send = button(None, rect(0.0, 0.0, 120.0, 48.0));
414 send.children.push(PlacedSemanticsNode {
415 role: SemanticsRole::Text {
416 value: "Send".to_string(),
417 },
418 ..node(Some("Send"), rect(8.0, 8.0, 40.0, 20.0))
419 });
420 assert!(audit_accessibility(&screen(vec![send])).is_empty());
421 }
422
423 #[test]
424 fn two_controls_with_one_name_and_one_role_are_reported_once() {
425 let root = screen(vec![
426 button(Some("Delete"), rect(0.0, 0.0, 120.0, 48.0)),
427 button(Some("Delete"), rect(0.0, 60.0, 120.0, 48.0)),
428 ]);
429 let issues = audit_accessibility(&root);
430 assert_eq!(issues.len(), 1);
431 assert_eq!(
432 issues[0].to_string(),
433 "SameName: button \"Delete\" appears 2 times"
434 );
435 }
436
437 #[test]
438 fn a_small_target_says_its_size() {
439 let root = screen(vec![button(Some("Close"), rect(0.0, 0.0, 16.0, 16.0))]);
440 let issues = audit_accessibility(&root);
441 assert_eq!(
442 issues[0].to_string(),
443 "SmallTarget: button \"Close\" is 16x16 points"
444 );
445 }
446
447 #[test]
448 fn a_touch_target_counts_over_the_drawn_size() {
449 let mut close = button(Some("Close"), rect(0.0, 0.0, 16.0, 16.0));
450 close.touch_bounds = Some(rect(-16.0, -16.0, 48.0, 48.0));
451 assert!(audit_accessibility(&screen(vec![close])).is_empty());
452 }
453
454 #[test]
455 fn a_control_placed_above_the_one_before_it_is_out_of_order() {
456 let root = screen(vec![
457 button(Some("Second"), rect(0.0, 100.0, 120.0, 48.0)),
458 button(Some("First"), rect(0.0, 0.0, 120.0, 48.0)),
459 ]);
460 let issues = audit_accessibility(&root);
461 assert_eq!(kinds(&root), vec![AccessibilityIssueKind::OutOfOrder]);
462 assert_eq!(
463 issues[0].to_string(),
464 "OutOfOrder: button \"First\" sits above \"Second\""
465 );
466 let mut on_purpose = root.clone();
467 on_purpose.children[1].traversal_index = -1.0;
468 assert!(audit_accessibility(&on_purpose).is_empty());
469 }
470
471 #[test]
472 fn a_screen_without_a_title_and_a_picture_without_words_are_reported() {
473 let mut root = node(None, rect(0.0, 0.0, 400.0, 800.0));
474 root.children.push(PlacedSemanticsNode {
475 widget_role: Some(SemanticsWidgetRole::Image),
476 ..node(None, rect(0.0, 0.0, 64.0, 64.0))
477 });
478 assert_eq!(
479 kinds(&root),
480 vec![
481 AccessibilityIssueKind::UnnamedImage,
482 AccessibilityIssueKind::NoPaneTitle
483 ]
484 );
485 }
486
487 #[test]
488 fn a_hidden_control_is_left_alone() {
489 let root = screen(vec![PlacedSemanticsNode {
490 hidden: true,
491 ..button(None, rect(0.0, 0.0, 10.0, 10.0))
492 }]);
493 assert!(audit_accessibility(&root).is_empty());
494 }
495
496 #[test]
497 #[should_panic(expected = "NoName: button \"\"")]
498 fn the_assertion_names_every_issue_and_the_fix() {
499 assert_accessible(&screen(vec![button(None, rect(0.0, 0.0, 48.0, 48.0))]));
500 }
501
502 #[test]
503 fn a_new_issue_and_a_listed_issue_that_went_away_both_fail_the_comparison() {
504 let known: &[KnownIssue] = &[
505 (
506 "library",
507 "SmallTarget: control \"Pill\"",
508 "sits over a tab",
509 ),
510 (
511 "*",
512 "SameName: tab \"Apps\"",
513 "two bars show one set of tabs",
514 ),
515 ];
516 let issues = vec![
517 "SmallTarget: control \"Pill\" is 79x17 points".to_string(),
518 "SameName: tab \"Apps\" appears 2 times".to_string(),
519 ];
520 assert_eq!(audit_changes("library", &issues, known), Ok(()));
521 assert_eq!(
522 audit_changes("settings", &issues[1..], known),
523 Ok(()),
524 "a * entry matches on every screen"
525 );
526 let with_new = [
527 issues.clone(),
528 vec!["NoName: control \"\" is 40x40 points at (1, 2)".to_string()],
529 ]
530 .concat();
531 let report = audit_changes("library", &with_new, known).unwrap_err();
532 assert!(report.contains("new accessibility issues on library"));
533 assert!(report.contains("NoName"));
534 let report = audit_changes("library", &issues[1..], known).unwrap_err();
535 assert!(report.contains("went away"));
536 assert!(report.contains("SmallTarget"));
537 assert_eq!(
538 audit_changes("settings", &[], known),
539 Ok(()),
540 "a * entry is never reported as gone"
541 );
542 }
543
544 #[test]
545 fn rows_of_a_list_with_one_name_are_apart_when_each_has_its_place() {
546 let mut list = screen(vec![
547 button(Some("Scan"), rect(0.0, 0.0, 200.0, 40.0)),
548 button(Some("Scan"), rect(0.0, 50.0, 200.0, 40.0)),
549 ]);
550 let same_names = |root: &PlacedSemanticsNode| {
551 audit_accessibility(root)
552 .iter()
553 .any(|issue| issue.kind == AccessibilityIssueKind::SameName)
554 };
555 assert!(same_names(&list), "two buttons with one name and no place");
556 for (row, position) in list.children.iter_mut().zip(1..) {
557 row.list_position = Some(position);
558 }
559 assert!(!same_names(&list), "the same two as rows 1 and 2 of a list");
560 }
561
562 #[test]
563 fn a_row_wrapped_by_its_list_still_gets_its_place() {
564 let wrapped = |y: f32| PlacedSemanticsNode {
565 children: vec![button(Some("Scan"), rect(0.0, y, 200.0, 40.0))],
566 ..node(None, rect(0.0, y, 200.0, 40.0))
567 };
568 let mut list = screen(vec![wrapped(0.0), wrapped(50.0)]);
569 for (row, position) in list.children.iter_mut().zip(1..) {
570 crate::placed_semantics::place_row(row, position);
571 }
572 let issues = audit_accessibility(&list);
573 assert!(
574 !issues
575 .iter()
576 .any(|issue| issue.kind == AccessibilityIssueKind::SameName),
577 "the buttons under the wrappers carry places 1 and 2: {issues:?}"
578 );
579 }
580
581 #[test]
582 fn a_control_inside_a_row_carries_the_place_of_the_row() {
583 let row = |y: f32| PlacedSemanticsNode {
584 children: vec![
585 button(Some("page thumbnail"), rect(0.0, y, 40.0, 40.0)),
586 button(Some("More"), rect(160.0, y, 40.0, 40.0)),
587 ],
588 ..node(None, rect(0.0, y, 200.0, 40.0))
589 };
590 let mut list = screen(vec![row(0.0), row(50.0)]);
591 for (row, position) in list.children.iter_mut().zip(1..) {
592 crate::placed_semantics::place_row(row, position);
593 }
594 let issues = audit_accessibility(&list);
595 assert!(
596 !issues
597 .iter()
598 .any(|issue| issue.kind == AccessibilityIssueKind::SameName),
599 "the thumbnails and the More buttons sit in rows 1 and 2: {issues:?}"
600 );
601 }
602}