1use std::collections::{BTreeMap, HashSet};
17use std::fmt;
18
19use ravenrs_core::{AccessAction, AccessTree, Color, Event, Key, Modifiers, Rect, Role};
20use ravenrs_style::{Semantic, Theme};
21use ravenrs_widgets::Ui;
22
23pub const TEXT_CONTRAST: f32 = 4.5;
25pub const UI_CONTRAST: f32 = 3.0;
27const MOST_TABS: usize = 400;
29
30#[derive(Clone, Debug, PartialEq)]
32#[non_exhaustive]
33pub enum Problem {
34 Unnamed {
36 role: Role,
38 bounds: Rect,
40 },
41 RepeatedId {
43 id: u64,
45 },
46 FocusOnNothing,
48 FocusStuck {
50 on: Option<String>,
52 },
53 FocusTrapped {
55 reached: usize,
57 of: usize,
59 missed: Option<String>,
61 },
62 LowContrast {
64 theme: String,
66 pair: String,
68 ratio: f32,
70 least: f32,
72 },
73 WindowHeld {
75 name: Option<String>,
77 },
78 SharedShortcut {
80 keys: String,
82 times: usize,
84 },
85}
86
87impl fmt::Display for Problem {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 match self {
90 Self::Unnamed { role, bounds } => write!(f, "a {role:?} has no name, at {bounds:?}"),
91 Self::RepeatedId { id } => write!(f, "the id {id} appears more than once"),
92 Self::FocusOnNothing => f.write_str("the focus is on a node the tree does not have"),
93 Self::FocusStuck { on } => {
94 write!(
95 f,
96 "Tab does not move the focus from {}",
97 on.as_deref().unwrap_or("an unnamed control")
98 )
99 }
100 Self::FocusTrapped { reached, of, missed } => write!(
101 f,
102 "Tab goes round {reached} of {of} controls and never reaches {}",
103 missed.as_deref().unwrap_or("the others")
104 ),
105 Self::LowContrast { theme, pair, ratio, least } => {
106 write!(f, "{theme}: {pair} is {ratio:.2}:1, under {least}:1")
107 }
108 Self::WindowHeld { name } => {
109 write!(f, "Escape does not close the window {}", name.as_deref().unwrap_or("with no name"))
110 }
111 Self::SharedShortcut { keys, times } => write!(f, "{times} commands answer to {keys}"),
112 }
113 }
114}
115
116#[derive(Clone, Debug, PartialEq)]
118#[non_exhaustive]
119pub struct Finding {
120 pub place: String,
122 pub problem: Problem,
124}
125
126impl Finding {
127 pub fn new(place: impl Into<String>, problem: Problem) -> Self {
129 Self { place: place.into(), problem }
130 }
131}
132
133impl fmt::Display for Finding {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 write!(f, "{}: {}", self.place, self.problem)
136 }
137}
138
139#[derive(Clone, Debug, Default, PartialEq)]
141#[non_exhaustive]
142pub struct Report {
143 pub findings: Vec<Finding>,
145 pub places: Vec<String>,
147}
148
149impl Report {
150 pub fn is_clean(&self) -> bool {
152 self.findings.is_empty()
153 }
154
155 #[track_caller]
161 pub fn assert_clean(&self) {
162 assert!(self.is_clean(), "{self}");
163 }
164
165 fn add(&mut self, found: Vec<Finding>) {
166 for finding in found {
167 if !self.findings.contains(&finding) {
168 self.findings.push(finding);
169 }
170 }
171 }
172}
173
174impl fmt::Display for Report {
175 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176 writeln!(f, "accessibility: {} problems in {} screens", self.findings.len(), self.places.len())?;
177 for finding in &self.findings {
178 writeln!(f, "- {finding}")?;
179 }
180 Ok(())
181 }
182}
183
184pub fn is_control(role: Role) -> bool {
186 matches!(
187 role,
188 Role::Button
189 | Role::Link
190 | Role::CheckBox
191 | Role::Switch
192 | Role::RadioButton
193 | Role::TextInput
194 | Role::MultilineTextInput
195 | Role::Slider
196 | Role::SpinButton
197 | Role::ComboBox
198 | Role::Tab
199 | Role::MenuItem
200 | Role::ListBoxOption
201 | Role::TreeItem
202 )
203}
204
205pub fn check_tree(tree: &AccessTree, place: &str) -> Vec<Finding> {
207 let mut found = Vec::new();
208 let mut ids = HashSet::new();
209 for node in &tree.nodes {
210 if !ids.insert(node.id) {
211 found.push(Finding::new(place, Problem::RepeatedId { id: node.id }));
212 }
213 let named = node.name.as_deref().is_some_and(|name| !name.trim().is_empty());
214 if is_control(node.role) && !named {
215 found.push(Finding::new(place, Problem::Unnamed { role: node.role, bounds: node.bounds }));
216 }
217 }
218 if tree.focus.is_some_and(|focus| tree.get(focus).is_none()) {
219 found.push(Finding::new(place, Problem::FocusOnNothing));
220 }
221 found
222}
223
224fn press(ui: &mut Ui, key: Key, modifiers: Modifiers) {
225 ui.handle_event(&Event::KeyDown { key: key.clone(), modifiers });
226 ui.handle_event(&Event::KeyUp { key, modifiers });
227 ui.update();
228}
229
230fn windows(tree: &AccessTree) -> Vec<u64> {
232 tree.nodes.iter().filter(|n| n.modal || n.role == Role::Dialog).map(|n| n.id).collect()
233}
234
235pub fn check_focus(ui: &mut Ui, place: &str) -> Vec<Finding> {
242 let tree = ui.access_tree();
243 let names: BTreeMap<u64, Option<String>> =
244 tree.nodes.iter().filter(|n| n.focusable).map(|n| (n.id, n.name.clone())).collect();
245 if names.len() < 2 {
246 return Vec::new();
247 }
248 let opened = windows(&tree);
249 let mut seen: Vec<u64> = Vec::new();
250 let mut closed = false;
251 let mut before = tree.focus;
252 let mut last = tree;
253 for _ in 0..(names.len() + 2).min(MOST_TABS) {
254 press(ui, Key::Tab, Modifiers::default());
255 let now = ui.access_tree();
256 if windows(&now).iter().any(|w| !opened.contains(w)) {
257 return Vec::new();
258 }
259 let Some(focus) = now.focus else { break };
260 if before == Some(focus) {
261 let on = now.get(focus).and_then(|n| n.name.clone());
262 return vec![Finding::new(place, Problem::FocusStuck { on })];
263 }
264 before = Some(focus);
265 last = now;
266 if seen.contains(&focus) {
267 closed = true;
268 break;
269 }
270 seen.push(focus);
271 }
272 let stayed: Vec<(&u64, &Option<String>)> =
273 names.iter().filter(|(id, _)| last.get(**id).is_some_and(|n| n.focusable)).collect();
274 let reached = stayed.iter().filter(|(id, _)| seen.contains(id)).count();
275 if closed && reached < stayed.len() {
276 let missed = stayed.iter().find(|(id, _)| !seen.contains(id)).and_then(|(_, name)| (*name).clone());
277 return vec![Finding::new(place, Problem::FocusTrapped { reached, of: stayed.len(), missed })];
278 }
279 Vec::new()
280}
281
282fn close_windows(ui: &mut Ui, place: &str) -> Vec<Finding> {
284 for _ in 0..4 {
285 if windows(&ui.access_tree()).is_empty() {
286 return Vec::new();
287 }
288 press(ui, Key::Escape, Modifiers::default());
289 settle(ui);
290 }
291 let tree = ui.access_tree();
292 let held = tree.nodes.iter().find(|n| n.modal || n.role == Role::Dialog);
293 held.map(|n| vec![Finding::new(place, Problem::WindowHeld { name: n.name.clone() })]).unwrap_or_default()
294}
295
296pub fn check_shortcuts(ui: &Ui, place: &str) -> Vec<Finding> {
298 let mut counted: Vec<(ravenrs_widgets::Hotkey, usize)> = Vec::new();
299 for keys in ui.shortcuts() {
300 match counted.iter_mut().find(|(known, _)| *known == keys) {
301 Some((_, times)) => *times += 1,
302 None => counted.push((keys, 1)),
303 }
304 }
305 counted
306 .into_iter()
307 .filter(|(_, times)| *times > 1)
308 .map(|(keys, times)| Finding::new(place, Problem::SharedShortcut { keys: keys.label(), times }))
309 .collect()
310}
311
312fn over(color: Color, ground: Color) -> Color {
314 ground.lerp(color.with_alpha(1.0), color.a)
315}
316
317const PAIRS: &[(Semantic, Semantic, f32)] = &[
320 (Semantic::Ink, Semantic::Canvas, TEXT_CONTRAST),
321 (Semantic::Ink, Semantic::Surface, TEXT_CONTRAST),
322 (Semantic::Ink, Semantic::SurfaceElevated, TEXT_CONTRAST),
323 (Semantic::InkMuted, Semantic::Canvas, TEXT_CONTRAST),
324 (Semantic::InkMuted, Semantic::Surface, TEXT_CONTRAST),
325 (Semantic::OnAccent, Semantic::Accent, TEXT_CONTRAST),
326 (Semantic::FocusRing, Semantic::Canvas, UI_CONTRAST),
327 (Semantic::FocusRing, Semantic::Surface, UI_CONTRAST),
328];
329
330pub fn check_theme(theme: &Theme) -> Vec<Finding> {
332 let canvas = theme.color(Semantic::Canvas).with_alpha(1.0);
333 PAIRS
334 .iter()
335 .filter_map(|&(front, back, least)| {
336 let ground = over(theme.color(back), canvas);
337 let ratio = over(theme.color(front), ground).contrast_ratio(ground);
338 (ratio < least).then(|| {
339 Finding::new(
340 theme.name,
341 Problem::LowContrast {
342 theme: theme.name.to_owned(),
343 pair: format!("{front:?} on {back:?}"),
344 ratio,
345 least,
346 },
347 )
348 })
349 })
350 .collect()
351}
352
353fn navigation_items(tree: &AccessTree) -> Vec<(Role, String)> {
355 let mut items = Vec::new();
356 let mut stack: Vec<u64> =
357 tree.nodes.iter().filter(|n| n.role == Role::Navigation).map(|n| n.id).collect();
358 let mut visited = HashSet::new();
359 while let Some(id) = stack.pop() {
360 if !visited.insert(id) {
361 continue;
362 }
363 let Some(node) = tree.get(id) else { continue };
364 let opens = matches!(node.role, Role::Button | Role::Link | Role::TreeItem)
365 && node.actions.contains(&AccessAction::Click)
366 && node.expanded.is_none()
367 && !node.disabled;
368 if opens && let Some(name) = node.name.clone().filter(|n| !n.trim().is_empty()) {
369 let item = (node.role, name);
370 if !items.contains(&item) {
371 items.push(item);
372 }
373 }
374 stack.extend(node.children.iter().rev());
375 }
376 items.sort_by_key(|(role, name)| {
378 tree.nodes.iter().position(|n| n.role == *role && n.name.as_deref() == Some(name.as_str()))
379 });
380 items
381}
382
383fn settle(ui: &mut Ui) {
384 for _ in 0..3 {
385 ui.update();
386 }
387}
388
389#[derive(Clone, Debug, Default)]
400pub struct Audit {
401 themes: Vec<Theme>,
402 open: Vec<String>,
403 skip: Vec<String>,
404 focus: bool,
405}
406
407impl Audit {
408 pub fn new() -> Self {
410 Self { focus: true, ..Self::default() }
411 }
412
413 pub fn theme(mut self, theme: Theme) -> Self {
415 self.themes.push(theme);
416 self
417 }
418
419 pub fn open(mut self, button: impl Into<String>) -> Self {
422 self.open.push(button.into());
423 self
424 }
425
426 pub fn skip(mut self, item: impl Into<String>) -> Self {
428 self.skip.push(item.into());
429 self
430 }
431
432 pub fn without_focus(mut self) -> Self {
434 self.focus = false;
435 self
436 }
437
438 pub fn screen(&self, ui: &mut Ui, place: &str) -> Report {
440 let mut report = Report::default();
441 self.look(ui, place, &mut report);
442 report.add(self.contrast(ui));
443 report
444 }
445
446 fn contrast(&self, ui: &Ui) -> Vec<Finding> {
447 let shown = ui.theme();
448 let mut found = check_theme(&shown);
449 for theme in self.themes.iter().filter(|t| t.name != shown.name) {
450 found.extend(check_theme(theme));
451 }
452 found
453 }
454
455 fn look(&self, ui: &mut Ui, place: &str, report: &mut Report) {
456 settle(ui);
457 report.places.push(place.to_owned());
458 report.add(check_tree(&ui.access_tree(), place));
459 report.add(check_shortcuts(ui, place));
460 if self.focus {
461 report.add(check_focus(ui, place));
462 }
463 }
464
465 pub fn app(&self, ui: &mut Ui) -> Report {
468 let mut report = Report::default();
469 report.add(self.contrast(ui));
470 settle(ui);
471 let start = ui.access_tree();
472 let items = navigation_items(&start);
473 self.look(ui, "start", &mut report);
474 report.add(close_windows(ui, "start"));
475 for (role, name) in items.iter().filter(|(_, name)| !self.skip.contains(name)) {
476 if !click(ui, *role, name) {
477 continue;
478 }
479 self.look(ui, name, &mut report);
480 report.add(close_windows(ui, name));
482 for button in &self.open {
483 if click(ui, Role::Button, button) {
484 let place = format!("{name} > {button}");
485 self.look(ui, &place, &mut report);
486 report.add(close_windows(ui, &place));
487 }
488 }
489 }
490 report
491 }
492}
493
494fn click(ui: &mut Ui, role: Role, name: &str) -> bool {
496 let tree = ui.access_tree();
497 let Some(node) = tree
498 .nodes
499 .iter()
500 .find(|n| n.role == role && n.name.as_deref() == Some(name) && !n.disabled)
501 .filter(|n| n.actions.contains(&AccessAction::Click))
502 else {
503 return false;
504 };
505 ui.perform_action(node.id, AccessAction::Click);
506 settle(ui);
507 true
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use ravenrs_core::AccessNode;
514
515 #[test]
516 fn a_control_without_a_name_and_a_repeated_id_are_found() {
517 let mut tree = AccessTree::default();
518 tree.nodes.push(AccessNode::new(1, Role::Window, Rect::ZERO));
519 tree.nodes.push(AccessNode::new(2, Role::Button, Rect::ZERO));
520 let mut named = AccessNode::new(2, Role::Label, Rect::ZERO);
521 named.name = Some("Nome".into());
522 tree.nodes.push(named);
523 tree.focus = Some(9);
524 let found = check_tree(&tree, "teste");
525 assert!(found.iter().any(|f| matches!(f.problem, Problem::Unnamed { role: Role::Button, .. })));
526 assert!(found.iter().any(|f| f.problem == Problem::RepeatedId { id: 2 }));
527 assert!(found.iter().any(|f| f.problem == Problem::FocusOnNothing));
528 }
529
530 #[test]
531 fn the_themes_that_come_with_the_library_read_well() {
532 for theme in [Theme::raven_dark(), Theme::raven_light(), Theme::raven_glass()] {
533 let found = check_theme(&theme);
534 assert!(found.is_empty(), "{found:?}");
535 }
536 }
537
538 #[test]
539 fn a_theme_with_faint_text_is_found() {
540 let mut theme = Theme::raven_dark();
541 theme.palette.ink_muted = theme.palette.bg_000;
542 let found = check_theme(&theme);
543 assert!(
544 found.iter().any(
545 |f| matches!(&f.problem, Problem::LowContrast { pair, .. } if pair.contains("InkMuted"))
546 )
547 );
548 }
549}