diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs index 4a0de28cf..8bd560381 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 5064b8d40..656ddda91 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 1529bcb41..a95530b2a 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 3776821de..888021198 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 e55008efd..a7b129e8c 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 90cfc248f..2d5ee53be 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 c6c259200..a331502f8 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