mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 20:17:18 -06:00
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:
co-authored by
Claude Opus 5.5
parent
9adcfe1a2c
commit
9adfb61819
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 | ⬜ | | |
|
||||
|
||||
Reference in New Issue
Block a user