# Conflicts:
#	crates/pipeline/src/spots.rs
#	crates/ui-egui/src/tests_panels.rs
#	docs/parity-checklist.md
#	docs/ui-parity.md
#	docs/whats-new.md
This commit is contained in:
bflatastic 2026-10-13 12:40:35 +07:00
commit 97d38486b7
16 changed files with 513 additions and 21 deletions

View File

@ -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()));

View File

@ -906,11 +906,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);

View File

@ -754,6 +754,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 {

View File

@ -722,6 +722,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]

View File

@ -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<T: Copy + Default + Send>(
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;
}
}

View File

@ -650,9 +650,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<Plane> {
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());

View File

@ -452,6 +452,11 @@ pub struct PlaneSigmas {
pub chroma: Option<f32>,
}
/// 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 };

View File

@ -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,44 @@ 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<Overlay> {
match id {
"light.exposure" | "light.highlights" | "light.whites" => Some(Overlay::ClipHighlights),
"light.shadows" | "light.blacks" => Some(Overlay::ClipShadows),
"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)
}
/// 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 +230,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<Plane> {
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 +278,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 +525,119 @@ 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));
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();
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());
}
}

View File

@ -47,6 +47,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;
@ -1061,6 +1063,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"))]

View File

@ -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.
@ -447,7 +447,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,
@ -1235,13 +1236,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))

View File

@ -637,6 +637,10 @@ pub struct UiState {
/// The develop control whose slider is being dragged (geometry sliders show a grid).
#[serde(skip)]
pub dragging_control: Option<String>,
/// 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)]
@ -1121,6 +1125,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,

View File

@ -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}");
}

View File

@ -2365,3 +2365,40 @@ fn new_album_can_leave_the_selected_photos_out() {
confirm(&mut h);
assert!(photos(&h, "Nothing").is_empty());
}
#[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);
}

View File

@ -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%) |
@ -184,12 +184,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 moves it a tenth as far for fine adjustment, issue #1363); 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`) | |
@ -210,8 +210,8 @@ 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-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | |
| 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` | 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 | ⬜ | | |
@ -821,6 +821,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-12 | minor | LRC-LIB-CATALOG-IMPORT: smart collections import as smart albums where every rule maps; Lightroom folder stacks import as stacks |
| 2026-10-12 | minor | KEYC-PANELS: F8 toggles the right panel and Shift+Tab hides and restores all panels (🟡, was ⬜); KEYC-HELP: F1 opens help (✅); KEYC-RATING: `[` `]` are implemented, notes corrected; two View menu rows |
| 2026-10-12 | minor | LR-IMP-FORMATS: folder discovery includes SR2/SRF; MCP and CLI reuse the engine extension list. DSC-F828 remains preview-only; decoder support and row status unchanged |
| 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 |

View File

@ -67,6 +67,15 @@ 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: flag cycling (backtick), the filter-bar `\` key, Tab and F5.
**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); 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:**
- No Tab (side panels) or F5 yet; F6 and F7 are in PR #1078. F8 (right panel) and ⇧Tab (hide / show all
panels) work: ⇧Tab hides the side panels, Presets and filmstrip and brings back exactly those; on a Mac
@ -108,5 +117,6 @@ left panel, canvas, right panel, which is sensible but not tuned).
|---|---|---|
| 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h |
| 2026-10-12 | minor | Shortcuts: F8 and Shift+Tab panel keys; F1 help and `[` `]` rating were already implemented; UI command counts recounted |
| 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 |

View File

@ -143,6 +143,17 @@
disconnected), LightCraft says so and keeps thumbnails in memory only; nothing is written to the library drive
instead.
- Agents use `library.thumbnailCacheLocation {path?, reset?, existing?: move|leave|discard}`.
### 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. 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.
### Open Library knows a Lightroom catalog
- Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /