From 9ac38222e12e9bec2bb171d2a30aa8b69aeb4b9a Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:07:40 +0200 Subject: [PATCH 1/2] Alt/Option-drag previews for clipping and sharpening Holding Alt (Option on macOS) while dragging an Edit slider shows Lightroom's temporary previews: highlight clipping for Exposure, Highlights and Whites, shadow clipping for Shadows and Blacks, greyscale for Sharpening Amount, the detail band for Radius / Detail and the sharpening mask for Masking. They are render overlays (pipeline visualize), drawn the same way after the CPU and GPU renderers; the sharpening views read each renderer's own detail planes. Settings and normal output are unchanged. Fixes #937 Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/render.rs | 8 + crates/gpu/tests/equivalence.rs | 28 ++++ crates/pipeline/src/finish.rs | 11 +- crates/pipeline/src/lib.rs | 7 +- crates/pipeline/src/local.rs | 7 +- crates/pipeline/src/spots.rs | 4 +- crates/pipeline/src/visualize.rs | 239 +++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/detail.rs | 16 +- crates/ui-egui/src/state.rs | 5 + crates/ui-egui/src/tests_panels.rs | 37 +++++ docs/parity-checklist.md | 21 +-- docs/ui-parity.md | 11 +- docs/whats-new.md | 11 ++ 14 files changed, 382 insertions(+), 25 deletions(-) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 44458350..bb920d4c 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -747,6 +747,14 @@ pub fn render( lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref()) } }); + // a sharpening view: the detail planes this render sharpened with + let overlay_mask = overlay_mask.or_else(|| { + req.overlay.reads_detail().then(|| { + let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone(); + let tex = prep.texture.as_deref().map(|b| cx.read_plane(b, w, h)); + lightcraft_pipeline::visualize::sharpen_plane(req.overlay, &plan.settings, &l, tex.as_ref(), plan.px_per_long) + })? + }); lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref()); // spots grew the window the render worked on: cut it back to the request if let Some(k) = plan.keep { diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 96771a97..96d46340 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -660,6 +660,34 @@ fn overlays_match() { let req = RenderRequest { overlay: Overlay::Mask { id, view, color: [230, 30, 40], opacity: 50 }, ..RenderRequest::fit(640, 640) }; check(&format!("mask {id} overlay {}", view.name()), &src, &raw, &s, &req); } + // Alt-drag previews: clipping and greyscale on the finished image; the sharpening views from + // each renderer's own detail planes (with sharpening on, and off: no texture plane then). + let mut s = DevelopSettings::default(); + (s.light.whites, s.light.blacks, s.detail.sharpen_masking) = (80.0, -80.0, 40.0); + for amount in [50.0, 0.0] { + s.detail.sharpen_amount = amount; + for o in [Overlay::ClipHighlights, Overlay::ClipShadows, Overlay::Grayscale, Overlay::SharpenDetail, Overlay::SharpenMask] { + let req = RenderRequest { overlay: o, ..RenderRequest::fit(640, 640) }; + if matches!(o, Overlay::ClipHighlights | Overlay::ClipShadows) { + // a binary view: a 1-LSB difference at 0 / 255 flips a channel, so compare shares + let cpu = render(&src, &raw, &s, &req).image; + let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image; + let differ = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a != b).count() as f64 / cpu.data.len() as f64; + assert!(differ < 0.002, "{o:?} sharpen {amount}: {differ}"); + } else if o == Overlay::Grayscale { + check(&format!("{o:?} preview"), &src, &raw, &s, &req); + } else { + // the sharpening views amplify the detail planes (the mask through a steep + // smoothstep): allow a few larger differences where the planes differ slightly + let cpu = render(&src, &raw, &s, &req).image; + let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image; + let (mean, max, _) = diff(&cpu, &gpu); + let far = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a[0].abs_diff(b[0]) > 8).count() as f64 / cpu.data.len() as f64; + eprintln!("{o:?} sharpen {amount:<4} mean {mean:.4} max {max} >8: {:.4}%", far * 100.0); + assert!(mean < 1.0 && far < 0.005, "{o:?} sharpen {amount}: mean {mean:.4}, >8 LSB {far}"); + } + } + } } #[test] diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index eb65d51a..99c83dc4 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -20,6 +20,15 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 { t * t * (3.0 - 2.0 * t) } +/// How much of the sharpening a pixel gets (0..1) under Masking `masking` (0..1), from its fine +/// detail `det` (log luminance minus its texture blur): 1 everywhere at 0, only stronger edges +/// as it rises. The GPU kernel (`finish.wgsl`) computes the same; Alt-drag on Masking shows it +/// ([`crate::Overlay::SharpenMask`]). +#[inline] +pub fn sharpen_mask_weight(masking: f32, det: f32) -> f32 { + if masking > 0.0 { smooth(masking * 0.25, masking * 0.25 + 0.15, det.abs()) } else { 1.0 } +} + /// Parametric region curve (encoded domain) composed with the master point curve. fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> { let parametric = c.highlights != 0.0 || c.lights != 0.0 || c.darks != 0.0 || c.shadows != 0.0; @@ -509,7 +518,7 @@ pub(crate) fn finish_with( let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs()); delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame; if sp != 0.0 { - let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 }; + let m = sharpen_mask_weight(sharpen_mask, det); delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m; } } diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 6fbb156e..a1d9e2aa 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -637,9 +637,12 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, Some(acc.map(|v| v / n)) } -/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a -/// fresh evaluation. +/// The plane an overlay shows: a sharpening view's detail plane, or the alpha of a mask overlay +/// (the one the render evaluated, or for a hidden mask a fresh evaluation). fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option { + if o.reads_detail() { + return visualize::sharpen_plane(o, &plan.settings, &prep.log_l, prep.texture_blur.as_deref(), plan.px_per_long); + } let m = o.mask(&plan.settings)?; if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) { return Some(e.alpha.clone()); diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index ce3994cd..4ddd885c 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -452,6 +452,11 @@ pub struct PlaneSigmas { pub chroma: Option, } +/// Gaussian sigma (pixels) of the texture / sharpening band at `px_per_long`. +pub fn texture_sigma(px_per_long: f64) -> f32 { + (0.0018 * px_per_long as f32).max(0.6) +} + pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas { let ppl = px_per_long as f32; let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0); @@ -472,7 +477,7 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS || local_any(|a| a.sharpness) || local_any(|a| a.noise) || s.masks.iter().any(|m| m.adjust.defringe > 0.0)) - .then(|| (0.0018 * ppl).max(0.6)); + .then(|| texture_sigma(px_per_long)); let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0)); let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0)); let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 }; diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3..f8b3fbbd 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index fc3d8390..baa992fd 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -9,6 +9,11 @@ //! - **Mask overlay** (Masking): the evaluated alpha of one mask, drawn as a colour tint, a colour //! tint on a black-and-white image, the image on black / white, or the alpha as white on black //! ([`MaskView`]). Both renderers hand the same alpha plane (the one the render used) to [`apply`]. +//! - **Alt/Option-drag previews** (Lightroom's temporary views while a slider is dragged with the +//! modifier held; [`Overlay::for_control`] picks one per control): shadow / highlight clipping +//! (white or black, with the clipped channels shown), greyscale (Sharpening Amount), the fine +//! detail band sharpening works on (Radius / Detail) and the sharpening mask (Masking). The +//! sharpening views read the render's own detail planes ([`sharpen_plane`]). use std::borrow::Cow; @@ -36,6 +41,19 @@ pub enum Overlay { /// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR /// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]). HdrRange, + /// Shadow clipping (Alt-drag on Blacks / Shadows): white, a channel at 0 drops out of the + /// colour, so pixels clipped in every channel are black and partly clipped ones are coloured. + ClipShadows, + /// Highlight clipping (Alt-drag on Exposure / Highlights / Whites): black, a channel at 255 + /// shows in the colour, so pixels clipped in every channel are white. + ClipHighlights, + /// The photo in greyscale (Alt-drag on Sharpening Amount). + Grayscale, + /// The fine detail band sharpening amplifies, as grey ± detail (Alt-drag on Sharpening Radius + /// / Detail). + SharpenDetail, + /// The sharpening mask, white where sharpening applies (Alt-drag on Sharpening Masking). + SharpenMask, } /// [`Overlay::HdrRange`] colours (sRGB) for 0–1, 1–2, 2–3 and ≥ 3 stops above SDR white. @@ -121,6 +139,11 @@ impl Overlay { Overlay::Spots(t) => (2, t as f64), Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64), Overlay::HdrRange => (4, 0.0), + Overlay::ClipShadows => (5, 0.0), + Overlay::ClipHighlights => (6, 0.0), + Overlay::Grayscale => (7, 0.0), + Overlay::SharpenDetail => (8, 0.0), + Overlay::SharpenMask => (9, 0.0), } } @@ -131,6 +154,11 @@ impl Overlay { 2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8), 3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64), 4 => Overlay::HdrRange, + 5 => Overlay::ClipShadows, + 6 => Overlay::ClipHighlights, + 7 => Overlay::Grayscale, + 8 => Overlay::SharpenDetail, + 9 => Overlay::SharpenMask, _ => Overlay::None, } } @@ -143,9 +171,38 @@ impl Overlay { Overlay::Spots(t) => 0x2000 + t as u64, Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity), Overlay::HdrRange => 0x4000, + Overlay::ClipShadows => 0x5000, + Overlay::ClipHighlights => 0x5001, + Overlay::Grayscale => 0x5002, + Overlay::SharpenDetail => 0x5003, + Overlay::SharpenMask => 0x5004, } } + /// The temporary preview Lightroom shows while develop control `id` is dragged with Alt + /// (Option on macOS) held, if it has one. + pub fn for_control(id: &str) -> Option { + match id { + "light.exposure" | "light.highlights" | "light.whites" => Some(Overlay::ClipHighlights), + "light.shadows" | "light.blacks" => Some(Overlay::ClipShadows), + "detail.sharpenAmount" => Some(Overlay::Grayscale), + "detail.sharpenRadius" | "detail.sharpenDetail" => Some(Overlay::SharpenDetail), + "detail.sharpenMasking" => Some(Overlay::SharpenMask), + _ => None, + } + } + + /// Whether the overlay draws from the render's detail planes ([`sharpen_plane`]). + pub fn reads_detail(self) -> bool { + matches!(self, Overlay::SharpenDetail | Overlay::SharpenMask) + } + + /// Whether the overlay works pixel by pixel on the finished image, so a window render shows + /// the same as the whole frame would. + pub fn per_pixel(self) -> bool { + matches!(self, Overlay::None | Overlay::Mask { .. } | Overlay::ClipShadows | Overlay::ClipHighlights | Overlay::Grayscale) + } + /// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components). pub fn mask(self, s: &DevelopSettings) -> Option<&lightcraft_develop::Mask> { match self { @@ -167,9 +224,44 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { } } -/// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask -/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]). -pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) { +/// The plane a sharpening view draws ([`Overlay::reads_detail`]), from the render's log luminance +/// `log_l` and its texture blur (`None` when the render didn't need it: blurred here with the same +/// sigma). [`Overlay::SharpenDetail`]: the detail band (log luminance minus its blur, in EV); +/// [`Overlay::SharpenMask`]: the sharpening weight under the photo's Masking. Both renderers call +/// this with their own planes. +pub fn sharpen_plane(o: Overlay, s: &DevelopSettings, log_l: &Plane, texture: Option<&Plane>, px_per_long: f64) -> Option { + if !o.reads_detail() { + return None; + } + let own; + let blur = match texture.filter(|b| (b.width, b.height) == (log_l.width, log_l.height)) { + Some(b) => b, + None => { + own = lightcraft_raster::blur::gaussian(log_l, crate::local::texture_sigma(px_per_long)); + &own + } + }; + let masking = (s.detail.sharpen_masking / 100.0).clamp(0.0, 1.0) as f32; + let mask = o == Overlay::SharpenMask; + let data = log_l + .data + .iter() + .zip(&blur.data) + .map(|(l, b)| { + let det = l - b; + if mask { crate::finish::sharpen_mask_weight(masking, det) } else { det } + }) + .collect(); + Some(Plane { width: log_l.width, height: log_l.height, data }) +} + +/// [`Overlay::SharpenDetail`]: encoded grey per EV of detail (±0.4 EV spans black to white). +const DETAIL_GAIN: f32 = 320.0; + +/// Draw overlay `o` over `img` (rendered with `plan`). `plane` is what the overlay shows, at the +/// image's size: the evaluated alpha of the mask an [`Overlay::Mask`] shows (see +/// [`Overlay::mask`]) or a sharpening view's [`sharpen_plane`]. +pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, plane: Option<&Plane>) { match o { // drawn by the renderer from the HDR rendition ([`hdr_range`]) Overlay::None | Overlay::HdrRange => {} @@ -180,13 +272,60 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) } Overlay::Spots(t) => spots(img, t, plan.px_per_long), Overlay::Mask { view, color, opacity, .. } => { - if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) { + if let Some(a) = plane.filter(|a| (a.width, a.height) == (img.width, img.height)) { mask_view(img, a, view, color, opacity); } } + Overlay::ClipShadows => clipping(img, false), + Overlay::ClipHighlights => clipping(img, true), + Overlay::Grayscale => grayscale(img), + Overlay::SharpenDetail | Overlay::SharpenMask => { + if let Some(p) = plane.filter(|a| (a.width, a.height) == (img.width, img.height)) { + let detail = o == Overlay::SharpenDetail; + let w = img.width; + for_rows(&mut img.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + let v = p.data[y * w + x]; + let g = if detail { 128.0 + v * DETAIL_GAIN } else { 255.0 * v }; + let g = g.round().clamp(0.0, 255.0) as u8; + *px = [g, g, g, 255]; + } + }); + } + } } } +/// [`Overlay::ClipHighlights`] (`high`) or [`Overlay::ClipShadows`]: each channel shows only +/// whether it is clipped, on black (highlights) or white (shadows). +pub fn clipping(img: &mut Rgba8, high: bool) { + let w = img.width; + for_rows(&mut img.data, w, |_, row| { + for px in row.iter_mut() { + for c in &mut px[..3] { + *c = if high { + if *c == 255 { 255 } else { 0 } + } else if *c == 0 { + 0 + } else { + 255 + }; + } + px[3] = 255; + } + }); +} + +fn grayscale(img: &mut Rgba8) { + let w = img.width; + for_rows(&mut img.data, w, |_, row| { + for px in row.iter_mut() { + let g = (0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32).round().clamp(0.0, 255.0) as u8; + *px = [g, g, g, 255]; + } + }); +} + /// Draw mask alpha `a` over `img` as `view`. pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opacity: u8) { let op = opacity.min(100) as f32 / 100.0; @@ -380,4 +519,96 @@ mod tests { assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color); assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw)); } + + #[test] + fn clipping_views_show_the_clipped_channels() { + let mut img = Rgba8 { + width: 5, + height: 1, + data: vec![[0, 0, 0, 255], [255, 255, 255, 255], [255, 40, 0, 255], [0, 128, 200, 255], [120, 130, 140, 255]], + }; + let mut hi = img.clone(); + clipping(&mut hi, true); + // black background, white where every channel clips, the clipped channels' colour otherwise + assert_eq!(hi.data, vec![[0, 0, 0, 255], [255; 4], [255, 0, 0, 255], [0, 0, 0, 255], [0, 0, 0, 255]]); + clipping(&mut img, false); + // white background, black where every channel clips, the unclipped channels' colour otherwise + assert_eq!(img.data, vec![[0, 0, 0, 255], [255; 4], [255, 255, 0, 255], [0, 255, 255, 255], [255; 4]]); + } + + #[test] + fn clipping_previews_follow_the_rendered_clipping() { + // left: deep shadow, middle: grey, right: bright highlight + let src = Rgb32f::from_fn(60, 20, |x, _| match x / 20 { + 0 => [0.0; 3], + 1 => [0.18; 3], + _ => [3.0; 3], + }); + let info = SourceInfo::default(); + let mut s = DevelopSettings::default(); + s.light.blacks = -100.0; + s.light.whites = 100.0; + let req = |o| RenderRequest { overlay: o, ..RenderRequest::fit(60, 20) }; + let plain = render(&src, &info, &s, &req(Overlay::None)); + let hi = render(&src, &info, &s, &req(Overlay::ClipHighlights)); + let lo = render(&src, &info, &s, &req(Overlay::ClipShadows)); + let at = |i: &Rgba8, x: usize| i.data[10 * 60 + x]; + assert_eq!(at(&plain.image, 55), [255; 4], "the highlight clips"); + assert_eq!(at(&plain.image, 5), [0, 0, 0, 255], "the shadow clips"); + assert_eq!((at(&hi.image, 55), at(&hi.image, 30), at(&hi.image, 5)), ([255; 4], [0, 0, 0, 255], [0, 0, 0, 255])); + assert_eq!((at(&lo.image, 5), at(&lo.image, 30), at(&lo.image, 55)), ([0, 0, 0, 255], [255; 4], [255; 4])); + // a preview only: the histogram is the photo's + assert_eq!(hi.histogram, plain.histogram); + assert_eq!(render(&src, &info, &s, &req(Overlay::None)).image, plain.image); + } + + #[test] + fn sharpening_previews_show_the_mask_and_detail() { + // a smooth ramp (left) and a hard edge (at x = 60) + let src = Rgb32f::from_fn(80, 40, |x, _| if x < 60 { [0.05 + 0.002 * x as f32; 3] } else { [0.6; 3] }); + let info = SourceInfo::default(); + let mut s = DevelopSettings::default(); + s.detail.sharpen_amount = 60.0; + let shot = |s: &DevelopSettings, o| render(&src, &info, s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image; + let (flat, edge) = (20 * 80 + 20, 20 * 80 + 60); + // Masking 0 sharpens everything: an all-white mask + let m = shot(&s, Overlay::SharpenMask); + assert!(m.data.iter().all(|p| *p == [255; 4])); + // Masking 100 keeps the edge, protects the ramp + s.detail.sharpen_masking = 100.0; + let m = shot(&s, Overlay::SharpenMask); + assert_eq!((m.data[edge], m.data[flat]), ([255; 4], [0, 0, 0, 255])); + // the same without sharpening on (the render has no texture plane then) + s.detail.sharpen_amount = 0.0; + assert_eq!(shot(&s, Overlay::SharpenMask), m); + // detail: mid grey on the ramp, light / dark either side of the edge + let d = shot(&s, Overlay::SharpenDetail); + assert!(d.data[flat][0].abs_diff(128) <= 3, "{:?}", d.data[flat]); + assert!(d.data[edge][0] > 200 && d.data[edge - 1][0] < 60, "{:?} {:?}", d.data[edge], d.data[edge - 1]); + // greyscale + let src = Rgb32f::from_fn(8, 8, |_, _| [0.4, 0.1, 0.05]); + let g = render(&src, &info, &s, &RenderRequest { overlay: Overlay::Grayscale, ..RenderRequest::fit(8, 8) }).image; + assert!(g.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[0] > 0)); + } + + #[test] + fn alt_previews_map_to_controls_and_round_trip() { + assert_eq!(Overlay::for_control("light.blacks"), Some(Overlay::ClipShadows)); + assert_eq!(Overlay::for_control("light.shadows"), Some(Overlay::ClipShadows)); + assert_eq!(Overlay::for_control("light.whites"), Some(Overlay::ClipHighlights)); + assert_eq!(Overlay::for_control("light.exposure"), Some(Overlay::ClipHighlights)); + assert_eq!(Overlay::for_control("light.highlights"), Some(Overlay::ClipHighlights)); + assert_eq!(Overlay::for_control("detail.sharpenAmount"), Some(Overlay::Grayscale)); + assert_eq!(Overlay::for_control("detail.sharpenDetail"), Some(Overlay::SharpenDetail)); + assert_eq!(Overlay::for_control("detail.sharpenMasking"), Some(Overlay::SharpenMask)); + assert_eq!(Overlay::for_control("light.contrast"), None); + let all = [Overlay::ClipShadows, Overlay::ClipHighlights, Overlay::Grayscale, Overlay::SharpenDetail, Overlay::SharpenMask]; + for o in all { + let (k, v) = o.to_parts(); + assert_eq!(Overlay::from_parts(k, v), o); + assert_eq!(all.iter().filter(|p| p.key() == o.key()).count(), 1); + assert_ne!(o.key(), Overlay::HdrRange.key()); + } + assert!(Overlay::ClipShadows.per_pixel() && !Overlay::SharpenDetail.per_pixel()); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77..5dc96512 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1018,6 +1018,8 @@ impl LightcraftApp { } } self.ui.was_focused = focused; + // Alt (Option on macOS) while a slider is dragged shows its temporary preview + self.ui.alt_held = ctx.input(|i| i.modifiers.alt); // auto import: list the watched folder every few seconds (on a worker thread: it may be on // a network share), then import what's new like any import (also on a worker thread) #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 23447616..4eb03c8c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -253,7 +253,7 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { /// Mask overlays are evaluated from the same mask alpha as the full render; the other diagnostic /// overlays either need whole-frame context or deliberately replace the loupe. pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { - matches!(overlay, lightcraft_pipeline::Overlay::None | lightcraft_pipeline::Overlay::Mask { .. }) + overlay.per_pixel() } /// What a window render of the loupe needs to know about the view it belongs to. @@ -444,7 +444,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let look = d.hash64(); let overlay = view_overlay(app, &d); // Diagnostic views that need whole-frame context, soft proofing, or reads too far for one - // window still render the whole frame at the size it is drawn. A mask overlay is window-safe. + // window still render the whole frame at the size it is drawn. Per-pixel ones (a mask, + // clipping) are window-safe. let sizes = crate::region::ViewSizes { drawn_long, canvas_long: canvas.width().max(canvas.height()) * ppp, @@ -1118,13 +1119,20 @@ fn targeted_drag(app: &mut LightcraftApp, resp: &egui::Response, map: &CanvasMap } } -/// The diagnostic overlay the loupe shows (the selected mask, Point Color's visualized range, -/// Visualize Spots). +/// The diagnostic overlay the loupe shows (an Alt-drag preview, the selected mask, Point Color's +/// visualized range, Visualize Spots). pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { use lightcraft_pipeline::{MaskView, Overlay}; if app.ui.fullscreen { return Overlay::None; } + // Lightroom's temporary previews: clipping, greyscale, sharpening detail / mask + if app.ui.right == RightPanel::Edit + && app.ui.alt_held + && let Some(o) = app.ui.dragging_control.as_deref().and_then(Overlay::for_control) + { + return o; + } if app.ui.right == RightPanel::Masking && app.ui.mask_overlay && let Some(m) = app.session.active_mask.and_then(|id| d.masks.iter().find(|m| m.id == id)) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d5..839bfa7a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -603,6 +603,10 @@ pub struct UiState { /// The develop control whose slider is being dragged (geometry sliders show a grid). #[serde(skip)] pub dragging_control: Option, + /// Alt (Option on macOS) is held this frame: a dragged Edit slider shows its temporary + /// preview ([`lightcraft_pipeline::Overlay::for_control`]). + #[serde(skip)] + pub alt_held: bool, pub search: String, /// Focus the search field on the next frame (Edit → Find…). #[serde(skip)] @@ -1074,6 +1078,7 @@ impl Default for UiState { unnamed_focus: false, hidden_locations: Vec::new(), dragging_control: None, + alt_held: false, external_edits: Vec::new(), was_focused: true, auto_import_at: 0.0, diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 25edf3bf..a7accb36 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2141,3 +2141,40 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { assert!(trip.photos.contains(&photo), "the right-clicked photo is in it: {:?}", trip.photos); assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); } + +#[test] +fn alt_while_dragging_an_edit_slider_shows_its_preview() { + use crate::state::RightPanel; + use lightcraft_pipeline::Overlay; + let mut h = demo([1400.0, 900.0], json!({"view": "detail", "right": "edit"})); + let id = h.app.session.active().expect("active photo"); + let d = (*h.app.session.develop_of(id).unwrap_or_default()).clone(); + let view = |h: &Headless| crate::panels::detail::view_overlay(&h.app, &d); + assert_eq!(h.app.ui.right, RightPanel::Edit); + h.app.ui.dragging_control = Some("light.blacks".into()); + assert_eq!(view(&h), Overlay::None, "dragging without Alt shows the photo"); + h.app.ui.alt_held = true; + assert_eq!(view(&h), Overlay::ClipShadows); + for (c, o) in [ + ("light.whites", Overlay::ClipHighlights), + ("light.exposure", Overlay::ClipHighlights), + ("detail.sharpenAmount", Overlay::Grayscale), + ("detail.sharpenRadius", Overlay::SharpenDetail), + ("detail.sharpenMasking", Overlay::SharpenMask), + ("light.contrast", Overlay::None), + ] { + h.app.ui.dragging_control = Some(c.into()); + assert_eq!(view(&h), o, "{c}"); + } + // Alt alone (no drag) or released mid-drag: back to the photo + h.app.ui.dragging_control = None; + assert_eq!(view(&h), Overlay::None); + h.app.ui.dragging_control = Some("light.whites".into()); + h.app.ui.alt_held = false; + assert_eq!(view(&h), Overlay::None); + // the Masking panel's local Blacks slider is not a global clipping preview + h.app.ui.alt_held = true; + h.app.ui.right = RightPanel::Masking; + h.app.ui.dragging_control = Some("light.blacks".into()); + assert_eq!(view(&h), Overlay::None); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4..75d2aed2 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Alt/Option-drag previews, LR-EDIT-DETAIL-SHARPEN to 🟡; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -31,7 +31,7 @@ cited here still exists. Formerly docs/parity.md. | C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | -| F. Edit panel — global adjustments (EDIT) | 43 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | +| F. Edit panel — global adjustments (EDIT) | 42 | 3 | 3 | 1 | 27/28 (96%) | 13/14 (93%) | | G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 407 | 50 | 73 | 35 | 192/200 (96%) | 145/157 (92%) | +| **Total** | 406 | 51 | 73 | 35 | 191/200 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 49.7% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.4%** of 530 in-scope rows — P0 97.8% of 200 · P1 95.2% of 157 · P2 49.7% of 172. ## Top gaps and shortcut notes @@ -177,12 +177,12 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | a Lightroom-like recipe calibrated black-box on Lightroom's Auto results for 24 Sony raws: Exposure from the scene's scene-linear log-average luminance (Lightroom's observed slope, -0.41 EV per EV), Highlights -66 and Shadows +52 (Lightroom's average Auto values), Contrast +68 and Vibrance +30 fitted on our scales. Held out one photo at a time: on the 16 photos whose start Sony's DRO doesn't brighten, a median dE76 of 6.4 from Lightroom's Auto results against 8.3 unedited; on all 24, 7.7 against 7.9 (issues #318, #209). Calibrated on Sony files only; Whites and Blacks stay 0 | | LR-EDIT-BW | Black & white | P0 | ✅ | `cmd:develop.treatment` | | | LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR button next to B&W in the Edit panel header, as Lightroom (Visualize HDR and the headroom limit in the Light panel while it's on); see Q. HDR | -| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | | +| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | Alt/Option-drag shows highlight clipping (black, clipped channels in colour) | | LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | ✅ | `ctl:light.contrast` | | -| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp | -| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632) | -| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | -| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | | +| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp. Alt/Option-drag shows highlight clipping | +| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632). Alt/Option-drag shows shadow clipping (white, clipped channels in colour) | +| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites`, `crates/pipeline/src/visualize.rs` | Alt/Option-drag shows highlight clipping (black, clipped channels in colour) | +| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks`, `crates/pipeline/src/visualize.rs` | Alt/Option-drag shows shadow clipping (white, clipped channels in colour) | | LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | | | LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON | | LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | | @@ -203,7 +203,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | | LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | `ctl:vignette.*`, `crates/pipeline/src/finish.rs`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section | | LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | | -| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview | +| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | 🟡 | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking`, `crates/pipeline/src/visualize.rs` | Amount and Masking work; Radius and Detail are stored (XMP round trip) but do not change the render yet (the band is a fixed 0.18 % of the long edge). Alt/Option-drag previews: Amount greyscale, Radius / Detail the detail band sharpening amplifies, Masking the mask (white = sharpened) | | LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | | | LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | | | LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); tiles can also run on another computer's graphics card (`lightcraft-cli denoise-serve`, `crates/denoise/src/remote.rs`); X-Trans/linear RGB, model policy and Lightroom fidelity remain; closing the model setup without installing turns the switch back off (#922) | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Alt/Option-drag previews on Exposure, Highlights, Shadows, Whites, Blacks and Sharpening (issue #937); LR-EDIT-DETAIL-SHARPEN ✅ → 🟡: Radius and Detail don't change the render | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 44aa1c70..06a728db 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (zoom row: Compare and Reference sharp at 1:1) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Alt/Option-drag slider previews) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -67,6 +67,14 @@ under the UI command that wraps it (a no-op error); the UI command now wins. **Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. Remaining Classic gaps: rating `[` / `]`, flag cycling and filter-bar keys. +**Alt/Option-drag previews (issue #937):** hold Alt (Option on macOS) while dragging an Edit slider, before or during +the drag. Exposure, Highlights and Whites show highlight clipping (black, clipped channels in their colour, white where +all three clip); Shadows and Blacks show shadow clipping (white, black where all three clip). Sharpening Amount shows +greyscale, Radius and Detail the detail band sharpening amplifies, Masking the mask (white = sharpened). Releasing the +key or the slider brings the photo back; the edit and its history are unchanged. Not yet: Noise Reduction, mask range +sliders and the Masking panel's local sliders. On Linux desktops that move windows with Alt+drag (KDE Plasma's +default, also some GNOME / Xfce setups) the window manager takes the drag first: set its window-move modifier to Meta. + **Still missing / broken:** - No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, visualize spots and merges have commands now). @@ -78,5 +86,6 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Alt/Option-drag slider previews (clipping, sharpening) and the Linux window-manager note | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a..5107eae2 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -11,6 +11,17 @@ - Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in noticeably duller. +### Alt/Option-drag previews +- Hold Alt (Option on macOS) while dragging Exposure, Highlights or Whites to see which areas clip to white: the + photo turns black, fully clipped areas show white and areas clipped in one or two channels show those channels' + colour. Shadows and Blacks do the same for clipping to black, on white (issue #937). +- In Detail ▸ Sharpening the same key shows Amount in greyscale, Radius and Detail as the fine detail being + sharpened, and Masking as the mask: white is sharpened, black is protected. Radius and Detail don't change the + render yet, so their preview stays the same while you drag them. +- The preview lasts while the key and the slider are held, and never changes the edit, its history or exports. +- Linux: KDE Plasma (and some other desktops) move windows with Alt+drag. If Alt+drag moves the window instead, + set the window manager's move modifier to Meta. + ### Open Library knows a Lightroom catalog - Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` / `.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The From 86f4bde867d1ffa44519b23d39733eac015e6e52 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Mon, 12 Oct 2026 13:53:24 +0200 Subject: [PATCH 2/2] LR-VIEW-CLIPPING: Alt-drag previews keep the histogram on profiled displays; NR previews (#937) Folds the parts of #995 that #999 lacked into #999: - engine/media.rs: with a display profile, a diagnostic view keeps the photo's histogram (taken before the overlay is drawn) and stays out of the view cache; the clipping previews test sRGB values and are converted for the display afterwards. Before, holding Alt on a P3 display swapped the histogram for the clipping view's (mean luma 0.59 -> 0.999). - Noise Reduction Luminance / Detail / Contrast show greyscale while Alt-dragged (Overlay::for_control). - Tests: diagnostic_views_keep_the_photos_histogram (P3 display; fails without the media.rs change), an end-to-end headless drag that presses and releases Alt mid-drag and checks the loupe pixels (tests_alt_preview.rs), and every Alt-mapped id is a real control. - Docs: NR row, What's New and ui-parity note; parity summary refreshed. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/display.rs | 19 +++++++ crates/engine/src/media.rs | 15 +++++- crates/pipeline/src/visualize.rs | 31 ++++++++++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_alt_preview.rs | 71 +++++++++++++++++++++++++ docs/parity-checklist.md | 6 +-- docs/ui-parity.md | 7 +-- docs/whats-new.md | 3 +- 8 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/tests_alt_preview.rs diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index f47e099b..34840e46 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -152,6 +152,25 @@ mod tests { } } + /// Alt-drag previews on a profiled display (issue #937): the histogram stays the photo's (not + /// the preview's), and the clipping view is tested and drawn in sRGB, so it stays black and white. + #[test] + fn diagnostic_views_keep_the_photos_histogram() { + use lightcraft_pipeline::Overlay; + let mut session = crate::Session::with_demo(); + let id = session.visible_cloned()[0]; + let d = std::sync::Arc::new(p3()); + let mut run = |o| session.loupe_job(id, 320, 240, true).unwrap().with_display(Some(d.clone())).with_overlay(o).run().rendered.unwrap(); + let plain = run(Overlay::None); + let clip = run(Overlay::ClipShadows); + let (a, b) = (plain.histogram.mean_luma(), clip.histogram.mean_luma()); + assert!((a - b).abs() < 0.02, "the photo's histogram: {a} vs {b}"); + let px = &clip.image.data; + // (the profile may round white to 254) + let binary = px.iter().filter(|p| p[..3].iter().all(|v| *v <= 2 || *v >= 253)).count(); + assert!(binary as f64 > 0.99 * px.len() as f64, "{binary} of {}", px.len()); + } + #[test] fn load_reports_and_rejects() { let dir = std::env::temp_dir().join(format!("lightcraft-display-{}", std::process::id())); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359..cd26f222 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -838,11 +838,24 @@ impl RenderJob { // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use // the GPU when there is one. let gpu = self.cache.is_none(); - let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); + // a clipping preview tests sRGB values, like the histogram: rendered in sRGB, then + // converted for the display below + let clip_view = self.request.overlay.is_clipping(); + let request = if clip_view { RenderRequest { display: None, ..self.request } } else { self.request }; + let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &request, self.stages.as_deref(), gpu); // for a display profile: the image goes to the display's values, the histogram // and the cached view preview stay sRGB let mut srgb = None; let display = match (&self.display, self.request.display) { + // a diagnostic view: the histogram stays the photo's (taken before the overlay + // was drawn), and nothing goes to the view cache + (Some(d), Some(_)) if self.request.overlay != lightcraft_pipeline::Overlay::None => { + let converted = if clip_view { d.profile.from_srgb(&mut rendered.image) } else { d.profile.correct(&mut rendered.image) }; + if let Err(e) = converted { + log::warn!("display profile: {e}"); + } + Some(d.id()) + } (Some(d), Some(_)) => { let keep = self.view_cache.is_some() && self.request.quality == Quality::Full; let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep); diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index baa992fd..50b95933 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -185,13 +185,19 @@ impl Overlay { match id { "light.exposure" | "light.highlights" | "light.whites" => Some(Overlay::ClipHighlights), "light.shadows" | "light.blacks" => Some(Overlay::ClipShadows), - "detail.sharpenAmount" => Some(Overlay::Grayscale), + "detail.sharpenAmount" | "detail.nrLuminance" | "detail.nrDetail" | "detail.nrContrast" => Some(Overlay::Grayscale), "detail.sharpenRadius" | "detail.sharpenDetail" => Some(Overlay::SharpenDetail), "detail.sharpenMasking" => Some(Overlay::SharpenMask), _ => None, } } + /// The clipping previews, which test the sRGB values the histogram counts (a render for a + /// display profile draws them in sRGB, then converts). + pub fn is_clipping(self) -> bool { + matches!(self, Overlay::ClipShadows | Overlay::ClipHighlights) + } + /// Whether the overlay draws from the render's detail planes ([`sharpen_plane`]). pub fn reads_detail(self) -> bool { matches!(self, Overlay::SharpenDetail | Overlay::SharpenMask) @@ -601,7 +607,30 @@ mod tests { assert_eq!(Overlay::for_control("detail.sharpenAmount"), Some(Overlay::Grayscale)); assert_eq!(Overlay::for_control("detail.sharpenDetail"), Some(Overlay::SharpenDetail)); assert_eq!(Overlay::for_control("detail.sharpenMasking"), Some(Overlay::SharpenMask)); + for c in ["detail.nrLuminance", "detail.nrDetail", "detail.nrContrast"] { + assert_eq!(Overlay::for_control(c), Some(Overlay::Grayscale), "{c}"); + } + assert_eq!(Overlay::for_control("detail.nrColor"), None); assert_eq!(Overlay::for_control("light.contrast"), None); + // every mapped id is a real control (a rename would silently drop its preview) + for c in [ + "light.exposure", + "light.highlights", + "light.whites", + "light.shadows", + "light.blacks", + "detail.sharpenAmount", + "detail.sharpenRadius", + "detail.sharpenDetail", + "detail.sharpenMasking", + "detail.nrLuminance", + "detail.nrDetail", + "detail.nrContrast", + ] { + assert!(lightcraft_develop::controls::find(c).is_some(), "{c}"); + assert!(Overlay::for_control(c).is_some(), "{c}"); + } + assert!(Overlay::ClipShadows.is_clipping() && Overlay::ClipHighlights.is_clipping() && !Overlay::Grayscale.is_clipping()); let all = [Overlay::ClipShadows, Overlay::ClipHighlights, Overlay::Grayscale, Overlay::SharpenDetail, Overlay::SharpenMask]; for o in all { let (k, v) = o.to_parts(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5dc96512..be0b6653 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,8 @@ mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] +mod tests_alt_preview; +#[cfg(test)] mod tests_appearance; #[cfg(test)] mod tests_before_after; diff --git a/crates/ui-egui/src/tests_alt_preview.rs b/crates/ui-egui/src/tests_alt_preview.rs new file mode 100644 index 00000000..7a212869 --- /dev/null +++ b/crates/ui-egui/src/tests_alt_preview.rs @@ -0,0 +1,71 @@ +//! Headless test of the Alt-drag previews (issue #937) through real input: a slider drag, Alt +//! (Option) pressed and released during it, and the loupe's pixels at each step. + +use std::time::Duration; + +use egui::{Event, Modifiers, PointerButton}; +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); + +fn detail() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 1400.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + if !h.app.ui.section_open("light") { + h.app.ui.toggle_section("light"); + } + h.app.renderer.keep_pixels = true; + h.settle(SETTLE); + h +} + +/// Share of the loupe render's pixels whose channels are all 0 or 255 (a clipping view). +fn binary_share(h: &Headless) -> f64 { + let t = h.app.renderer.textures.get(&crate::render::Slot::Main).expect("loupe render"); + let px = &t.pixels.as_ref().expect("pixels kept").pixels; + let binary = px.iter().filter(|c| [c.r(), c.g(), c.b()].iter().all(|v| *v == 0 || *v == 255)).count(); + binary as f64 / px.len().max(1) as f64 +} + +#[test] +fn alt_during_a_blacks_drag_shows_shadow_clipping_until_released() { + let mut h = detail(); + assert!(binary_share(&h) < 0.5, "the photo itself is no clipping view"); + let track = h.app.widgets.iter().find(|(w, _)| w == "slider:light.blacks").map(|(_, r)| *r).expect("Blacks slider on screen"); + let from = track.center(); + let before = h.app.session.develop_of(h.app.session.active().expect("photo")).expect("develop").light.blacks; + // a plain drag to the left, button held: the photo as usual + h.app.synthetic.push(Event::PointerMoved(from)); + h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + for i in 1..=8 { + h.app.synthetic.push(Event::PointerMoved(from - egui::vec2(6.0 * i as f32, 0.0))); + } + h.settle(SETTLE); + assert_eq!(h.app.ui.dragging_control.as_deref(), Some("light.blacks")); + assert!(binary_share(&h) < 0.5, "no preview without Alt"); + // Alt pressed mid-drag: the shadow clipping view + h.app.synthetic.push(Event::ModifiersChanged(Modifiers::ALT)); + h.settle(SETTLE); + assert!(binary_share(&h) > 0.99, "the clipping view: {}", binary_share(&h)); + // Alt released: the photo again, the drag still on + h.app.synthetic.push(Event::ModifiersChanged(Modifiers::NONE)); + h.settle(SETTLE); + assert!(binary_share(&h) < 0.5, "the photo is back"); + h.app.synthetic.push(Event::PointerButton { + pos: from - egui::vec2(48.0, 0.0), + button: PointerButton::Primary, + pressed: false, + modifiers: Modifiers::NONE, + }); + h.settle(SETTLE); + assert_eq!(h.app.ui.dragging_control, None); + let after = h.app.session.develop_of(h.app.session.active().expect("photo")).expect("develop").light.blacks; + assert!(after < before, "the drag itself still moved Blacks: {before} → {after}"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 75d2aed2..cfd9248d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 406 | 51 | 73 | 35 | 191/200 (96%) | 145/157 (92%) | +| **Total** | 407 | 52 | 72 | 35 | 192/201 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.4%** of 530 in-scope rows — P0 97.8% of 200 · P1 95.2% of 157 · P2 49.7% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 531 in-scope rows — P0 97.8% of 201 · P1 95.2% of 157 · P2 50.0% of 172. ## Top gaps and shortcut notes @@ -204,7 +204,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | `ctl:vignette.*`, `crates/pipeline/src/finish.rs`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section | | LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | | | LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | 🟡 | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking`, `crates/pipeline/src/visualize.rs` | Amount and Masking work; Radius and Detail are stored (XMP round trip) but do not change the render yet (the band is a fixed 0.18 % of the long edge). Alt/Option-drag previews: Amount greyscale, Radius / Detail the detail band sharpening amplifies, Masking the mask (white = sharpened) | -| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | | +| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | Alt/Option-drag on Luminance, Detail and Contrast shows the photo in greyscale | | LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | | | LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); tiles can also run on another computer's graphics card (`lightcraft-cli denoise-serve`, `crates/denoise/src/remote.rs`); X-Trans/linear RGB, model policy and Lightroom fidelity remain; closing the model setup without installing turns the switch back off (#922) | | LR-EDIT-DETAIL-RAWDETAILS | Improved demosaic toggle | P2 | ⬜ | | | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 06a728db..6782cc6b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -70,9 +70,10 @@ under the UI command that wraps it (a no-op error); the UI command now wins. **Alt/Option-drag previews (issue #937):** hold Alt (Option on macOS) while dragging an Edit slider, before or during the drag. Exposure, Highlights and Whites show highlight clipping (black, clipped channels in their colour, white where all three clip); Shadows and Blacks show shadow clipping (white, black where all three clip). Sharpening Amount shows -greyscale, Radius and Detail the detail band sharpening amplifies, Masking the mask (white = sharpened). Releasing the -key or the slider brings the photo back; the edit and its history are unchanged. Not yet: Noise Reduction, mask range -sliders and the Masking panel's local sliders. On Linux desktops that move windows with Alt+drag (KDE Plasma's +greyscale, Radius and Detail the detail band sharpening amplifies, Masking the mask (white = sharpened); luminance Noise +Reduction (Luminance, Detail, Contrast) shows greyscale. Releasing the key or the slider brings the photo back; the +edit, its history and the histogram are unchanged (on a profiled display too). Not yet: mask range sliders and the +Masking panel's local sliders. On Linux desktops that move windows with Alt+drag (KDE Plasma's default, also some GNOME / Xfce setups) the window manager takes the drag first: set its window-move modifier to Meta. **Still missing / broken:** diff --git a/docs/whats-new.md b/docs/whats-new.md index 5107eae2..8d2329db 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -17,7 +17,8 @@ colour. Shadows and Blacks do the same for clipping to black, on white (issue #937). - In Detail ▸ Sharpening the same key shows Amount in greyscale, Radius and Detail as the fine detail being sharpened, and Masking as the mask: white is sharpened, black is protected. Radius and Detail don't change the - render yet, so their preview stays the same while you drag them. + render yet, so their preview stays the same while you drag them. Noise Reduction's Luminance, Detail and Contrast + show the photo in greyscale. - The preview lasts while the key and the slider are held, and never changes the edit, its history or exports. - Linux: KDE Plasma (and some other desktops) move windows with Alt+drag. If Alt+drag moves the window instead, set the window manager's move modifier to Meta.