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,
61 pub control: String,
63 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
73pub 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
104pub 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
124pub 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
222fn 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
278pub type KnownIssue = (&'static str, &'static str, &'static str);
281
282pub 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;