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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 23:45:25 -04:00
co-authored by Claude Opus 5.5
parent 9adcfe1a2c
commit 9adfb61819
4 changed files with 68 additions and 4 deletions
+14
View File
@@ -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 {
+5
View File
@@ -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);
+45
View File
@@ -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<u
pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: bool, enabled: bool, tooltip: &str) -> 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:?}");
}
}
+4 -4
View File
@@ -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.
<!-- /parity:summary -->
## 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 | ⬜ | | |