mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
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:
co-authored by
Claude Opus 5.5
parent
3b4279fc24
commit
4551790eab
@@ -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])) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 | ⬜ | | |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user