From 4551790eabb2c6698df5f07a901073d808cd30d9 Mon Sep 17 00:00:00 2001 From: BuonBreda <86723373+BuonBreda@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:32:06 +0200 Subject: [PATCH] =?UTF-8?q?LRC-DEV-LOUPEOVERLAY:=20a=20layout=20image=20ov?= =?UTF-8?q?er=20the=20loupe=20(View=20=E2=96=B8=20Loupe=20Overlay)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightroom Classic's Loupe Overlay ▸ Layout Image: a PNG with transparency (a cover's masthead and cover lines, a banner's safe areas) drawn over the photo in the loupe, fitted and centred, following zoom and pan. Opacity and matte (the photo darkened outside the graphic's box) in steps, ⌥⌘O shows / hides it, the choice is saved with the UI state. The file is read off the UI thread; an unreadable one is reported on the canvas. engine::export::read_graphic is the watermark logo's reader, now shared. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Mj2w3LwYjFhMHALZuFp2rU --- crates/engine/src/export.rs | 11 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menubar.rs | 34 ++- crates/ui-egui/src/menus.rs | 52 +++++ crates/ui-egui/src/panels/detail.rs | 2 + crates/ui-egui/src/panels/layout_overlay.rs | 223 ++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/state.rs | 36 ++++ crates/ui-egui/src/tests_layout_image.rs | 181 ++++++++++++++++ docs/parity-checklist.md | 7 +- docs/whats-new.md | 6 + 11 files changed, 549 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/panels/layout_overlay.rs create mode 100644 crates/ui-egui/src/tests_layout_image.rs diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 403cb71f6..6ee4cfb46 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -560,12 +560,19 @@ fn watermark_logo(path: &str) -> Option> { { return Some(img.clone()); } - let bytes = std::fs::read(path).ok()?; - let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8()); + let img = Arc::new(read_graphic(path).ok()?); map.insert(path.to_string(), (modified, img.clone())); Some(img) } +/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the +/// watermark logo, the loupe's layout image. The error says why it couldn't be read or decoded. +pub fn read_graphic(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?; + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?; + Ok(decoded.to_srgb8()) +} + /// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to /// `image_width` of the photo, placed like the text, blended with its alpha × opacity. fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..ae0550e6a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -89,6 +89,8 @@ mod tests_keywording; #[cfg(test)] mod tests_labels; #[cfg(test)] +mod tests_layout_image; +#[cfg(test)] mod tests_library_problem; #[cfg(test)] mod tests_lights_out; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index fe6905cad..7a00ec8a3 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -136,6 +136,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.histogram", "view.navigator", "view.infoOverlay", + "@Loupe Overlay", "@Appearance", "---", "view.fullScreenPreview", @@ -329,6 +330,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.autoAdvance" => Some(u.auto_advance), "view.filterBar" => Some(u.filter_bar), "view.navigator" => Some(u.navigator), + "view.layoutImage" => Some(u.layout_image.show && !u.layout_image.path.trim().is_empty()), "view.fullScreenPreview" => Some(u.fullscreen), "view.lightsOut" => Some(u.lights_out != crate::state::LightsOut::On), "view.enterFullScreen" => Some(app.window_is_fullscreen), @@ -367,7 +369,7 @@ fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String { } /// The parameterized submenus. -fn expanded(app: &LightcraftApp, name: &str) -> Option> { +pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option> { let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned()); let has = active.is_some(); Some(match name { @@ -507,6 +509,36 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { } v } + "Loupe Overlay" => { + // Lightroom Classic's View ▸ Loupe Overlay ▸ Layout Image: choose the graphic, show it, + // and how opaque it is and how dark the photo is outside it + let li = &app.ui.layout_image; + let chosen = !li.path.trim().is_empty(); + let mut v = vec![ + item("view.chooseLayoutImage", Value::Null, "Choose Layout Image…", None, true, None), + item( + "view.layoutImage", + Value::Null, + "Show Layout Image", + crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.layoutImage"), + chosen, + Some(li.show && chosen), + ), + MenuNode::Separator, + ]; + let near = |a: f32, b: f32| (a - b).abs() < 0.005; + for step in crate::state::LayoutImage::OPACITY_STEPS { + let label = format!("{} {:.0}%", crate::i18n::tr("Opacity"), step * 100.0); + v.push(item("view.layoutImageOpacity", json!({"opacity": step}), label, None, chosen, Some(near(li.opacity, step)))); + } + v.push(MenuNode::Separator); + for step in crate::state::LayoutImage::MATTE_STEPS { + let label = + if step == 0.0 { crate::i18n::tr("No Matte").to_string() } else { format!("{} {:.0}%", crate::i18n::tr("Matte"), step * 100.0) }; + v.push(item("view.layoutImageMatte", json!({"matte": step}), label, None, chosen, Some(near(li.matte, step)))); + } + v + } "Grid Info" => ["filename", "exposure", "date"] .into_iter() .zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"]) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ad31c57bf..52f8dbbbe 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -112,6 +112,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), + ("view.chooseLayoutImage", "Choose Layout Image…", None, "View"), + ("view.layoutImage", "Show Layout Image", Some("Cmd+Alt+O"), "View"), + ("view.layoutImageOpacity", "Layout Image Opacity", None, ""), + ("view.layoutImageMatte", "Layout Image Matte", None, ""), ("view.navigator", "Navigator", None, "View"), ("panel.edit", "Edit", Some("E"), "Window"), ("panel.profiles", "Profile Browser", None, "Window"), @@ -651,6 +655,54 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let path = match p.get("path").and_then(Value::as_str) { + Some(path) => path.trim().to_string(), + None => { + let req = PickRequest::file(crate::i18n::tr("Choose Layout Image…"), "PNG", &["png"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(paths) => match paths.first() { + Some(path) => path.trim().to_string(), + None => return Some(Ok(Value::Null)), + }, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform; supply path".into())), + } + } + }; + if path.is_empty() { + return Some(Err("view.chooseLayoutImage: path is empty".into())); + } + let li = &mut app.ui.layout_image; + li.path = path; + li.show = true; + li.revision = li.revision.wrapping_add(1); + app.toast(&ctx, "Layout Image On"); + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.layoutImage" => { + let show = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.layout_image.show); + // nothing chosen yet: showing it chooses one first + if show && app.ui.layout_image.path.trim().is_empty() { + return run_ui_command(app, "view.chooseLayoutImage", &json!({})); + } + app.ui.layout_image.show = show; + app.toast(&ctx, if show { "Layout Image On" } else { "Layout Image Off" }); + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.layoutImageOpacity" | "view.layoutImageMatte" => { + let key = if id == "view.layoutImageOpacity" { "opacity" } else { "matte" }; + let Some(v) = p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()) else { + return Some(Err(format!("{id}: {key} (0..1) is required"))); + }; + let v = v.clamp(0.0, 1.0) as f32; + if key == "opacity" { + app.ui.layout_image.opacity = v; + } else { + app.ui.layout_image.matte = v; + } + Ok(json!({"layoutImage": app.ui.layout_image})) + } "view.navigator" => { app.ui.navigator = !app.ui.navigator; Ok(json!({"navigator": app.ui.navigator})) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..96697c16e 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -689,6 +689,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.ui.show_clipping && !show_before && !fullscreen { clipping_overlay(app, &p, img_rect); } + // View ▸ Loupe Overlay ▸ Layout Image, over the photo (the After view in a before/after) + super::layout_overlay::draw(app, &p, img_rect); register(ui.ctx(), "canvas:image", img_rect); let map = CanvasMap::new(&frame, img_rect); info_overlay(app, &p, canvas, &photo); diff --git a/crates/ui-egui/src/panels/layout_overlay.rs b/crates/ui-egui/src/panels/layout_overlay.rs new file mode 100644 index 000000000..9b4800d0f --- /dev/null +++ b/crates/ui-egui/src/panels/layout_overlay.rs @@ -0,0 +1,223 @@ +//! View ▸ Loupe Overlay ▸ Layout Image, as in Lightroom Classic: a graphic with transparency (a +//! magazine cover's masthead and cover lines, a web banner's safe areas, a print layout's +//! margins) drawn over the photo in the loupe, so a shot can be judged against the page it is made +//! for. The graphic is fitted inside the photo and centred, and follows zoom and pan with it. +//! `opacity` fades it; `matte` darkens the photo outside its box, where the page would crop it. +//! +//! The file is read and decoded off the UI thread, once per path and Choose Layout Image… (an +//! offline drive never holds up a frame); until it arrives nothing is drawn, and a file that can't +//! be read says so at the canvas' bottom left. Graphics larger than [`MAX_SIDE`] are scaled down +//! once, premultiplied so transparent edges stay clean. + +use std::sync::{Arc, Mutex, PoisonError}; + +use egui::{Color32, Rect, pos2, vec2}; +use lightcraft_raster::Rgba8; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::register; + +/// The longest side a layout image keeps (a screen never shows more of it). +pub const MAX_SIDE: usize = 4096; + +/// The decoded graphic once the worker is done, or why it couldn't be read. +type Loaded = Arc>>>; + +/// What the loupe keeps between frames (in egui's temporary memory). +#[derive(Clone)] +struct Cache { + /// The (path, revision) the file was read for. + file: (String, u32), + loaded: Loaded, + /// The texture made from the graphic, and the display profile it was made for. + tex: Option<(Option, egui::TextureHandle)>, +} + +/// The rectangle a `size` (pixels) graphic takes fitted inside `photo` and centred on it. +pub fn fit(photo: Rect, size: [usize; 2]) -> Rect { + let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); + let scale = (photo.width() / w).min(photo.height() / h).max(0.0); + Rect::from_center_size(photo.center(), vec2(w * scale, h * scale)) +} + +/// The parts of `photo` outside `inner` (the matte): up to four strips. +pub fn outside(photo: Rect, inner: Rect) -> Vec { + let inner = inner.intersect(photo); + let strips = [ + Rect::from_min_max(photo.min, pos2(inner.left(), photo.bottom())), + Rect::from_min_max(pos2(inner.right(), photo.top()), photo.max), + Rect::from_min_max(pos2(inner.left(), photo.top()), pos2(inner.right(), inner.top())), + Rect::from_min_max(pos2(inner.left(), inner.bottom()), pos2(inner.right(), photo.bottom())), + ]; + strips.into_iter().filter(|r| r.width() > 0.0 && r.height() > 0.0).collect() +} + +/// `img` with its longest side at most `max` pixels (scaled premultiplied, so transparent edges +/// keep their colour); an empty graphic is an error. +pub fn fit_within(img: Rgba8, max: usize) -> Result { + let (w, h) = (img.width, img.height); + if w == 0 || h == 0 { + return Err("the layout image is empty".into()); + } + let max = max.max(1); + if w.max(h) <= max { + return Ok(img); + } + let s = max as f32 / w.max(h) as f32; + let (nw, nh) = (((w as f32 * s).round() as usize).clamp(1, max), ((h as f32 * s).round() as usize).clamp(1, max)); + let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(w, h, |x, y| { + let p = img.get(x, y); + let a = p[3] as f32 / 255.0; + [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] + }); + let scaled = lightcraft_raster::resample::resize(&pre, nw, nh, lightcraft_raster::resample::Filter::Mitchell); + Ok(Rgba8::from_fn(nw, nh, |x, y| { + let [r, g, b, a] = scaled.get(x, y); + let a = a.clamp(0.0, 1.0); + let c = |v: f32| if a <= 1e-4 { 0 } else { (v / a).round().clamp(0.0, 255.0) as u8 }; + [c(r), c(g), c(b), (a * 255.0).round() as u8] + })) +} + +/// Reads and decodes `path` on a worker thread (in place on the web, which has no threads and no +/// files to read: the error says so). +fn start(ctx: &egui::Context, path: &str) -> Loaded { + let loaded: Loaded = Default::default(); + let max = match ctx.input(|i| i.max_texture_side) { + 0 => MAX_SIDE, + m => m.min(MAX_SIDE), + }; + let (out, repaint, path) = (loaded.clone(), ctx.clone(), path.to_string()); + let work = move || { + let r = lightcraft_engine::export::read_graphic(&path).and_then(|img| fit_within(img, max)); + *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(r); + repaint.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + if let Err(e) = std::thread::Builder::new().name("lc-layout-image".into()).spawn(work) { + *loaded.lock().unwrap_or_else(PoisonError::into_inner) = Some(Err(format!("can't start reading the layout image: {e}"))); + } + #[cfg(target_arch = "wasm32")] + work(); + loaded +} + +/// Draws the layout image (when shown) over `photo`, the photo's rectangle on screen; `p` clips it +/// to the canvas. +pub fn draw(app: &LightcraftApp, p: &egui::Painter, photo: Rect) { + let li = &app.ui.layout_image; + let path = li.path.trim(); + if !li.show || path.is_empty() || photo.width() < 1.0 || photo.height() < 1.0 { + return; + } + let ctx = p.ctx().clone(); + let id = egui::Id::new("loupe-layout-image"); + let file = (path.to_string(), li.revision); + let mut cache = match ctx.data(|d| d.get_temp::(id)) { + Some(c) if c.file == file => c, + _ => Cache { loaded: start(&ctx, path), file, tex: None }, + }; + let display_id = app.renderer.display_id(); + if cache.tex.as_ref().is_none_or(|(d, _)| *d != display_id) { + let made = match cache.loaded.lock().unwrap_or_else(PoisonError::into_inner).as_ref() { + // still reading: the worker repaints when it is done + None => None, + Some(Err(e)) => Some(Err(e.clone())), + Some(Ok(img)) => { + let mut img = img.clone(); + // shown through the monitor profile like every other preview + if let Some(d) = app.renderer.display() + && let Err(e) = d.profile.from_srgb(&mut img) + { + log::warn!("layout image, display profile: {e}"); + } + let color = egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes()); + Some(Ok(ctx.load_texture("loupe-layout-image", color, egui::TextureOptions::LINEAR))) + } + }; + match made { + None => {} + Some(Ok(tex)) => cache.tex = Some((display_id, tex)), + Some(Err(e)) => note(p, &e), + } + } + if let Some((_, tex)) = &cache.tex { + let r = fit(photo, tex.size()); + let matte = li.matte.clamp(0.0, 1.0); + if matte > 0.0 { + let veil = Color32::from_black_alpha((matte * 255.0).round() as u8); + for strip in outside(photo, r) { + p.rect_filled(strip, 0.0, veil); + } + } + let alpha = (li.opacity.clamp(0.0, 1.0) * 255.0).round() as u8; + p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(alpha)); + register(&ctx, "canvas:layoutImage", r.intersect(p.clip_rect())); + } + ctx.data_mut(|d| d.insert_temp(id, cache)); +} + +/// Why the layout image isn't shown, at the canvas' bottom left. +fn note(p: &egui::Painter, why: &str) { + let t = Tokens::get(p.ctx()); + let canvas = p.clip_rect(); + let lines = [crate::i18n::tr("The layout image can't be shown"), why]; + let mut y = canvas.bottom() - 14.0; + for (i, l) in lines.iter().enumerate().rev() { + let font = if i == 0 { t.semibold(13.0) } else { t.font(12.0) }; + let g = p.layout_no_wrap(l.to_string(), font, Color32::WHITE); + y -= g.size().y; + let at = pos2(canvas.left() + 14.0, y); + p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); + p.galley(at, g, Color32::WHITE); + y -= 3.0; + } + register(p.ctx(), "canvas:layoutImageError", Rect::from_min_max(pos2(canvas.left(), y), pos2(canvas.left() + 360.0, canvas.bottom()))); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_graphic_fits_inside_the_photo_centred() { + let photo = Rect::from_min_size(pos2(100.0, 50.0), vec2(600.0, 400.0)); + // a portrait cover over a landscape photo: as tall as the photo, centred across + let r = fit(photo, [800, 1000]); + assert!((r.height() - 400.0).abs() < 1e-3 && (r.width() - 320.0).abs() < 1e-3, "{r:?}"); + assert!((r.center().x - photo.center().x).abs() < 1e-3 && (r.center().y - photo.center().y).abs() < 1e-3); + // the same shape fills it + let same = fit(photo, [1200, 800]); + assert!((same.width() - 600.0).abs() < 1e-3 && (same.height() - 400.0).abs() < 1e-3); + // a zero-size graphic never divides by zero + assert!(fit(photo, [0, 0]).width().is_finite()); + } + + #[test] + fn the_matte_covers_the_photo_outside_the_graphic_only() { + let photo = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)); + let r = fit(photo, [800, 1000]); + let strips = outside(photo, r); + assert_eq!(strips.len(), 2, "{strips:?}"); + let area: f32 = strips.iter().map(|s| s.area()).sum(); + assert!((area - (photo.area() - r.area())).abs() < 1e-2); + assert!(strips.iter().all(|s| s.intersect(r).area() < 1e-3)); + // a graphic of the photo's own shape leaves nothing to darken + assert!(outside(photo, fit(photo, [3, 2])).is_empty()); + } + + #[test] + fn large_graphics_are_scaled_down_keeping_shape_and_transparency() { + // a transparent graphic with an opaque red bar across the middle + let img = Rgba8::from_fn(5000, 2500, |_, y| if (1000..1500).contains(&y) { [255, 0, 0, 255] } else { [0, 0, 0, 0] }); + let out = fit_within(img, 1000).unwrap(); + assert_eq!((out.width, out.height), (1000, 500)); + assert_eq!(out.get(500, 250), [255, 0, 0, 255]); + assert_eq!(out.get(500, 20)[3], 0); + // small graphics are kept as they are, empty ones are refused + let small = Rgba8::from_fn(10, 4, |x, _| [x as u8, 0, 0, 255]); + assert_eq!(fit_within(small.clone(), 1000).unwrap().data, small.data); + assert!(fit_within(Rgba8::new(0, 0), 1000).is_err()); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed2..d3d44e911 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -15,6 +15,7 @@ pub mod grid; pub mod keymap; pub mod keyword_list; pub mod keywording; +pub mod layout_overlay; pub mod left; pub mod library_problem; pub mod masking; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..5acd9d9c9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -135,6 +135,39 @@ impl InfoOverlay { } } +/// View ▸ Loupe Overlay ▸ Layout Image (Lightroom Classic's loupe overlay): a graphic with +/// transparency, such as a magazine cover's masthead and cover lines, drawn over the photo in the +/// loupe (`panels::layout_overlay`). Saved with the UI state, so the layout stays chosen across +/// launches. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LayoutImage { + /// The graphic's file; empty until one is chosen. + pub path: String, + /// Drawn over the loupe (⌥⌘O). + pub show: bool, + /// The graphic's opacity, 0..=1. + pub opacity: f32, + /// How dark the photo is outside the graphic's box, 0..=1 (0: not darkened). + pub matte: f32, + /// Bumped by each Choose Layout Image…, so choosing the same file again reads it again (after + /// it was edited elsewhere). + #[serde(skip)] + pub revision: u32, +} + +impl Default for LayoutImage { + fn default() -> Self { + LayoutImage { path: String::new(), show: false, opacity: 1.0, matte: 0.0, revision: 0 } + } +} + +impl LayoutImage { + /// The opacity and matte steps View ▸ Loupe Overlay offers. + pub const OPACITY_STEPS: [f32; 4] = [0.25, 0.5, 0.75, 1.0]; + pub const MATTE_STEPS: [f32; 4] = [0.0, 0.25, 0.5, 0.75]; +} + /// Lights Out (View ▸ Lights Out, Shift+L cycles On → Dim → Off → On; Esc turns the lights back /// on): everything but the photo is veiled in black, the panels stay where they are. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -725,6 +758,8 @@ pub struct UiState { pub slideshow: Option<(f64, f64, bool)>, /// Info overlay on the loupe. pub info_overlay: InfoOverlay, + /// The loupe's layout image overlay (View ▸ Loupe Overlay). + pub layout_image: LayoutImage, /// Navigator mini map in the loupe while zoomed in. pub navigator: bool, /// App preferences (Settings dialog). @@ -1145,6 +1180,7 @@ impl Default for UiState { keyword_list_selected: None, dragging_keyword: None, info_overlay: InfoOverlay::Off, + layout_image: LayoutImage::default(), navigator: true, settings: AppSettings::default(), window_fullscreen: None, diff --git a/crates/ui-egui/src/tests_layout_image.rs b/crates/ui-egui/src/tests_layout_image.rs new file mode 100644 index 000000000..f07b391ca --- /dev/null +++ b/crates/ui-egui/src/tests_layout_image.rs @@ -0,0 +1,181 @@ +//! Headless tests of View ▸ Loupe Overlay ▸ Layout Image: a PNG with transparency drawn over the +//! photo in the loupe, fitted and centred, its opaque parts covering the photo and its transparent +//! parts leaving it alone; opacity, matte, ⌥⌘O, a file that can't be read, and the choice saved +//! with the UI state. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The graphic's opaque band: magenta, unlike anything in the demo photos. +const BAND: [u8; 4] = [255, 0, 255, 255]; + +fn loupe() -> Headless { + let services = Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +/// A portrait "cover": an opaque magenta masthead across its top quarter, transparent below. +fn cover(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lc-layout-image-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(300, 400, |_, y| if y < 100 { BAND } else { [0, 0, 0, 0] }); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let path = dir.join("cover.png"); + std::fs::write(&path, png).unwrap(); + path +} + +fn widget(h: &Headless, name: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == name).map(|(_, r)| *r) +} + +fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + h.step(); + h.step(); + r +} + +fn px(img: &egui::ColorImage, p: egui::Pos2) -> [u8; 3] { + let c = img.pixels[(p.y as usize) * img.size[0] + p.x as usize]; + [c.r(), c.g(), c.b()] +} + +fn close(a: [u8; 3], b: [u8; 3], tol: i32) -> bool { + a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol) +} + +#[test] +fn the_layout_image_covers_the_photo_where_opaque_and_follows_opacity_matte_and_the_toggle() { + let mut h = loupe(); + let path = cover("draw"); + let photo = widget(&h, "canvas:image").expect("the loupe shows a photo"); + let before = h.snapshot(SETTLE); + + let r = run(&mut h, "view.chooseLayoutImage", json!({"path": path.to_string_lossy()})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.layout_image.show, "choosing a layout image shows it"); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImage").is_some()), "the layout image is drawn once read"); + let shown = h.snapshot(SETTLE); + let r = widget(&h, "canvas:layoutImage").unwrap(); + // fitted inside the photo, centred, with the graphic's 3:4 shape + assert!(photo.expand(1.0).contains_rect(r), "{r:?} inside {photo:?}"); + assert!((r.center().x - photo.center().x).abs() < 1.5 && (r.center().y - photo.center().y).abs() < 1.5, "{r:?} centred on {photo:?}"); + assert!((r.width() / r.height() - 0.75).abs() < 0.02, "{r:?}"); + // the masthead covers the photo; below it the photo shows through untouched + let masthead = egui::pos2(r.center().x, r.top() + r.height() * 0.12); + let clear = egui::pos2(r.center().x, r.top() + r.height() * 0.7); + assert!(close(px(&shown, masthead), [255, 0, 255], 12), "masthead {:?}", px(&shown, masthead)); + assert!(close(px(&shown, clear), px(&before, clear), 3), "{:?} vs {:?}", px(&shown, clear), px(&before, clear)); + + // half opacity: the masthead blends with the photo + let r2 = run(&mut h, "view.layoutImageOpacity", json!({"opacity": 0.5})); + assert_eq!(r2["ok"], true, "{r2}"); + let half = h.snapshot(SETTLE); + let m = px(&half, masthead); + assert!(!close(m, [255, 0, 255], 12) && !close(m, px(&before, masthead), 12), "half-opaque masthead {m:?}"); + + // the matte darkens the photo outside the graphic's box, and only there + // (a 3:4 graphic leaves the sides of a wider photo, the top and bottom of a taller one) + let side = if r.left() - photo.left() > 8.0 { + egui::pos2((photo.left() + r.left()) / 2.0, photo.center().y) + } else { + egui::pos2(photo.center().x, (photo.top() + r.top()) / 2.0) + }; + assert!(!r.expand(2.0).contains(side) && photo.contains(side), "{side:?}: in the photo, outside {r:?}"); + run(&mut h, "view.layoutImageMatte", json!({"matte": 0.75})); + let matte = h.snapshot(SETTLE); + let (lit, dark) = (px(&before, side), px(&matte, side)); + assert!(dark.iter().zip(lit).all(|(d, l)| *d as i32 <= l as i32 / 2 + 2), "matte {dark:?} vs {lit:?}"); + assert!(close(px(&matte, clear), px(&before, clear), 3), "inside the box the matte leaves the photo alone"); + assert!(run(&mut h, "view.layoutImageMatte", json!({}))["ok"] == false, "a matte is required"); + + // ⌥⌘O hides it and shows it again + let k = h.request("ui.key", json!({"key": "o", "cmd": true, "alt": true}), T); + assert_eq!(k["ok"], true, "{k}"); + h.step(); + h.step(); + assert!(!h.app.ui.layout_image.show); + assert!(widget(&h, "canvas:layoutImage").is_none(), "hidden: not drawn"); + let back = h.snapshot(SETTLE); + assert!(close(px(&back, masthead), px(&before, masthead), 3), "hidden: the photo is untouched"); + run(&mut h, "view.layoutImage", json!({"show": true})); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImage").is_some())); + + // saved with the UI state (the revision is not) + let saved = serde_json::to_value(&h.app.ui).unwrap(); + assert_eq!(saved["layoutImage"]["path"], json!(path.to_string_lossy())); + assert_eq!(saved["layoutImage"]["show"], true); + assert_eq!(saved["layoutImage"]["opacity"], json!(0.5)); + assert!(saved["layoutImage"].get("revision").is_none()); + let back: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(back.layout_image.path, path.to_string_lossy()); + // an older ui.json without it opens with nothing chosen + let old: crate::state::UiState = serde_json::from_value(json!({})).unwrap(); + assert_eq!(old.layout_image, crate::state::LayoutImage::default()); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn a_layout_image_that_cant_be_read_says_so_and_showing_none_asks_for_one() { + let mut h = loupe(); + // nothing chosen: showing it asks for a file, which headless has no dialog for + let r = run(&mut h, "view.layoutImage", json!({})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!h.app.ui.layout_image.show); + assert!(run(&mut h, "view.chooseLayoutImage", json!({"path": " "}))["ok"] == false, "an empty path is refused"); + + let dir = cover("missing"); + let missing = dir.parent().unwrap().join("no-such-cover.png"); + let r = run(&mut h, "view.chooseLayoutImage", json!({"path": missing.to_string_lossy()})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImageError").is_some()), "the canvas says the file can't be read"); + assert!(widget(&h, "canvas:layoutImage").is_none()); + // choosing a readable one replaces the error + let r = run(&mut h, "view.chooseLayoutImage", json!({"path": dir.to_string_lossy()})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImage").is_some())); + h.step(); + assert!(widget(&h, "canvas:layoutImageError").is_none()); + let _ = std::fs::remove_dir_all(dir.parent().unwrap()); +} + +#[test] +fn view_loupe_overlay_lists_the_layout_image_commands() { + let mut h = loupe(); + let names = |h: &Headless| -> Vec<(String, bool, Option)> { + let menu = crate::menubar::expanded(&h.app, "Loupe Overlay").unwrap_or_default(); + menu.into_iter() + .filter_map(|n| match n { + crate::menubar::MenuNode::Item { id, enabled, checked, .. } => Some((id, enabled, checked)), + _ => None, + }) + .collect() + }; + let before = names(&h); + assert_eq!(before[0], ("view.chooseLayoutImage".to_string(), true, None)); + // nothing chosen: only Choose… is enabled + assert!(before[1..].iter().all(|(_, enabled, _)| !enabled), "{before:?}"); + let path = cover("menu"); + run(&mut h, "view.chooseLayoutImage", json!({"path": path.to_string_lossy()})); + let after = names(&h); + assert_eq!(after[1], ("view.layoutImage".to_string(), true, Some(true))); + // 100% opacity and no matte are checked by default + let checked: Vec<&String> = after.iter().filter(|(_, _, c)| *c == Some(true)).map(|(id, ..)| id).collect(); + assert_eq!(checked, ["view.layoutImage", "view.layoutImageOpacity", "view.layoutImageMatte"]); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..3e94700e5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 4 | 3 | 1 | 49/52 (94%) | 23/25 (92%) | -| Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 409 | 53 | 72 | 35 | 193/201 (96%) | 146/159 (92%) | +| Lightroom Classic extras | 34 | 17 | 35 | 8 | — | 24/26 (92%) | +| **Total** | 409 | 54 | 72 | 35 | 193/201 (96%) | 146/159 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 535 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 50.0% of 174. ## Top gaps and shortcut notes @@ -724,6 +724,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ✅ | `cmd:library.preferences` (`camera`, `import.perCamera`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import: raw default and per-camera presets | | LRC-DEV-PROCESS | Process versions, Update to Current Process | P1 | ✅ | `cmd:develop.updateProcess`, `cmd:develop.reset`, `cmd:develop.get`, `crates/develop/src/settings.rs`, `crates/pipeline/src/finish.rs`, `docs/process-versions.md` | each photo records the rendering process its settings are interpreted by: settings saved before this existed load as V1, new photos and Reset get the latest, virtual copies inherit, copy / paste / sync / presets keep each photo's own; Photo ▸ Update to Current Process (one undo step, enabled while a selected photo is on an older process); a number from a newer LightCraft is kept and renders with the newest known process. Only V1 exists so far: this is what lets a retuned default tone (#146) or stronger Light sliders (#196) ship without changing existing edits | | LRC-DEV-VIEWOPTIONS | Develop view options | P2 | ⬜ | | | +| LRC-DEV-LOUPEOVERLAY | Loupe overlay: layout image | P2 | 🟡 | `cmd:view.chooseLayoutImage`, `cmd:view.layoutImage`, `cmd:view.layoutImageOpacity`, `cmd:view.layoutImageMatte`, `crates/ui-egui/src/panels/layout_overlay.rs` | View ▸ Loupe Overlay: a PNG with transparency (a cover layout, a banner's safe areas) fitted and centred over the loupe photo, following zoom and pan; opacity and matte (the photo darkened outside the graphic's box) in steps; ⌥⌘O shows / hides it; read off the UI thread; saved with the UI state. Not yet: the Grid and Guides overlays, moving and resizing the layout with ⌘ held, free opacity / matte sliders | | LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | | LRC-MAP-VIEW | Map view | P2 | ⬜ | | | | LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..58c3c9f4f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-14 +### Layout image over the loupe +- View ▸ Loupe Overlay ▸ Choose Layout Image… draws a PNG with transparency over the photo in the loupe, such as + a magazine cover's masthead and cover lines, so you can frame a shot for the page it is made for. It fits inside + the photo, follows zoom and pan, and ⌥⌘O shows or hides it. The same menu sets its opacity and a matte that + darkens the photo outside it. The layout stays chosen across launches. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.