LRC-DEV-LOUPEOVERLAY: a layout image over the loupe (View ▸ Loupe Overlay)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mj2w3LwYjFhMHALZuFp2rU
This commit is contained in:
BuonBreda
2026-10-14 19:32:06 +02:00
co-authored by Claude Opus 5.5
parent 3b4279fc24
commit 4551790eab
11 changed files with 549 additions and 6 deletions
+9 -2
View File
@@ -560,12 +560,19 @@ fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
{
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<Rgba8, String> {
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])) {
+2
View File
@@ -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;
+33 -1
View File
@@ -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<bool> {
"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<Vec<MenuNode>> {
pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
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<Vec<MenuNode>> {
}
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"])
+52
View File
@@ -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<Re
app.toast(&ctx, label);
Ok(json!({"infoOverlay": app.ui.info_overlay}))
}
"view.chooseLayoutImage" => {
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}))
+2
View File
@@ -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);
+223
View File
@@ -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<Mutex<Option<Result<Rgba8, String>>>>;
/// 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<u64>, 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<Rect> {
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<Rgba8, String> {
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::<Cache>(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());
}
}
+1
View File
@@ -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;
+36
View File
@@ -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,
+181
View File
@@ -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<egui::Rect> {
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<bool>)> {
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());
}
+4 -3
View File
@@ -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.
<!-- /parity:summary -->
## 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 | ⬜ | | |
+6
View File
@@ -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.