diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 9b72e65de..d5b069c41 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2836,7 +2836,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { continue; } let resp = ui.interact(cr, egui::Id::new(("film", id.0)), Sense::click()); - crate::widgets::announce(&resp, app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default()); + let failed = app.renderer.thumb(*id).is_none() && app.renderer.failure(Slot::Thumb(*id)).is_some(); + crate::widgets::announce(&resp, film_spoken(app.session.catalog.photo(*id).map_or("", |p| p.file_name.as_str()), failed)); register(ui.ctx(), format!("film:{}", id.0), cr); let state = app.session.selection.state_of(*id); let sel = state == lightcraft_engine::SelectionState::Active; @@ -2862,8 +2863,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let thumb = app.renderer.thumb(*id); if thumb.is_none() { p.rect_filled(img_area, 0.0, t.skeleton); - if app.renderer.failure(Slot::Thumb(*id)).is_some() { - p.text(img_area.center(), Align2::CENTER_CENTER, "!", t.semibold(14.0), t.text_dim); + if failed { + p.text(img_area.center(), Align2::CENTER_CENTER, "!", t.semibold(14.0), t.text); } } if let Some(tex) = thumb { @@ -2934,6 +2935,12 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { }); } +/// What a screen reader says for a filmstrip thumbnail: the file, and why there is no picture +/// when the thumbnail failed (the painted `!` has no name of its own). +pub(crate) fn film_spoken(file_name: &str, failed: bool) -> String { + if failed { format!("{file_name}, {}", crate::i18n::tr("This photo can't be opened")) } else { file_name.to_string() } +} + /// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface). fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) { use crate::icons::{Icon, paint}; diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 9e934f9e8..1e8cb47fa 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -805,11 +805,13 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square if active { p.rect_stroke(ph, 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); } else if selected { - p.rect_stroke(ph, 0.0, Stroke::new(2.0, Color32::from_gray(170)), StrokeKind::Outside); + // a light theme's skeleton is itself mid-grey: the ring takes the dim text colour + let ring = if t.skeleton.r() > 0x80 { t.text_dim } else { Color32::from_gray(170) }; + p.rect_stroke(ph, 0.0, Stroke::new(2.0, ring), StrokeKind::Outside); } if app.renderer.failure(Slot::Thumb(id)).is_some() { // unreadable / missing file - p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text_dim); + p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text); } } // labels and badges diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index 037d619c7..816e4600a 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -856,8 +856,9 @@ fn paint_cell( clip.add(egui::Shape::mesh(mesh)); } None => { - let t = Tokens::get(painter.ctx()); - clip.rect_filled(shown, 0.0, if photo.is_some() { t.skeleton } else { t.track }); + // the page is paper in every theme (its colour is the print's, not the theme's), so + // these stay fixed greys: a photo still to come, and an empty cell + clip.rect_filled(shown, 0.0, if photo.is_some() { Color32::from_gray(52) } else { Color32::from_gray(200) }); } } if im.stroke_enabled && im.stroke_width_pt > 0.0 { diff --git a/crates/ui-egui/src/tests_progress.rs b/crates/ui-egui/src/tests_progress.rs index ebdbda756..22eb3a70a 100644 --- a/crates/ui-egui/src/tests_progress.rs +++ b/crates/ui-egui/src/tests_progress.rs @@ -175,7 +175,25 @@ fn the_thumbnail_placeholder_stands_apart_in_every_theme() { let t = Tokens::for_kind(kind); for (name, behind) in [("grid", t.grid_bg), ("cell", t.cell), ("selected cell", t.cell_selected), ("canvas", t.canvas)] { let c = contrast(t.skeleton, behind); - assert!(c >= 1.1, "{kind:?}: the placeholder on the {name} is {c:.2}:1"); + // the light themes' surfaces are close together, so the placeholder has to be set + // further off them to read at all + let floor = if matches!(kind, ThemeKind::Silver | ThemeKind::Paper) { 1.15 } else { 1.1 }; + assert!(c >= floor, "{kind:?}: the placeholder on the {name} is {c:.2}:1"); } + let mark = contrast(t.text, t.skeleton); + assert!(mark >= 3.0, "{kind:?}: the failure mark on the placeholder is {mark:.2}:1"); } } + +#[test] +fn a_failed_filmstrip_thumbnail_is_announced_with_its_reason() { + let spoken = |failed: bool| { + let (nodes, _, _) = frames(Vec::new(), |ui| { + let resp = ui.interact(Rect::from_min_size(pos2(10.0, 10.0), vec2(120.0, 80.0)), egui::Id::new("film-test"), egui::Sense::click()); + crate::widgets::announce(&resp, crate::panels::detail::film_spoken("IMG_0001.jpg", failed)); + }); + nodes.into_iter().find(|n| n.role == "Button").map(|n| n.label) + }; + assert_eq!(spoken(false).as_deref(), Some("IMG_0001.jpg")); + assert_eq!(spoken(true).as_deref(), Some("IMG_0001.jpg, This photo can't be opened")); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 95e042e39..6c2bac6ac 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -194,7 +194,7 @@ impl Tokens { grid_bg: rgb(0xd9d9d9), cell: rgb(0xcbcbcb), cell_selected: rgb(0xb4b4b4), - skeleton: rgb(0xbfbfbf), + skeleton: rgb(0xa6a6a6), divider: rgb(0xc4c4c4), inset: rgb(0xdcdcdc), field: rgb(0xf6f6f6), @@ -225,7 +225,7 @@ impl Tokens { grid_bg: rgb(0xf2f2f2), cell: rgb(0xe8e8e8), cell_selected: rgb(0xd2d2d2), - skeleton: rgb(0xdddddd), + skeleton: rgb(0xbcbcbc), divider: rgb(0xe0e0e0), inset: rgb(0xf0f0f0), field: rgb(0xffffff),