mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
LR-VIEW-PHOTOGRID: the active photo's selection ring shows on light themes and differs from the others by weight
The active photo had a 2 px pure white outline and the other selected photos grey 170, so on Silver and Paper's light grid the active ring vanished and the two differed by lightness alone. The loading placeholder was a fixed dark grey slab in every theme. - theme.rs: new tokens `ring_active`, `ring_inner`, `ring_selected` and `placeholder`, per theme; tests that every theme's rings stand 3:1 against the grid and the square grid's cells, and that the placeholder follows the theme's family - panels/grid.rs: `selection_ring` draws the active ring as 3 pt (1 pt inner line against the photo, 2 pt theme line) and the others as 1.5 pt; the grid uses it for loaded and unloaded thumbnails, and the placeholder token - panels/detail.rs: the filmstrip uses the same ring and rule - panels/compare.rs: the compare placeholder uses the token - tests_grid.rs, tests_filmstrip.rs: painted ring widths and colours in a light theme, active versus selected versus unselected - docs/parity-checklist.md, docs/whats-new.md: rows and entry Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
59a5d00c60
commit
ecba8045bf
@@ -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)
|
||||
{
|
||||
|
||||
@@ -651,6 +651,24 @@ 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: a 1 pt inner line
|
||||
/// in [`Tokens::ring_inner`] against the photo's edge (so the ring reads on a white photo and on a
|
||||
/// black one) and a 2 pt line in [`Tokens::ring_active`]. 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 => {
|
||||
p.rect_stroke(photo, 0.0, Stroke::new(1.0, t.ring_inner), StrokeKind::Outside);
|
||||
p.rect_stroke(photo.expand(1.0), 0.0, Stroke::new(2.0, t.ring_active), 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 +790,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 +798,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 +831,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 +839,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 +990,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,
|
||||
}
|
||||
|
||||
@@ -59,3 +59,44 @@ 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!(on_active.iter().map(|(w, _)| w).sum::<f32>() >= 3.0 && on_active.iter().any(|(_, col)| *col == t.ring_active), "{on_active:?}");
|
||||
assert_eq!(on_other.iter().map(|(_, col)| *col).collect::<Vec<_>>(), vec![t.ring_selected], "{on_other:?}");
|
||||
assert!(on_other.iter().map(|(w, _)| w).sum::<f32>() < 3.0, "{on_other:?}");
|
||||
assert!(on_unselected.is_empty(), "{on_unselected:?}");
|
||||
}
|
||||
|
||||
@@ -133,24 +133,52 @@ 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, outermost last: (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;
|
||||
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 {
|
||||
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);
|
||||
let width: f32 = strokes.iter().map(|(w, _)| w).sum();
|
||||
if id == active.0 {
|
||||
assert!(width >= 3.0, "photo {id}: active ring {strokes:?}");
|
||||
assert!(strokes.iter().any(|(_, c)| *c == t.ring_active), "photo {id}: {strokes:?}");
|
||||
assert!(strokes.iter().all(|(_, c)| *c != egui::Color32::WHITE), "photo {id}: a white ring vanishes on the light grid: {strokes:?}");
|
||||
} else {
|
||||
assert!((1.0..3.0).contains(&width), "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}");
|
||||
|
||||
@@ -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, 2 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(0xaa, 0xaa, 0xaa),
|
||||
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(0x5a5a5a),
|
||||
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(0x5a5a5a),
|
||||
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));
|
||||
}
|
||||
assert!(contrast(t.ring_selected, t.grid_bg) >= 3.0, "{kind:?}: selected ring on the grid: {:.2}", contrast(t.ring_selected, t.grid_bg));
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
/// A photo still being drawn is a slab of its theme, not a dark one on a light grid.
|
||||
#[test]
|
||||
fn the_loading_placeholder_belongs_to_the_theme() {
|
||||
for kind in ThemeKind::ALL {
|
||||
let t = Tokens::for_kind(kind);
|
||||
assert!(t.skeleton != t.grid_bg, "{kind:?}: the slab must show on the grid");
|
||||
let dark_slab = contrast(t.skeleton, Color32::BLACK) < contrast(t.skeleton, Color32::WHITE);
|
||||
assert_eq!(dark_slab, kind.is_dark(), "{kind:?}: a {} slab", if dark_slab { "dark" } else { "light" });
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
@@ -157,6 +157,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. A photo still loading is no longer a dark slab
|
||||
on a light grid.
|
||||
|
||||
### 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
|
||||
|
||||
Reference in New Issue
Block a user