From ecba8045bf21069556b638c146870f03bb5aa3d2 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 17 Oct 2026 08:54:30 -0400 Subject: [PATCH 1/7] 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 --- crates/ui-egui/src/panels/detail/filmstrip.rs | 7 +-- crates/ui-egui/src/panels/grid.rs | 40 +++++++++------- crates/ui-egui/src/tests/filmstrip.rs | 41 +++++++++++++++++ crates/ui-egui/src/tests/grid.rs | 40 +++++++++++++--- crates/ui-egui/src/theme.rs | 46 +++++++++++++++++++ docs/parity-checklist.md | 7 +-- docs/whats-new.md | 7 +++ 7 files changed, 156 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs index 4a0de28cf2..8bd560381a 100644 --- a/crates/ui-egui/src/panels/detail/filmstrip.rs +++ b/crates/ui-egui/src/panels/detail/filmstrip.rs @@ -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) { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 5064b8d401..656ddda915 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -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, } diff --git a/crates/ui-egui/src/tests/filmstrip.rs b/crates/ui-egui/src/tests/filmstrip.rs index 1529bcb41d..a95530b2a8 100644 --- a/crates/ui-egui/src/tests/filmstrip.rs +++ b/crates/ui-egui/src/tests/filmstrip.rs @@ -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::() >= 3.0 && on_active.iter().any(|(_, col)| *col == t.ring_active), "{on_active:?}"); + assert_eq!(on_other.iter().map(|(_, col)| *col).collect::>(), vec![t.ring_selected], "{on_other:?}"); + assert!(on_other.iter().map(|(w, _)| w).sum::() < 3.0, "{on_other:?}"); + assert!(on_unselected.is_empty(), "{on_unselected:?}"); +} diff --git a/crates/ui-egui/src/tests/grid.rs b/crates/ui-egui/src/tests/grid.rs index 3776821de9..888021198a 100644 --- a/crates/ui-egui/src/tests/grid.rs +++ b/crates/ui-egui/src/tests/grid.rs @@ -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::().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![t.ring_selected], "photo {id}"); + } checked += 1; } assert!(checked >= 3, "checked {checked}"); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index e55008efdb..a7b129e8cb 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f6..2d5ee53be6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -123,12 +123,12 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own; a mouse-wheel notch scrolls a third of the grid's height, in both grids, as in Lightroom Classic (`theme::WHEEL_STRIP_SHARE`; trackpads scroll the points they report); queued embedded thumbnail stand-ins follow viewport priority changes without another extraction (`crates/ui-egui/src/render.rs`, issue #348; full-app visual/timing verification pending) | -| LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid | +| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own; a mouse-wheel notch scrolls a third of the grid's height, in both grids, as in Lightroom Classic (`theme::WHEEL_STRIP_SHARE`; trackpads scroll the points they report); queued embedded thumbnail stand-ins follow viewport priority changes without another extraction (`crates/ui-egui/src/render.rs`, issue #348; full-app visual/timing verification pending); selected photos are ringed in the theme's colours (`Tokens::ring_active` / `ring_selected`, 3:1 or more against the grid in every theme): the active photo gets a 3 pt two-tone ring (a thin inner line against the photo, so it shows on a white photo too), the other selected photos a 1.5 pt ring; a photo still loading is a theme-coloured slab (`Tokens::placeholder`), not a dark one on a light grid | +| LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers and selection rings as the photo grid | | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | -| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one in the heavier two-tone ring and the others in the lighter one, in the theme's colours, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with an output-device profile proof or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. Photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; only an output-device-profile proof renders on the CPU, where a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with an output-device-profile soft proof (the CPU path) | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests/focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | @@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-17 | minor | LR-VIEW-PHOTOGRID, LR-VIEW-SQUAREGRID, LR-VIEW-FILMSTRIP: selection rings are theme tokens (3:1 or more against the grid; the active photo's 3 pt two-tone ring against the others' 1.5 pt), so the active photo is no longer a white ring lost on the light themes' grid; loading placeholder follows the theme; status unchanged (✅) | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c2592003..a331502f8d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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 From b2e6657b95ad4df8c2e1473f636a3cb9efd3a131 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 17 Oct 2026 09:28:31 -0400 Subject: [PATCH 2/7] LR-VIEW-PHOTOGRID: the active ring shows in Compare, Survey and the face tiles too, with no seam at fractional scales Review of the selection rings found the grid test never checked the active (top-left) photo, a selected ring that fell under 3:1 on label-tinted cells, and other places still framing the selection in fixed white. - tests_grid.rs: the active photo is now checked (it was skipped by a leftover x filter): a 3 pt `ring_active` stroke over a 1 pt `ring_inner` one, and not white in a light theme - theme.rs: `ring_selected` is also tested at 3:1 against the selected cell and its label-tinted forms; Silver and Paper's goes darker (0x4a) and Charcoal and Midnight's lighter (0xc0) to pass - panels/grid.rs: the active ring is one 3 pt stroke with the 1 pt inner line on top, both on the same rect, so two separately snapped strokes cannot leave a half-pixel seam at 1.25x, 1.5x or 1.75x - panels/compare.rs: Compare, Reference and Survey frame the active tile with `selection_ring` instead of a white 2 pt stroke; the placeholder uses the tile's own tokens - panels/settings.rs: the theme preview's mini filmstrip rings its selected cell with the theme's `ring_active` - panels/people.rs: a selected face tile uses the same ring - tests_compare.rs, tests_filmstrip.rs: a Survey test in a light theme and the filmstrip test adjusted to the new widths Co-Authored-By: Claude --- .../src/dialogs/settings/appearance.rs | 2 +- crates/ui-egui/src/panels/compare.rs | 6 +-- crates/ui-egui/src/panels/grid.rs | 9 ++-- crates/ui-egui/src/panels/people.rs | 2 +- crates/ui-egui/src/tests/compare.rs | 41 +++++++++++++++++++ crates/ui-egui/src/tests/filmstrip.rs | 5 +-- crates/ui-egui/src/tests/grid.rs | 13 +++--- crates/ui-egui/src/theme.rs | 19 +++++++-- 8 files changed, 75 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/dialogs/settings/appearance.rs b/crates/ui-egui/src/dialogs/settings/appearance.rs index f714a97e62..5f8f428d45 100644 --- a/crates/ui-egui/src/dialogs/settings/appearance.rs +++ b/crates/ui-egui/src/dialogs/settings/appearance.rs @@ -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); } } } diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 9b9a236734..88463a285f 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -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.placeholder); } 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 diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 656ddda915..ed7614590e 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -651,16 +651,17 @@ 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 +/// 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); - 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); diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index 73d19cbaa9..f81b903723 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -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); } diff --git a/crates/ui-egui/src/tests/compare.rs b/crates/ui-egui/src/tests/compare.rs index c816b3c676..b0ccb0b149 100644 --- a/crates/ui-egui/src/tests/compare.rs +++ b/crates/ui-egui/src/tests/compare.rs @@ -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 = 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 = 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"); +} diff --git a/crates/ui-egui/src/tests/filmstrip.rs b/crates/ui-egui/src/tests/filmstrip.rs index a95530b2a8..de05996d1e 100644 --- a/crates/ui-egui/src/tests/filmstrip.rs +++ b/crates/ui-egui/src/tests/filmstrip.rs @@ -95,8 +95,7 @@ fn the_filmstrip_marks_the_active_photo_heavier_than_the_other_selected_one() { .collect() }; let (on_active, on_other, on_unselected) = (strokes(active), strokes(other), strokes(c)); - assert!(on_active.iter().map(|(w, _)| w).sum::() >= 3.0 && on_active.iter().any(|(_, col)| *col == t.ring_active), "{on_active:?}"); - assert_eq!(on_other.iter().map(|(_, col)| *col).collect::>(), vec![t.ring_selected], "{on_other:?}"); - assert!(on_other.iter().map(|(w, _)| w).sum::() < 3.0, "{on_other:?}"); + 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:?}"); } diff --git a/crates/ui-egui/src/tests/grid.rs b/crates/ui-egui/src/tests/grid.rs index 888021198a..4e32756e44 100644 --- a/crates/ui-egui/src/tests/grid.rs +++ b/crates/ui-egui/src/tests/grid.rs @@ -164,24 +164,25 @@ fn unloaded_thumbnails_show_their_selection() { .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::().unwrap(); 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:?}"); + 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!((1.0..3.0).contains(&width), "photo {id}: selected ring {strokes:?}"); + assert_eq!(strokes.iter().map(|(w, _)| *w).sum::(), 1.5, "photo {id}: selected ring {strokes:?}"); assert_eq!(strokes.iter().map(|(_, c)| *c).collect::>(), 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 diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a7b129e8cb..d57258a4aa 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -140,7 +140,7 @@ impl Default for Tokens { 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), + 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), @@ -203,7 +203,7 @@ impl Tokens { ThemeKind::Silver => Tokens { ring_active: rgb(0x141414), ring_inner: rgb(0xffffff), - ring_selected: rgb(0x5a5a5a), + ring_selected: rgb(0x4a4a4a), chrome: rgb(0xe6e6e6), canvas: rgb(0xcfcfcf), grid_bg: rgb(0xd9d9d9), @@ -237,7 +237,7 @@ impl Tokens { ThemeKind::Paper => Tokens { ring_active: rgb(0x141414), ring_inner: rgb(0xffffff), - ring_selected: rgb(0x5a5a5a), + ring_selected: rgb(0x4a4a4a), chrome: rgb(0xfafafa), canvas: rgb(0xececec), grid_bg: rgb(0xf2f2f2), @@ -489,7 +489,18 @@ mod tests { 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 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"); } From 4f31cc5c3c0912766ea9b1a6cdd6e63b4605dbb8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 17 Oct 2026 09:37:35 -0400 Subject: [PATCH 3/7] LR-VIEW-PHOTOGRID: the docs name Compare, Survey and People's rings too - docs/whats-new.md, docs/parity-checklist.md (LR-VIEW-SURVEY row and the revision-history line): the active-photo ring in Compare, Reference, Survey and the People view. - tests_grid.rs: the ring comment matches the paint order. Co-Authored-By: Claude --- crates/ui-egui/src/tests/grid.rs | 2 +- docs/parity-checklist.md | 6 +++--- docs/whats-new.md | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/tests/grid.rs b/crates/ui-egui/src/tests/grid.rs index 4e32756e44..3a518726b5 100644 --- a/crates/ui-egui/src/tests/grid.rs +++ b/crates/ui-egui/src/tests/grid.rs @@ -149,7 +149,7 @@ fn unloaded_thumbnails_show_their_selection() { 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) + // 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() diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2d5ee53be6..ea7aa3f195 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -133,8 +133,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests/focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests/before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | -| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | -| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys, the bottom bar and the Info panel's stars and colour labels act on the active photo, each tile's caption shows its colour label, hover × removes | +| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); the active pane is framed in the active photo's two-tone selection ring (theme tokens, 3 pt), as in the grids; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | +| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys, the bottom bar and the Info panel's stars and colour labels act on the active photo (ringed as in the grid), each tile's caption shows its colour label, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay`, `cmd:view.viewOptions`, `crates/ui-egui/src/panels/detail.rs` (`info_overlay`) | off / Info 1 (by default file name, capture date, size) / Info 2 (file name, exposure, camera + lens), each three lines chosen in View Options; ⌘I cycles (I in full screen; elsewhere I stays the Info panel); the star rating and flag show under the lines when the photo has them, and in the full-screen preview on macOS the overlay starts below the window controls (issue #2460) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | @@ -959,4 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | -| 2026-10-17 | minor | LR-VIEW-PHOTOGRID, LR-VIEW-SQUAREGRID, LR-VIEW-FILMSTRIP: selection rings are theme tokens (3:1 or more against the grid; the active photo's 3 pt two-tone ring against the others' 1.5 pt), so the active photo is no longer a white ring lost on the light themes' grid; loading placeholder follows the theme; status unchanged (✅) | +| 2026-10-17 | minor | LR-VIEW-PHOTOGRID, LR-VIEW-SQUAREGRID, LR-VIEW-FILMSTRIP, LR-VIEW-SURVEY, LR-VIEW-COMPARE, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: selection rings are theme tokens (3:1 or more against the grid; the active photo's 3 pt two-tone ring against the others' 1.5 pt), so the active photo is no longer a white ring lost on the light themes' grid; Compare, Reference and Survey frame their active photo and the People view its chosen person the same way; statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index a331502f8d..8ad9ffa9b6 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -161,8 +161,9 @@ - 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. + 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. 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 From 58fac428cef420a27c2da6e2ef9abbab07939229 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sun, 18 Oct 2026 13:32:43 -0400 Subject: [PATCH 4/7] LR-VIEW-PHOTOGRID: the loading slab uses the skeleton token #2077 adds whose picture is still being made; this PR had added `placeholder` for the same slab. Both now add the same token, with the same name, doc and colours, so the two merge into one. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/compare.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 88463a285f..dad49923fa 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -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, t.placeholder); + 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)); From 58b091f3d6726fda457671c57f01e3c40aba85ed Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sun, 18 Oct 2026 14:00:05 -0400 Subject: [PATCH 5/7] LR-VIEW-PHOTOGRID: the checklist names the skeleton token Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/theme.rs | 13 +------------ docs/parity-checklist.md | 2 +- docs/whats-new.md | 3 +-- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index d57258a4aa..0c562f451f 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -47,7 +47,7 @@ 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 + /// 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 @@ -506,17 +506,6 @@ mod tests { } } - /// 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] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ea7aa3f195..0e9f78aa25 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -123,7 +123,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own; a mouse-wheel notch scrolls a third of the grid's height, in both grids, as in Lightroom Classic (`theme::WHEEL_STRIP_SHARE`; trackpads scroll the points they report); queued embedded thumbnail stand-ins follow viewport priority changes without another extraction (`crates/ui-egui/src/render.rs`, issue #348; full-app visual/timing verification pending); selected photos are ringed in the theme's colours (`Tokens::ring_active` / `ring_selected`, 3:1 or more against the grid in every theme): the active photo gets a 3 pt two-tone ring (a thin inner line against the photo, so it shows on a white photo too), the other selected photos a 1.5 pt ring; a photo still loading is a theme-coloured slab (`Tokens::placeholder`), not a dark one on a light grid | +| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own; a mouse-wheel notch scrolls a third of the grid's height, in both grids, as in Lightroom Classic (`theme::WHEEL_STRIP_SHARE`; trackpads scroll the points they report); queued embedded thumbnail stand-ins follow viewport priority changes without another extraction (`crates/ui-egui/src/render.rs`, issue #348; full-app visual/timing verification pending); selected photos are ringed in the theme's colours (`Tokens::ring_active` / `ring_selected`, 3:1 or more against the grid in every theme): the active photo gets a 3 pt two-tone ring (a thin inner line against the photo, so it shows on a white photo too), the other selected photos a 1.5 pt ring | | LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers and selection rings as the photo grid | | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | diff --git a/docs/whats-new.md b/docs/whats-new.md index 8ad9ffa9b6..86bc6d4a00 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -162,8 +162,7 @@ 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. A photo still loading is no - longer a dark slab on a light grid. + 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 From c9c10481fd236757c2adf3a7ca6dd9f332c7e4b0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 24 Oct 2026 22:09:50 -0400 Subject: [PATCH 6/7] LR-VIEW-FILMSTRIP: the parity row says the selection ring needs a loaded thumbnail Co-Authored-By: Claude Opus 5.5 --- docs/parity-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0e9f78aa25..b62e13f9f3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -128,7 +128,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | -| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one in the heavier two-tone ring and the others in the lighter one, in the theme's colours, as in the grid | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo has the selected background; selected photos with a loaded thumbnail also show a ring, the active one in the heavier two-tone ring and the others in the lighter one, in the theme's colours, as in the grid (a selected cell with no thumbnail, still loading or failed, has the background and no ring) | | LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with an output-device profile proof or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. Photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; only an output-device-profile proof renders on the CPU, where a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with an output-device-profile soft proof (the CPU path) | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests/focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | From a5e1cee421f5c2572d2a5675972702aeeb791650 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sun, 25 Oct 2026 21:00:43 -0400 Subject: [PATCH 7/7] LR-VIEW-PHOTOGRID: the checklist's Last updated line names the selection-ring rows Co-Authored-By: Claude Opus 5.5 --- docs/parity-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b62e13f9f3..cd76265781 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good