From 9adfb618190b79bf571bcc20dd4422ecd7890e1c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:45:25 -0400 Subject: [PATCH] LR-BEHAV-ACCESS: screen-reader names for the custom widgets AccessKit (already on in the desktop build) now sees what the painted widgets are: sliders announce their control and value, text / icon buttons and dropdowns their label or tooltip and state, section headers and flyouts open / closed, left-panel sources their name and count, grid thumbnails file name, rating, flag and label. Test reads the AccessKit tree. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/grid.rs | 14 ++++++++++ crates/ui-egui/src/panels/left.rs | 5 ++++ crates/ui-egui/src/widgets.rs | 45 +++++++++++++++++++++++++++++++ docs/parity.md | 8 +++--- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 8e009d8ef..8555786da 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -286,6 +286,20 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square let resp = ui.interact(r, egui::Id::new(("cell", id.0)), Sense::click_and_drag()); register(ui.ctx(), format!("thumb:{}", id.0), r); let selected = app.session.selection.contains(id); + // screen readers: the file, then rating / flag / label + let mut spoken = photo.file_name.clone(); + if photo.rating > 0 { + spoken.push_str(&format!(", {} star{}", photo.rating, if photo.rating == 1 { "" } else { "s" })); + } + match photo.flag { + lightcraft_catalog::Flag::Pick => spoken.push_str(", picked"), + lightcraft_catalog::Flag::Reject => spoken.push_str(", rejected"), + lightcraft_catalog::Flag::None => {} + } + if let Some(l) = photo.label { + spoken.push_str(&format!(", {} label", app.session.catalog.label_name(l))); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &spoken)); let active = app.session.selection.active == Some(id); let p = ui.painter(); let img_rect = if square { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 04e6af292..140186f88 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -23,6 +23,11 @@ fn row( let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::click()); register(ui.ctx(), format!("source:{id}"), r); + let name = match count { + Some(n) => format!("{label}, {n} photos"), + None => label.to_string(), + }; + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name)); let inner = r.shrink2(vec2(8.0, 0.0)); if selected { ui.painter().rect_filled(inner, 4.0, t.canvas); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index bfa0d6033..8ce025265 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -112,6 +112,9 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0)); let id = ui.id().with(spec.id); let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); + // screen readers: a slider named after its control, with its value + let label_text = label_override.unwrap_or(spec.label).to_string(); + resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone())); let label_resp = ui.interact(label_rect, id.with("label"), Sense::click()); register(ui.ctx(), format!("slider:{}", spec.id), track_rect); let mut out = SliderOut::default(); @@ -214,6 +217,7 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O let t = Tokens::get(ui.ctx()); let w = ui.available_width(); let (r, resp) = ui.allocate_exact_size(vec2(w, 52.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); register(ui.ctx(), format!("section:{id}"), r); let p = ui.painter(); if resp.hovered() { @@ -255,6 +259,7 @@ pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) -> let (outer, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let r = Rect::from_min_max(pos2(outer.left() + 16.0, outer.top() + 4.0), pos2(outer.right() - 16.0, outer.bottom() - 4.0)); let resp = ui.interact(r, ui.id().with(("flyout", id)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); register(ui.ctx(), format!("flyout:{id}"), r); let p = ui.painter(); p.rect_filled(r, 4.0, if resp.hovered() { t.hover.gamma_multiply(0.8) } else { t.inset }); @@ -278,6 +283,7 @@ pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response let galley = ui.painter().layout_no_wrap(label.to_string(), font, t.text); let size = vec2((galley.size().x + 18.0).max(37.0), 24.0); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, label)); register(ui.ctx(), format!("button:{id}"), r); let fill = if active { t.pressed @@ -331,6 +337,7 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() }); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, active, tooltip)); register(ui.ctx(), format!("icon:{id}"), r); let p = ui.painter(); let sq = Rect::from_center_size(r.center(), vec2(32.0f32.min(size.x), 32.0f32.min(size.y))); @@ -384,6 +391,7 @@ pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Co let galley = ui.painter().layout_no_wrap(text.to_string(), font, color); let size = vec2(galley.size().x + 20.0, galley.size().y.max(20.0)); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, text)); register(ui.ctx(), format!("dropdown:{id}"), r); let c = if resp.hovered() { t.text } else { color }; ui.painter().galley(pos2(r.left(), r.center().y - galley.size().y / 2.0), galley, c); @@ -414,3 +422,40 @@ mod tests { assert_eq!(nudged(&spec("light.contrast", -100.0, 100.0, 1.0), 99.0, 5.0), 100.0, "clamped"); } } + +#[cfg(test)] +mod access_tests { + use super::*; + + /// Screen readers see the custom widgets: sliders by control name with their value, buttons + /// by label or tooltip. + #[test] + fn custom_widgets_describe_themselves() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let spec = lightcraft_develop::controls::find("light.exposure").unwrap(); + let mut found = Vec::new(); + for _ in 0..3 { + let out = ctx.run_ui(egui::RawInput::default(), |ui| { + let _ = slider(ui, spec, 0.5, true, None); + let _ = text_button(ui, "auto", "Auto", false); + let _ = icon_button(ui, "trash", Icon::Trash, vec2(24.0, 24.0), false, true, "Delete mask"); + }); + let mut out = out; + out.textures_delta.clear(); + if let Some(update) = out.platform_output.accesskit_update.take() { + found = update + .nodes + .iter() + .map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string(), n.numeric_value())) + .collect(); + } + } + let has = |role: &str, label: &str| found.iter().any(|(r, l, _)| r == role && l == label); + assert!(has("Slider", "Exposure"), "{found:?}"); + assert!(found.iter().any(|(r, l, v)| r == "Slider" && l == "Exposure" && *v == Some(0.5)), "the slider's value: {found:?}"); + assert!(has("Button", "Auto"), "{found:?}"); + assert!(has("Button", "Delete mask"), "{found:?}"); + } +} diff --git a/docs/parity.md b/docs/parity.md index 956d3fffc..5cc38253a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 13 | 0 | 4 | 1 | 8/8 (100%) | 5/5 (100%) | +| X. Cross-cutting behaviours (BEHAV) | 13 | 1 | 3 | 1 | 8/8 (100%) | 5/5 (100%) | | Y. Menus | 77 | 2 | 6 | 8 | 46/47 (98%) | 22/23 (96%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 22 | 10 | 47 | 9 | — | 18/21 (86%) | -| **Total** | 367 | 24 | 109 | 37 | 193/198 (97%) | 132/144 (92%) | +| **Total** | 367 | 25 | 108 | 37 | 193/198 (97%) | 132/144 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **75.8%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 30.1% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **75.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 30.4% of 158. ## Top gaps @@ -483,7 +483,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | -| LR-BEHAV-ACCESS | Accessibility | P2 | ⬜ | | unverified (screen-reader labels not audited) | +| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | | LR-BEHAV-LOCALIZE | Localisation | P2 | ⬜ | | | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ⬜ | | |