mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 02:59:17 -06:00
canvas_keys.rs gives every handle on the photo a focusable stand-in with an AccessKit name and position: crop corners, edges and box (crop.drag), spot targets and sources plus a New spot point (Return adds), radial and linear gradient handles and mask pins. Arrows move by 0.5% (Shift 5%), one undo step per press; the stand-in keeps arrows/Return/Space from the shortcuts. The audit now counts any focusable or clickable node; ~50 hand-drawn icons, tiles and rows are announced as buttons. A test walks Tab through the Edit/Crop/Remove/Masking panels top to bottom. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
252 lines
9.8 KiB
Rust
252 lines
9.8 KiB
Rust
//! Accessibility audit: walks the AccessKit tree egui exposes to screen readers (VoiceOver,
|
|
//! Narrator, AT-SPI) in each main view and Develop tool, headless, and fails on an interactive
|
|
//! node with no name, a slider without its value and range, or a canvas tool no key can reach.
|
|
use egui::accesskit::Role;
|
|
use egui::vec2;
|
|
use egui_kittest::{Harness, kittest::NodeT};
|
|
use lightcraft_ui_egui::{
|
|
LightcraftApp, Services,
|
|
state::{RightPanel, ViewMode},
|
|
};
|
|
|
|
fn harness(view: ViewMode, right: RightPanel) -> Harness<'static, LightcraftApp> {
|
|
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
|
app.ui.left_panel = true;
|
|
app.ui.view = view;
|
|
app.ui.right = right;
|
|
let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state(
|
|
|ui, app: &mut LightcraftApp| {
|
|
app.logic(&ui.ctx().clone());
|
|
app.ui(ui);
|
|
},
|
|
app,
|
|
);
|
|
h.run_steps(10);
|
|
h
|
|
}
|
|
|
|
fn interactive(role: Role) -> bool {
|
|
matches!(
|
|
role,
|
|
Role::Button
|
|
| Role::CheckBox
|
|
| Role::Switch
|
|
| Role::RadioButton
|
|
| Role::Slider
|
|
| Role::SpinButton
|
|
| Role::TextInput
|
|
| Role::MultilineTextInput
|
|
| Role::SearchInput
|
|
| Role::ComboBox
|
|
| Role::EditableComboBox
|
|
| Role::Tab
|
|
| Role::Link
|
|
| Role::MenuItem
|
|
| Role::MenuItemCheckBox
|
|
| Role::MenuItemRadio
|
|
| Role::ListBoxOption
|
|
)
|
|
}
|
|
|
|
/// Every problem found in the harness's current tree, one line each.
|
|
fn audit(h: &Harness<'_, LightcraftApp>, place: &str) -> Vec<String> {
|
|
let mut problems = Vec::new();
|
|
let mut count = 0;
|
|
for node in h.root().children_recursive() {
|
|
let n = node.accesskit_node();
|
|
let role = n.role();
|
|
// egui's own scroll bars are announced by their role, as native ones are
|
|
if role == Role::ScrollBar {
|
|
continue;
|
|
}
|
|
// a widget that takes the focus or a click is interactive whatever its role
|
|
let acts = |a| n.data().supports_action(a);
|
|
if !interactive(role) && !acts(egui::accesskit::Action::Focus) && !acts(egui::accesskit::Action::Click) {
|
|
continue;
|
|
}
|
|
count += 1;
|
|
let name = n.label().unwrap_or_default();
|
|
let name = if name.trim().is_empty() { n.value().unwrap_or_default() } else { name };
|
|
if name.trim().is_empty() {
|
|
problems.push(format!("{place}: unnamed {role:?} at {:?}", n.bounding_box()));
|
|
}
|
|
if role == Role::Slider && (n.numeric_value().is_none() || n.min_numeric_value().is_none() || n.max_numeric_value().is_none()) {
|
|
problems.push(format!("{place}: slider {name:?} lacks value/range"));
|
|
}
|
|
}
|
|
assert!(count > 5, "{place}: the tree has interactive nodes ({count})");
|
|
problems
|
|
}
|
|
|
|
#[test]
|
|
fn every_interactive_node_has_a_name_in_every_view_and_tool() {
|
|
let mut problems = Vec::new();
|
|
for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail, ViewMode::Compare, ViewMode::Survey, ViewMode::Reference] {
|
|
let h = harness(view, RightPanel::None);
|
|
problems.extend(audit(&h, &format!("{view:?}")));
|
|
}
|
|
for right in [
|
|
RightPanel::Edit,
|
|
RightPanel::Profiles,
|
|
RightPanel::Crop,
|
|
RightPanel::Remove,
|
|
RightPanel::Masking,
|
|
RightPanel::RedEye,
|
|
RightPanel::Versions,
|
|
RightPanel::Activity,
|
|
RightPanel::Keywords,
|
|
RightPanel::Info,
|
|
] {
|
|
let h = harness(ViewMode::Detail, right);
|
|
problems.extend(audit(&h, &format!("Detail/{right:?}")));
|
|
}
|
|
// the dialogs: each a window with a title, every control in it named
|
|
for dialog in [
|
|
"dialog.export",
|
|
"dialog.copySettings",
|
|
"dialog.pasteSettings",
|
|
"dialog.createPreset",
|
|
"dialog.rename",
|
|
"dialog.newAlbum",
|
|
"dialog.newSmartAlbum",
|
|
"dialog.captureTime",
|
|
"dialog.labelNames",
|
|
"dialog.allMetadata",
|
|
"dialog.saveMetadataPreset",
|
|
"dialog.autoStack",
|
|
"dialog.contactSheet",
|
|
] {
|
|
let mut h = harness(ViewMode::PhotoGrid, RightPanel::None);
|
|
if let Err(e) = h.state_mut().run(dialog, serde_json::json!({})) {
|
|
problems.push(format!("{dialog}: does not open ({e})"));
|
|
continue;
|
|
}
|
|
h.run_steps(6);
|
|
problems.extend(audit(&h, dialog));
|
|
let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| n.role() == Role::Window).count();
|
|
let untitled = h
|
|
.root()
|
|
.children_recursive()
|
|
.map(|n| n.accesskit_node())
|
|
.filter(|n| n.role() == Role::Window && n.label().unwrap_or_default().trim().is_empty())
|
|
.count();
|
|
if untitled > 0 {
|
|
problems.push(format!("{dialog}: {untitled} of {windows} windows have no title"));
|
|
}
|
|
}
|
|
problems.sort();
|
|
problems.dedup();
|
|
assert!(problems.is_empty(), "{} accessibility problems:\n{}", problems.len(), problems.join("\n"));
|
|
}
|
|
|
|
fn slider_value(h: &Harness<'_, LightcraftApp>, name: &str) -> Option<f64> {
|
|
h.root()
|
|
.children_recursive()
|
|
.map(|n| n.accesskit_node())
|
|
.find(|n| n.role() == Role::Slider && n.label().as_deref() == Some(name))
|
|
.and_then(|n| n.numeric_value())
|
|
}
|
|
|
|
/// A screen reader's Increment on a develop slider moves it one step, as an arrow key does.
|
|
#[test]
|
|
fn screen_reader_increment_moves_a_slider() {
|
|
let mut h = harness(ViewMode::Detail, RightPanel::Edit);
|
|
let before = slider_value(&h, "Exposure").expect("Exposure is in the tree");
|
|
let target = h
|
|
.root()
|
|
.children_recursive()
|
|
.find(|n| n.accesskit_node().role() == Role::Slider && n.accesskit_node().label().as_deref() == Some("Exposure"))
|
|
.unwrap()
|
|
.accesskit_node()
|
|
.locate();
|
|
h.event(egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest {
|
|
action: egui::accesskit::Action::Increment,
|
|
target_node: target.0,
|
|
target_tree: target.1,
|
|
data: None,
|
|
}));
|
|
h.run_steps(4);
|
|
let after = slider_value(&h, "Exposure").unwrap();
|
|
assert!(after > before, "{before} -> {after}");
|
|
}
|
|
|
|
/// The canvas tools' settings (crop angle and offsets, spot size, mask amount) are reachable with
|
|
/// Tab alone, and the arrow keys then adjust them.
|
|
#[test]
|
|
fn canvas_tool_sliders_are_reachable_with_tab() {
|
|
for right in [RightPanel::Crop, RightPanel::Remove] {
|
|
let mut h = harness(ViewMode::Detail, right);
|
|
let mut reached = None;
|
|
for _ in 0..300 {
|
|
h.key_press(egui::Key::Tab);
|
|
h.run_steps(1);
|
|
let focused = h.root().children_recursive().map(|n| n.accesskit_node()).find(|n| n.is_focused() && n.role() == Role::Slider);
|
|
if let Some(n) = focused {
|
|
reached = Some((n.label().unwrap_or_default(), n.numeric_value()));
|
|
break;
|
|
}
|
|
}
|
|
let (name, before) = reached.unwrap_or_else(|| panic!("{right:?}: no slider takes the focus with Tab"));
|
|
h.key_press(egui::Key::ArrowUp);
|
|
h.run_steps(4);
|
|
let after = slider_value(&h, &name);
|
|
assert_ne!(before, after, "{right:?}: ArrowUp on the Tab-focused {name:?}");
|
|
}
|
|
}
|
|
|
|
/// The canvas handles are in the tree by name, with their position as the value.
|
|
#[test]
|
|
fn canvas_handles_are_named_with_their_position() {
|
|
let h = harness(ViewMode::Detail, RightPanel::Crop);
|
|
let found: Vec<(String, String)> = h
|
|
.root()
|
|
.children_recursive()
|
|
.map(|n| n.accesskit_node())
|
|
.filter(|n| n.role() == Role::Button)
|
|
.map(|n| (n.label().unwrap_or_default(), n.value().unwrap_or_default()))
|
|
.collect();
|
|
for name in ["Crop top-left corner", "Crop right edge", "Crop box"] {
|
|
assert!(found.iter().any(|(l, v)| l == name && v.contains('%')), "{name}: {found:?}");
|
|
}
|
|
let h = harness(ViewMode::Detail, RightPanel::Remove);
|
|
assert!(h.root().children_recursive().any(|n| n.accesskit_node().label().is_some_and(|l| l.starts_with("New spot"))));
|
|
}
|
|
|
|
/// Tab walks each right panel from top to bottom (rows left to right): the order a sighted user
|
|
/// reads it in. Each step down may not jump back up by more than a row.
|
|
#[test]
|
|
fn tab_walks_the_right_panels_top_to_bottom() {
|
|
for right in [RightPanel::Edit, RightPanel::Crop, RightPanel::Remove, RightPanel::Masking] {
|
|
let mut h = harness(ViewMode::Detail, right);
|
|
let panel_left = 1500.0 - h.state().ui.right_width - 10.0;
|
|
// the tool strip beside the panel is a column of its own, walked before it
|
|
let strip = h
|
|
.root()
|
|
.children_recursive()
|
|
.map(|n| n.accesskit_node())
|
|
.find(|n| n.label().as_deref() == Some("Edit (E)"))
|
|
.and_then(|n| n.bounding_box())
|
|
.map(|b| b.x0 as f32)
|
|
.unwrap_or(f32::INFINITY);
|
|
let mut seen: Vec<(String, f32)> = Vec::new();
|
|
for _ in 0..120 {
|
|
h.key_press(egui::Key::Tab);
|
|
h.run_steps(1);
|
|
let Some(n) = h.root().children_recursive().map(|n| n.accesskit_node()).find(|n| n.is_focused()) else { continue };
|
|
let Some(b) = n.bounding_box() else { continue };
|
|
if (b.x0 as f32) < panel_left || (b.x0 as f32) >= strip - 1.0 {
|
|
continue;
|
|
}
|
|
let name = n.label().unwrap_or_default();
|
|
if seen.iter().any(|(s, y)| *s == name && (*y - b.y0 as f32).abs() < 1.0) && !name.is_empty() {
|
|
break;
|
|
}
|
|
seen.push((name, b.y0 as f32));
|
|
}
|
|
assert!(seen.len() > 3, "{right:?}: Tab reaches the panel: {seen:?}");
|
|
for w in seen.windows(2) {
|
|
assert!(w[1].1 >= w[0].1 - 30.0, "{right:?}: Tab jumps up from {:?} to {:?}\n{seen:?}", w[0], w[1]);
|
|
}
|
|
}
|
|
}
|