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.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
221fn 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
277pub type KnownIssue = (&'static str, &'static str, &'static str);
280
281pub 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;