Merge PR #2162: LR-VIEW-PHOTOGRID: the active photo's selection ring shows on light themes and differs from the other selected photos by weight

docs/parity-checklist.md: kept the newer header date and both sides' change clauses; revision rows are the union.
This commit is contained in:
aiongg
2026-10-31 02:06:46 +00:00
11 changed files with 207 additions and 41 deletions
@@ -221,7 +221,7 @@ fn theme_preview(ui: &mut egui::Ui, kind: crate::theme::ThemeKind, width: f32) {
let thumb = cell.shrink(2.5);
painter.rect_filled(thumb, 0.0, Color32::from_rgb(0x6f + 8 * i as u8, 0x80, 0x8f));
if selected {
painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, p.pick), StrokeKind::Outside);
crate::panels::grid::selection_ring(&painter, &p, thumb, lightcraft_engine::SelectionState::Active);
}
}
}
+3 -3
View File
@@ -2,7 +2,7 @@
//! pan) and **Survey** (the selected photos tiled). Rating, flag and label keys act on the active
//! photo only here, and with Auto Advance on they move to the next candidate / photo.
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use lightcraft_catalog::{Flag, PhotoId};
use serde_json::{Value, json};
@@ -286,7 +286,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan);
p.image(tex.tex.id(), display, uv, Color32::WHITE);
} else {
p.rect_filled(img.intersect(img_area), 0.0, Tokens::get(ui.ctx()).skeleton);
p.rect_filled(img.intersect(img_area), 0.0, t.skeleton);
}
if photo.flag == Flag::Reject {
p.rect_filled(display, 0.0, Color32::from_black_alpha(110));
@@ -296,7 +296,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
}
let active = app.session.selection.active == Some(id);
if active {
p.rect_stroke(display.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
super::grid::selection_ring(&p, &t, display.intersect(img_area.shrink(3.0)), lightcraft_engine::SelectionState::Active);
}
// Compare and Reference are where sharpness is judged: as in the loupe, say when what is on
// screen isn't the render on its way (a thumbnail, the last render after an edit, a magnified
@@ -97,7 +97,6 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
crate::widgets::announce(&resp, film_spoken(app.session.catalog.photo(*id).map_or("", |p| p.file_name.as_str()), failed));
register(ui, format!("film:{}", id.0), cr);
let state = app.session.selection.state_of(*id);
let sel = state == lightcraft_engine::SelectionState::Active;
let p = ui.painter();
let label = app.session.catalog.photo(*id).and_then(|ph| ph.label);
let selected = state != lightcraft_engine::SelectionState::NotSelected;
@@ -129,11 +128,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32);
let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s));
p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
if sel {
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);
} else if state == lightcraft_engine::SelectionState::Selected {
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::from_gray(170)), StrokeKind::Outside);
}
crate::panels::grid::selection_ring(p, &t, fr, state);
if app.ui.settings.film_badges
&& let Some(ph) = app.session.catalog.photo(*id)
{
+24 -17
View File
@@ -651,6 +651,25 @@ pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target:
out
}
/// The ring round a selected photo, drawn outside `photo`. The active one is 3 pt in
/// [`Tokens::ring_active`] with a 1 pt line in [`Tokens::ring_inner`] over its inner edge, against
/// the photo (so the ring reads on a white photo and on a black one). The others are a single 1.5 pt line in
/// [`Tokens::ring_selected`]; the two are told apart by weight, not by lightness alone.
pub(crate) fn selection_ring(p: &egui::Painter, t: &Tokens, photo: Rect, state: lightcraft_engine::SelectionState) {
use lightcraft_engine::SelectionState;
match state {
SelectionState::Active => {
// both on the same rect, so no seam opens between them at a fractional scale
p.rect_stroke(photo, 0.0, Stroke::new(3.0, t.ring_active), StrokeKind::Outside);
p.rect_stroke(photo, 0.0, Stroke::new(1.0, t.ring_inner), StrokeKind::Outside);
}
SelectionState::Selected => {
p.rect_stroke(photo, 0.0, Stroke::new(1.5, t.ring_selected), StrokeKind::Outside);
}
SelectionState::NotSelected => {}
}
}
/// A date header: "Wednesday, 30 September 2026 · 12 photos". Clicking it selects the group
/// (Cmd/Shift: adds to the selection).
fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: &[PhotoId], r: Rect, pinned: bool) {
@@ -772,7 +791,6 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
let selected = state != lightcraft_engine::SelectionState::NotSelected;
let spoken = cell_description(app, &photo);
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &spoken));
let active = state == lightcraft_engine::SelectionState::Active;
let p = ui.painter();
let img_rect = if square {
let base = if selected { t.cell_selected } else { t.cell };
@@ -781,7 +799,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
} else {
r
};
paint_cell_thumbnail(app, p, &t, id, img_rect, ThumbFlags { square, selected, active, onscreen, ppp });
paint_cell_thumbnail(app, p, &t, id, img_rect, ThumbFlags { square, state, onscreen, ppp });
// labels and badges
paint_cell_caption(app, p, &t, &photo, r, square);
paint_cell_badges(app, p, &t, &photo, img_rect, BadgeFlags { square, selected, hovered: resp.hovered() });
@@ -814,7 +832,7 @@ fn cell_description(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> S
}
fn paint_cell_thumbnail(app: &mut LightcraftApp, p: &egui::Painter, t: &Tokens, id: PhotoId, img_rect: Rect, flags: ThumbFlags) {
let ThumbFlags { square, selected, active, onscreen, ppp } = flags;
let ThumbFlags { square, state, onscreen, ppp } = flags;
let size = thumb_px(img_rect.width().max(img_rect.height()), ppp);
request_thumb(app, id, size, if onscreen { 10 } else { 5 });
if let Some(tex) = app.renderer.thumb(id) {
@@ -822,22 +840,12 @@ fn paint_cell_thumbnail(app: &mut LightcraftApp, p: &egui::Painter, t: &Tokens,
// Preserve the decoded preview's ratio when it differs from the catalog dimensions.
let fit = super::detail::fit_texture_rect(img_rect, [tw, th]);
p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
if active {
p.rect_stroke(fit.expand(if square { 2.0 } else { 0.0 }), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
} else if selected {
p.rect_stroke(fit, 0.0, Stroke::new(2.0, Color32::from_gray(170)), StrokeKind::Outside);
}
selection_ring(p, t, fit, state);
} else {
let ph = img_rect.shrink(if square { 20.0 } else { 0.0 });
p.rect_filled(ph, 0.0, t.skeleton);
// not drawn yet (or unreadable): the selection is still visible
if active {
p.rect_stroke(ph, 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
} else if selected {
// 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);
}
selection_ring(p, t, ph, state);
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);
@@ -983,8 +991,7 @@ fn cell_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Res
struct ThumbFlags {
square: bool,
selected: bool,
active: bool,
state: lightcraft_engine::SelectionState,
onscreen: bool,
ppp: f32,
}
+1 -1
View File
@@ -174,7 +174,7 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ed
p.rect_filled(pill, 4.0, Color32::from_black_alpha(175));
p.galley(pill.min + vec2(5.0, 3.0), g, Color32::WHITE);
if selected {
p.rect_stroke(face, 3.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
super::grid::selection_ring(p, &t, face, lightcraft_engine::SelectionState::Active);
} else if resp.hovered() {
p.rect_stroke(face, 3.0, Stroke::new(1.0, t.text_dim), StrokeKind::Outside);
}
+41
View File
@@ -318,3 +318,44 @@ fn info_panel_label_in_compare_labels_the_active_photo_and_shows_it() {
assert_eq!(photo(&h, cand).rating, 3);
assert_eq!(photo(&h, sel).rating, 0, "the select is not rated with it");
}
/// Survey frames its active tile in the theme's active ring, which must not be the white that
/// vanishes on a light theme's canvas.
#[test]
fn survey_rings_the_active_tile_in_the_theme_colour() {
use egui::epaint::Shape;
let mut h = demo();
h.app.ui.settings.appearance_mode = crate::state::AppearanceMode::Light;
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1], vis[2]], "active": vis[1]}}), T);
let r = h.request("engine.execute", json!({"command": "view.survey"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
let t = crate::theme::Tokens::get(&h.view.ctx);
assert_ne!(t.ring_active, egui::Color32::WHITE);
// the filmstrip below rings its active cell the same way: only the tile counts here
let films: Vec<egui::Rect> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("film:")).map(|(_, r)| *r).collect();
let rings: Vec<(f32, egui::Color32)> = h
.view
.painted_shapes()
.iter()
.filter_map(|s| match s {
Shape::Rect(rs)
if rs.stroke_kind == egui::StrokeKind::Outside
&& rs.stroke.color == t.ring_active
&& !films.iter().any(|f| f.intersects(rs.rect)) =>
{
Some((rs.stroke.width, rs.stroke.color))
}
_ => None,
})
.collect();
assert_eq!(rings, vec![(3.0, t.ring_active)], "one active tile, ringed once");
let white = h
.view
.painted_shapes()
.iter()
.filter(|s| matches!(s, Shape::Rect(rs) if rs.stroke.width == 2.0 && rs.stroke.color == egui::Color32::WHITE))
.count();
assert_eq!(white, 0, "no white 2 pt frame");
}
+40
View File
@@ -59,3 +59,43 @@ fn filmstrip_stack_badge_expands_and_collapses_the_stack() {
assert!(h.app.session.catalog.stack(StackId(1)).unwrap().collapsed, "a second click collapses it");
assert!(!h.app.session.visible_cloned().contains(&member));
}
/// The filmstrip rings its selected photos by the same rule as the grid, in the theme's colours:
/// the active photo's ring is heavier than the other selected photo's, and a light theme gets no
/// fixed white or grey ring.
#[test]
fn the_filmstrip_marks_the_active_photo_heavier_than_the_other_selected_one() {
use egui::epaint::Shape;
let mut session = lightcraft_engine::Session::with_demo();
let ids = session.visible_cloned();
let (a, b, c) = (ids[0], ids[1], ids[2]);
let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.app.ui.settings.appearance_mode = crate::state::AppearanceMode::Light;
let r = h.request("ui.set", json!({"view": "detail"}), T);
assert_eq!(r["ok"], true, "{r}");
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [a.0, b.0]}}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
let t = crate::theme::Tokens::get(&h.view.ctx);
assert!(crate::theme::current(&h.view.ctx).is_some_and(|k| !k.is_dark()));
let active = h.app.session.selection.active.expect("active");
let other = if active == a { b } else { a };
let shapes = h.view.painted_shapes();
let strokes = |id: lightcraft_catalog::PhotoId| -> Vec<(f32, egui::Color32)> {
let cell = h.app.widgets.iter().find(|(w, _)| *w == format!("film:{}", id.0)).map(|(_, r)| *r).expect("cell drawn");
shapes
.iter()
.filter_map(|s| match s {
Shape::Rect(rs) if rs.stroke.width > 0.0 && rs.stroke_kind == egui::StrokeKind::Outside && cell.contains_rect(rs.rect) => {
Some((rs.stroke.width, rs.stroke.color))
}
_ => None,
})
.collect()
};
let (on_active, on_other, on_unselected) = (strokes(active), strokes(other), strokes(c));
assert_eq!(on_active, vec![(3.0, t.ring_active), (1.0, t.ring_inner)]);
assert_eq!(on_other, vec![(1.5, t.ring_selected)]);
assert!(on_unselected.is_empty(), "{on_unselected:?}");
}
+36 -7
View File
@@ -133,27 +133,56 @@ fn grid_headers_selection_scrolling_stacks_and_shape_changes() {
}
/// Selected photos whose thumbnails have not loaded (or can't) are still marked selected in the
/// justified grid (issue #298): the active one white, the others grey.
/// justified grid (issue #298), in a light theme too (a white ring vanished on the light grid):
/// the ring of the active photo is heavier than the others' and both are the theme's colours, not
/// fixed white and grey.
#[test]
fn unloaded_thumbnails_show_their_selection() {
use egui::epaint::Shape;
let mut h = grid(ViewMode::PhotoGrid);
h.app.ui.settings.appearance_mode = crate::state::AppearanceMode::Light;
h.step();
h.request("engine.execute", json!({"command": "library.selectAll"}), T);
let img = h.snapshot(SETTLE);
assert_eq!(h.view.ctx.pixels_per_point(), 1.0);
h.settle(SETTLE);
let t = crate::theme::Tokens::get(&h.view.ctx);
assert!(crate::theme::current(&h.view.ctx).is_some_and(|k| !k.is_dark()), "light theme");
let active = h.app.session.selection.active.expect("active");
let canvas = h.app.canvas_rect.expect("grid drawn");
let shapes = h.view.painted_shapes();
// the strokes drawn around a photo's cell in paint order (the widest first): (width, colour)
let ring = |r: egui::Rect| -> Vec<(f32, egui::Color32)> {
shapes
.iter()
.filter_map(|s| match s {
Shape::Rect(rs)
if rs.stroke.width > 0.0 && rs.stroke_kind == egui::StrokeKind::Outside && (rs.rect.center() - r.center()).length() < 0.5 =>
{
Some((rs.stroke.width, rs.stroke.color))
}
_ => None,
})
.collect()
};
let mut checked = 0;
let mut saw_active = false;
for (w, r) in h.app.widgets.iter().filter(|(w, _)| w.starts_with("thumb:")) {
if !canvas.shrink(4.0).contains_rect(*r) || r.min.x < 6.0 {
if !canvas.shrink(4.0).contains_rect(*r) {
continue;
}
let id = w.trim_start_matches("thumb:").parse::<u64>().unwrap();
let want = if id == active.0 { egui::Color32::WHITE } else { egui::Color32::from_gray(170) };
let px = img[((r.min.x - 1.0) as usize, r.center().y as usize)];
assert_eq!(px, want, "photo {id}");
let strokes = ring(*r);
if id == active.0 {
assert_ne!(t.ring_active, egui::Color32::WHITE, "the light theme's ring must not vanish on its grid");
assert_eq!(strokes, vec![(3.0, t.ring_active), (1.0, t.ring_inner)], "photo {id}: active ring");
saw_active = true;
} else {
assert_eq!(strokes.iter().map(|(w, _)| *w).sum::<f32>(), 1.5, "photo {id}: selected ring {strokes:?}");
assert_eq!(strokes.iter().map(|(_, c)| *c).collect::<Vec<_>>(), vec![t.ring_selected], "photo {id}");
}
checked += 1;
}
assert!(checked >= 3, "checked {checked}");
assert!(saw_active, "the active photo (top left) was checked");
}
/// A folder of unedited raws on a slow share: the grid must not decode every raw to draw a 512 px
+46
View File
@@ -47,6 +47,15 @@ pub struct Tokens {
/// A thumbnail's place while its picture is still being made: static, apart from the grid, the
/// cells and the canvas in every theme.
pub skeleton: Color32,
/// The ring round the active photo (outer line, 3 pt): 3:1 or more against the grid and the
/// square grid's cells. The photo itself is parted from it by [`Tokens::ring_inner`].
pub ring_active: Color32,
/// The thin line (1 pt) between the active photo and its ring: the ring's opposite, so the ring
/// reads on a photo of any brightness.
pub ring_inner: Color32,
/// The ring round the other selected photos (1.5 pt, against the active one's 3 pt): 3:1 or
/// more against the grid.
pub ring_selected: Color32,
pub divider: Color32,
pub inset: Color32,
pub field: Color32,
@@ -129,6 +138,9 @@ impl Default for Tokens {
cell: Color32::from_rgb(0x1c, 0x1c, 0x1c),
cell_selected: Color32::from_rgb(0x2d, 0x2d, 0x2d),
skeleton: Color32::from_rgb(0x25, 0x25, 0x25),
ring_active: Color32::from_rgb(0xff, 0xff, 0xff),
ring_inner: Color32::from_rgb(0x00, 0x00, 0x00),
ring_selected: Color32::from_rgb(0xc0, 0xc0, 0xc0),
divider: Color32::from_rgb(0x1c, 0x1c, 0x1c),
inset: Color32::from_rgb(0x23, 0x23, 0x23),
field: Color32::from_rgb(0x22, 0x22, 0x22),
@@ -189,6 +201,9 @@ impl Tokens {
..base
},
ThemeKind::Silver => Tokens {
ring_active: rgb(0x141414),
ring_inner: rgb(0xffffff),
ring_selected: rgb(0x4a4a4a),
chrome: rgb(0xe6e6e6),
canvas: rgb(0xcfcfcf),
grid_bg: rgb(0xd9d9d9),
@@ -220,6 +235,9 @@ impl Tokens {
..base
},
ThemeKind::Paper => Tokens {
ring_active: rgb(0x141414),
ring_inner: rgb(0xffffff),
ring_selected: rgb(0x4a4a4a),
chrome: rgb(0xfafafa),
canvas: rgb(0xececec),
grid_bg: rgb(0xf2f2f2),
@@ -458,8 +476,36 @@ pub fn apply_kind(ctx: &egui::Context, kind: ThemeKind) {
#[cfg(test)]
mod tests {
use super::*;
use crate::button::contrast;
use crate::i18n::{Locale, set_language};
/// The selection ring is a mark on the grid: 3:1 or more against what it is drawn over, in
/// every theme. The active photo's ring reads on the grid and on a selected or unselected
/// square-grid cell; the other selected photos' ring on the grid.
#[test]
fn selection_rings_stand_out_from_the_grid_in_every_theme() {
for kind in ThemeKind::ALL {
let t = Tokens::for_kind(kind);
for (what, surface) in [("grid", t.grid_bg), ("cell", t.cell), ("selected cell", t.cell_selected)] {
assert!(contrast(t.ring_active, surface) >= 3.0, "{kind:?}: active ring on the {what}: {:.2}", contrast(t.ring_active, surface));
}
// the selected ring also sits on a selected cell (square grid, filmstrip), label-tinted or not
let mut selected_cells = vec![("grid", t.grid_bg), ("selected cell", t.cell_selected)];
for label in lightcraft_catalog::ColorLabel::ALL {
selected_cells.push(("labelled selected cell", label_background(t.cell_selected, Some(label), true)));
}
for (what, surface) in selected_cells {
assert!(
contrast(t.ring_selected, surface) >= 3.0,
"{kind:?}: selected ring on the {what}: {:.2}",
contrast(t.ring_selected, surface)
);
}
// the thin inner line of the active ring is the ring's opposite: it parts the ring from the photo's edge
assert!(contrast(t.ring_inner, t.ring_active) >= 3.0, "{kind:?}: inner line against the active ring");
}
}
/// Every theme scrolls 100 points a wheel notch, and a zoom notch stays egui's stock ~1.22x
/// (`exp(40 / 200)`) instead of growing with the longer scroll.
#[test]
File diff suppressed because one or more lines are too long
+7
View File
@@ -168,6 +168,13 @@
the full count took about 14 s instead of about 23 s on a heavily loaded machine; the rest is the text rules of
the albums themselves. The counts are the same as before, including for albums that loop through each other.
### Selection rings you can see in the light themes
- The active photo's white outline vanished on Silver and Paper's light grid, and told the active photo from the other
selected ones by lightness alone. Now the active photo has a heavier ring (3 pt, with a thin line against the photo so
it also shows on a white one) and the other selected photos a lighter one (1.5 pt), in colours each theme picks to
stand out from its grid. The filmstrip rings its photos the same way, and so do Compare, Reference and Survey (where
the ring is the only sign of the active photo) and the People view's chosen person.
### Sharper resampling in HDR and panorama merges
- Merged photos (HDR and panorama) are resampled with a sharper filter (Lanczos-3 instead of bilinear). Moving a
photo by half a pixel used to lose a third of its finest detail; now it keeps most of it, without halos around