From a2911a508621273c0c6f4cfed0b8387824ce844e Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Tue, 13 Oct 2026 12:56:50 +0200 Subject: [PATCH 01/27] LR-VIEW-ZOOM: drag-scale follow-ups from review (#330) - A full set of slow drags forgets only the one remembered longest ago instead of all 256 (each forgotten pair costs one more slow full-size drag). - DragScale's doc says a slow drag is remembered for its photo only. - A host frame time over 1 s (a hidden, occluded or stalled window) counts as unknown, like no frame time at all, so it can't mark a drag slow. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/region.rs | 69 ++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 4fb2e587b..f02f80df5 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -107,14 +107,20 @@ const GIVE_UP_FACTOR: f64 = 2.0; /// The full-size drafts (after the first) a drag times before it may give up on full size. const GIVE_UP_AFTER: usize = 3; -/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts. +/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts; past +/// it, the one remembered longest ago is forgotten. const SLOW_DRAGS_KEPT: usize = 256; +/// A host frame time (ms) longer than this is not a texture upload but a window that was hidden, +/// occluded or stalled: it counts as unknown, like a frame time from no host at all. +const FRAME_MS_KNOWN: f64 = 1000.0; + /// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it /// ends (a change in the middle would make the resolution jump), unless its full-size drafts are /// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of /// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`] -/// (issue #330). Whether the GPU drew a render is not enough on its own: some views (a LUT +/// (issue #330). A slow drag is remembered for its photo only: the same control on another photo +/// (another size, other edits) starts at full size again and is timed afresh. Whether the GPU drew a render is not enough on its own: some views (a LUT /// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large /// draft costs a texture upload too, which only the time a draft took to show tells. #[derive(Clone, Debug, Default)] @@ -128,8 +134,8 @@ pub struct DragScale { /// The render time (ms) of a draft that reached the screen this frame: the time of the frame /// that uploads it is only known on the next one. pending: Option, - /// Drags whose full-size drafts went over budget. - slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>, + /// Drags whose full-size drafts went over budget, the one remembered longest ago first. + slow: std::collections::VecDeque<(lightcraft_catalog::PhotoId, String)>, } impl DragScale { @@ -165,7 +171,7 @@ impl DragScale { return; } if let Some(render) = self.pending.take() { - let frame = last_frame_ms.filter(|ms| ms.is_finite() && *ms >= 0.0).unwrap_or(0.0); + let frame = last_frame_ms.filter(|ms| ms.is_finite() && (0.0..=FRAME_MS_KNOWN).contains(ms)).unwrap_or(0.0); if self.samples.len() < 1024 { self.samples.push(render + frame); } @@ -189,7 +195,19 @@ impl DragScale { /// Remember drags of `control` on `photo` as too slow for full-size drafts (tests). #[cfg(test)] pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) { - self.slow.insert((photo, control.to_string())); + self.mark_slow((photo, control.to_string())); + } + + /// Remember a slow drag, forgetting the one remembered longest ago when the set is full (not + /// all of them: each forgotten pair costs one more slow full-size drag). + fn mark_slow(&mut self, key: (lightcraft_catalog::PhotoId, String)) { + if self.slow.contains(&key) { + return; + } + while self.slow.len() >= SLOW_DRAGS_KEPT { + self.slow.pop_front(); + } + self.slow.push_back(key); } /// The drag ended: remember it if its full-size drafts were over budget. @@ -197,10 +215,7 @@ impl DragScale { if let Some((key, _)) = self.current.take() && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS) { - if self.slow.len() >= SLOW_DRAGS_KEPT { - self.slow.clear(); - } - self.slow.insert(key); + self.mark_slow(key); } self.samples.clear(); self.seen = None; @@ -522,6 +537,40 @@ mod tests { assert_eq!(hostile.scale(drag, true, None), Some(1.0)); } + // Given more slow drags than are remembered, only the one remembered longest ago is forgotten: + // clearing them all would bring back a slow full-size drag for every pair + #[test] + fn a_full_slow_set_forgets_only_the_oldest_drag() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + for photo in 0..=SLOW_DRAGS_KEPT as u64 { + d.remember_slow(PhotoId(photo), "Exposure"); + } + assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None), Some(1.0), "the oldest is forgotten"); + d.scale(None, true, None); + for photo in [1, SLOW_DRAGS_KEPT as u64] { + assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None), Some(CPU_DRAFT_SCALE), "photo {photo}"); + d.scale(None, true, None); + } + d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(CPU_DRAFT_SCALE)); + } + + // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as + // unknown: fast renders don't mark the drag slow because the window wasn't drawing + #[test] + fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + d.scale(drag, true, None); + for key in 1..=6 { + d.observe(key, 6.0, Some(4000.0)); + } + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(1.0)); + } + // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), // the drag drops to the small draft after a few of them instead of at its end, and the next // drag starts small From 8d91abe60330d28e9ebce3e37e9170b93832be11 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Tue, 13 Oct 2026 13:33:38 +0200 Subject: [PATCH 02/27] LR-VIEW-ZOOM: a slow drag verdict needs two strikes and expires (#330) A busy moment (another app, a backup) could make a slider draft small for the rest of the session. DragScale keeps timing in wall-clock time, what the user waits for, but now: - a (photo, control) pair is slow only after two drags in a row over 20 ms (1.25x the 16 ms budget); the first makes it suspect, and a drag within 20 ms forgets the verdict; - a slow verdict lasts 30 s, then a drag tries full size again; a slow retry doubles the wait, up to 240 s (after WebRTC's CPU overuse detector: 40 s ramp-up delay, doubled after a failed up-switch, capped at 240 s). The time comes from egui's input clock, which the web build has too. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/detail.rs | 4 +- crates/ui-egui/src/region.rs | 319 ++++++++++++++++++++-------- 2 files changed, 235 insertions(+), 88 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index ba14091a7..6661ce92f 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -445,7 +445,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && lightcraft_engine::gpu::available(); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); - let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key)); + let now = ui.input(|i| i.time); + let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key), now); // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; @@ -471,7 +472,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.drag_scale.observe(key, ms, app.perf.host_frame_ms); } // a pinch changes the zoom every frame: keep the sizes of before it until it is quiet - let now = ui.input(|i| i.time); let plan = app.size_hold.apply(id, now, navigating, plan); if app.size_hold.holding(now) { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index f02f80df5..1c2c78407 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -107,22 +107,50 @@ const GIVE_UP_FACTOR: f64 = 2.0; /// The full-size drafts (after the first) a drag times before it may give up on full size. const GIVE_UP_AFTER: usize = 3; -/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts; past -/// it, the one remembered longest ago is forgotten. -const SLOW_DRAGS_KEPT: usize = 256; +/// The most (photo, control) pairs [`DragScale`] keeps a verdict on; past it, the one remembered +/// longest ago is forgotten. +const VERDICTS_KEPT: usize = 256; /// A host frame time (ms) longer than this is not a texture upload but a window that was hidden, /// occluded or stalled: it counts as unknown, like a frame time from no host at all. const FRAME_MS_KNOWN: f64 = 1000.0; +/// A drag counts against its (photo, control) pair only when its full-size drafts took longer than +/// this (ms): a quarter over the budget, so a drag that only just misses it keeps full size. +const VERDICT_MS: f64 = DRAFT_BUDGET_MS * 1.25; + +/// How long (s) a pair found slow drafts small before a drag of it tries full size again: the +/// machine may only have been busy. Each retry that is slow too doubles the wait, up to +/// [`RETRY_MAX_S`]. A retry costs at most a few slow drafts before the drag gives up on full size. +const RETRY_AFTER_S: f64 = 30.0; + +/// The longest wait (s) before a slow pair tries full size again. +const RETRY_MAX_S: f64 = 240.0; + +/// What [`DragScale`] remembers about the drags of one control on one photo. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Verdict { + /// One drag was over budget at full size: maybe a busy moment, so the next stays full size. + Suspect, + /// Two drags in a row were: drafts are small until `until` (the host's clock, s), then a drag + /// tries full size again; `wait` is how long this verdict holds (s). + Slow { until: f64, wait: f64 }, +} + /// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it /// ends (a change in the middle would make the resolution jump), unless its full-size drafts are -/// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of -/// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`] -/// (issue #330). A slow drag is remembered for its photo only: the same control on another photo -/// (another size, other edits) starts at full size again and is timed afresh. Whether the GPU drew a render is not enough on its own: some views (a LUT -/// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large -/// draft costs a texture upload too, which only the time a draft took to show tells. +/// far over budget. A drag renders at full size when the GPU draws the view and the same control +/// on this photo isn't known to be too slow at that size; otherwise at [`CPU_DRAFT_SCALE`] (issue +/// #330). Whether the GPU drew a render is not enough on its own: some views (a LUT profile, +/// refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large draft +/// costs a texture upload too, which only the time a draft took to show tells. +/// +/// A drag's time is wall-clock time, what the user waits for. So that a moment the machine was busy +/// (another app, a backup) can't make a slider draft small for the rest of the session, a pair is +/// slow only after two drags in a row over [`VERDICT_MS`], and only for a while: after +/// [`RETRY_AFTER_S`] a drag tries full size again, a fast one forgets the verdict and a slow one +/// doubles the wait. A verdict is for its photo only: the same control on another photo (another +/// size, other edits) is timed afresh. #[derive(Clone, Debug, Default)] pub struct DragScale { /// The drag running now: (photo, control) and its scale. @@ -134,26 +162,28 @@ pub struct DragScale { /// The render time (ms) of a draft that reached the screen this frame: the time of the frame /// that uploads it is only known on the next one. pending: Option, - /// Drags whose full-size drafts went over budget, the one remembered longest ago first. - slow: std::collections::VecDeque<(lightcraft_catalog::PhotoId, String)>, + /// Pairs whose full-size drafts went over budget, the one remembered longest ago first. + verdicts: std::collections::VecDeque<((lightcraft_catalog::PhotoId, String), Verdict)>, } impl DragScale { /// The draft scale for this frame, or `None` without a drag. `drag` is the photo and control /// being dragged; `gpu` whether the GPU draws this view; `shown` the key of the whole-frame - /// render on screen (a render from before the drag is not one of its drafts). - pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option) -> Option { + /// render on screen (a render from before the drag is not one of its drafts); `now` the host's + /// clock (s; egui's `input.time`). + pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option, now: f64) -> Option { let Some((photo, control)) = drag else { - self.finish(); + self.finish(now); return None; }; match &self.current { Some(((p, c), s)) if *p == photo && c == control => return Some(*s), - Some(_) => self.finish(), + Some(_) => self.finish(now), None => {} } let key = (photo, control.to_string()); - let s = if gpu && !self.slow.contains(&key) { 1.0 } else { CPU_DRAFT_SCALE }; + let small = matches!(self.verdict(&key), Some(Verdict::Slow { until, .. }) if now < until); + let s = if gpu && !small { 1.0 } else { CPU_DRAFT_SCALE }; self.current = Some((key, s)); self.seen = shown; Some(s) @@ -192,30 +222,58 @@ impl DragScale { } } - /// Remember drags of `control` on `photo` as too slow for full-size drafts (tests). + /// Remember drags of `control` on `photo` as too slow for full-size drafts, for good (tests). #[cfg(test)] pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) { - self.mark_slow((photo, control.to_string())); + self.set_verdict((photo, control.to_string()), Some(Verdict::Slow { until: f64::INFINITY, wait: RETRY_MAX_S })); } - /// Remember a slow drag, forgetting the one remembered longest ago when the set is full (not - /// all of them: each forgotten pair costs one more slow full-size drag). - fn mark_slow(&mut self, key: (lightcraft_catalog::PhotoId, String)) { - if self.slow.contains(&key) { - return; - } - while self.slow.len() >= SLOW_DRAGS_KEPT { - self.slow.pop_front(); - } - self.slow.push_back(key); + fn verdict(&self, key: &(lightcraft_catalog::PhotoId, String)) -> Option { + self.verdicts.iter().find(|(k, _)| k == key).map(|(_, v)| *v) } - /// The drag ended: remember it if its full-size drafts were over budget. - fn finish(&mut self) { + /// Set or (`None`) forget a pair's verdict. A new pair forgets the one remembered longest ago + /// when the list is full (not all of them: each forgotten pair costs one more slow drag). + fn set_verdict(&mut self, key: (lightcraft_catalog::PhotoId, String), verdict: Option) { + let at = self.verdicts.iter().position(|(k, _)| *k == key); + match (at, verdict) { + (Some(i), Some(v)) => { + if let Some(entry) = self.verdicts.get_mut(i) { + entry.1 = v; + } + } + (Some(i), None) => { + self.verdicts.remove(i); + } + (None, Some(v)) => { + while self.verdicts.len() >= VERDICTS_KEPT { + self.verdicts.pop_front(); + } + self.verdicts.push_back((key, v)); + } + (None, None) => {} + } + } + + /// The drag ended. A drag timed at full size updates its pair's verdict: over [`VERDICT_MS`] + /// a first time makes it suspect, a second slow for [`RETRY_AFTER_S`], and a slow retry + /// doubles the wait; within it, the verdict is forgotten. A drag never timed at full size (it + /// drafted small, or was too short) changes nothing. + fn finish(&mut self, now: f64) { if let Some((key, _)) = self.current.take() - && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS) + && let Some(median) = median_after_first(&self.samples) { - self.mark_slow(key); + let verdict = if median > VERDICT_MS { + let wait = match self.verdict(&key) { + None => None, + Some(Verdict::Suspect) => Some(RETRY_AFTER_S), + Some(Verdict::Slow { wait, .. }) => Some((wait * 2.0).min(RETRY_MAX_S)), + }; + Some(wait.map_or(Verdict::Suspect, |wait| Verdict::Slow { until: now + wait, wait })) + } else { + None + }; + self.set_verdict(key, verdict); } self.samples.clear(); self.seen = None; @@ -442,10 +500,10 @@ mod tests { fn a_drag_shrinks_the_draft_only_on_the_cpu() { use lightcraft_catalog::PhotoId; let mut gpu = DragScale::default(); - assert_eq!(gpu.scale(None, true, None), None, "no drag, no draft"); - assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None), Some(1.0)); + assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft"); + assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0)); let mut cpu = DragScale::default(); - assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE)); } // Given a drag that started before the GPU was ready, it keeps its size until it ends: the @@ -455,33 +513,120 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, false, None), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); - assert_eq!(d.scale(None, true, None), None); - assert_eq!(d.scale(drag, true, None), Some(1.0), "the next drag gets full size"); + assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size"); // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends - assert_eq!(d.scale(drag, false, None), Some(1.0)); + assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0)); } - // Given full-size drafts over the slider budget (a LUT profile or refined mask the CPU renders, - // a photo past a GPU limit, a slow GPU), the next drag of that control on that photo drafts - // small; other controls and photos keep full size + /// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`. + fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option { + let scale = d.scale(drag, true, None, now); + for key in 1..=6 { + d.observe(key, ms, None); + } + d.scale(None, true, None, now); + scale + } + + // Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask + // the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on + // that photo drafts small; once could be a busy moment. Other controls and photos keep full size #[test] - fn a_drag_over_budget_drafts_small_the_next_time() { + fn a_drag_over_budget_twice_drafts_small_the_next_time() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let exposure = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0)); + assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0)); d.observe(7, 400.0, None); // the render from before the drag: not a draft of it - for (key, ms) in [(8, 300.0), (9, 20.0), (10, 25.0), (11, 9.0)] { + for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] { d.observe(key, ms, None); d.observe(key, ms, None); // a render is timed once, however many frames show it } - assert_eq!(d.scale(exposure, true, None), Some(1.0), "over budget, but not far enough to give up mid-drag"); - assert_eq!(d.scale(None, true, None), None); - assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0)); - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(1.0)); + assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy"); + assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 2.0); + assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0)); + } + + // Given a pair found slow, it drafts small only for a while: then a drag tries full size + // again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict + #[test] + fn a_slow_verdict_expires_and_is_retried() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 0.0); + timed_drag(&mut d, drag, 25.0, 0.0); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over"); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow"); + let mut at = RETRY_AFTER_S; + let mut wait = RETRY_AFTER_S * 2.0; + for _ in 0..6 { + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again"); + at += wait; + wait = (wait * 2.0).min(RETRY_MAX_S); + } + assert_eq!(wait, RETRY_MAX_S, "the wait stops growing"); + assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size"); + assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s"); + } + + // Given a drag only just over budget, it doesn't count against the pair (it would flip between + // the two sizes); one clearly over does + #[test] + fn only_a_drag_clearly_over_budget_counts() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..4 { + timed_drag(&mut d, drag, 18.0, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget"); + d.scale(None, true, None, 4.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0); + assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE)); + } + + // Given one slow drag while the machine was busy, then fast ones, the slider keeps full size + #[test] + fn a_busy_moment_doesnt_make_a_slider_draft_small() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 40.0, 0.0); + for t in 1..=3 { + assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0)); + } + timed_drag(&mut d, drag, 40.0, 4.0); + assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one"); + } + + // Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it + // can't read tries full size + #[test] + fn a_hostile_clock_never_panics() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 100.0); + timed_drag(&mut d, drag, 25.0, 100.0); + assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow"); + d.scale(None, true, None, 50.0); + assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0)); + d.scale(None, true, None, f64::NAN); + for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { + timed_drag(&mut d, drag, 25.0, now); + } } // Given full-size drafts within budget (the first one, rebuilding the stage caches, may be @@ -491,18 +636,18 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); + d.scale(drag, true, None, 0.0); for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { d.observe(key, ms, None); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); // hostile times are ignored, never a panic for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { d.observe(key, ms, None); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); } // Given renders within budget whose frames (the texture upload) take it over, the drag counts @@ -512,29 +657,30 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); - for key in 1..=5 { - d.observe(key, 8.0, Some(12.0)); // the previous frame, which uploaded the last draft + for _ in 0..2 { + d.scale(drag, true, None, 0.0); + for key in 1..=5 { + d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft + } + d.observe(5, 8.0, Some(14.0)); + d.scale(None, true, None, 0.0); } - d.observe(5, 8.0, Some(12.0)); - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "8 ms renders + 12 ms frames: over 16 ms"); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget"); // the same renders without the host's frame times (headless) stay within budget let mut headless = DragScale::default(); - headless.scale(drag, true, None); - for key in 1..=6 { - headless.observe(key, 8.0, None); + headless.scale(drag, true, None, 0.0); + for _ in 0..2 { + timed_drag(&mut headless, drag, 8.0, 0.0); } - headless.scale(None, true, None); - assert_eq!(headless.scale(drag, true, None), Some(1.0)); + assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0)); // a hostile frame time counts as nothing, never a panic let mut hostile = DragScale::default(); - hostile.scale(drag, true, None); + hostile.scale(drag, true, None, 0.0); for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { hostile.observe(key, 8.0, Some(frame)); } - hostile.scale(None, true, None); - assert_eq!(hostile.scale(drag, true, None), Some(1.0)); + hostile.scale(None, true, None, 0.0); + assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0)); } // Given more slow drags than are remembered, only the one remembered longest ago is forgotten: @@ -543,17 +689,17 @@ mod tests { fn a_full_slow_set_forgets_only_the_oldest_drag() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); - for photo in 0..=SLOW_DRAGS_KEPT as u64 { + for photo in 0..=VERDICTS_KEPT as u64 { d.remember_slow(PhotoId(photo), "Exposure"); } - assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None), Some(1.0), "the oldest is forgotten"); - d.scale(None, true, None); - for photo in [1, SLOW_DRAGS_KEPT as u64] { - assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None), Some(CPU_DRAFT_SCALE), "photo {photo}"); - d.scale(None, true, None); + assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten"); + d.scale(None, true, None, 0.0); + for photo in [1, VERDICTS_KEPT as u64] { + assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}"); + d.scale(None, true, None, 0.0); } d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE)); } // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as @@ -563,33 +709,34 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); + d.scale(drag, true, None, 0.0); for key in 1..=6 { d.observe(key, 6.0, Some(4000.0)); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); } // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), - // the drag drops to the small draft after a few of them instead of at its end, and the next - // drag starts small + // the drag drops to the small draft after a few of them instead of at its end; after a second + // such drag, the next starts small #[test] fn a_drag_far_over_budget_drafts_small_before_it_ends() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); let mut scales = Vec::new(); for key in 1..=8 { d.observe(key, 190.0, None); - scales.push(d.scale(drag, true, None)); + scales.push(d.scale(drag, true, None, 0.0)); } // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 0.0); + assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment"); + assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE)); } // Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is From 6a66860ea9db6fa85206f59453a7e84dd1793a32 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Tue, 13 Oct 2026 14:11:27 +0200 Subject: [PATCH 03/27] LR-VIEW-ZOOM: hidden-window test drags twice so it catches a missing frame-time bound (#330) One slow drag only makes the pair suspect, so a single hidden-window drag passed even without the FRAME_MS_KNOWN upper bound. Two such drags followed by a full-size third fail (0.6) when the bound is removed. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/region.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 1c2c78407..23b21bc19 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -703,18 +703,21 @@ mod tests { } // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as - // unknown: fast renders don't mark the drag slow because the window wasn't drawing + // unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such + // drags, since one slow drag only makes the pair suspect #[test] fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None, 0.0); - for key in 1..=6 { - d.observe(key, 6.0, Some(4000.0)); + for t in 0..2u32 { + assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1); + for key in 1..=6 { + d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0)); + } + d.scale(None, true, None, f64::from(t)); } - d.scale(None, true, None, 0.0); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); + assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size"); } // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), From 9a0f0761057aabd468336289a351697f51ea1e1d Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 11 Oct 2026 16:53:11 +0200 Subject: [PATCH 04/27] =?UTF-8?q?LR-VIEW-CLIPPING:=20=E2=8C=A5-drag=20prev?= =?UTF-8?q?iews=20for=20Blacks,=20Whites,=20Exposure,=20Sharpening=20and?= =?UTF-8?q?=20NR=20(#937)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holding ⌥ (Option) while dragging a slider shows a diagnostic view in the loupe, as in Lightroom; releasing ⌥ or the slider brings the photo back and nothing is saved: - Blacks: shadow clipping (white; black where every channel is 0, the colour of the unclipped channels where only some are). - Whites / Exposure: highlight clipping (black; white where every channel is 255, the clipped channels' colour where only some are). - Sharpening Masking: the weight sharpening gets at each pixel, white on black. - Sharpening Amount / Radius / Detail, luminance NR: the image in grey. New `Overlay` variants (ShadowClip, HighlightClip, SharpenMask, Grey) are drawn by the shared `visualize::apply`, so CPU and GPU agree; the sharpening mask needs the detail planes and renders on the CPU (like HdrRange). The mask uses the same weight function as the render (`finish::sharpen_weight`). With a display profile the clipping views are tested in sRGB, like the histogram, and diagnostic views keep the photo's histogram instead of the view's. Tracker: LR-VIEW-CLIPPING ✅ → 🟡 (J only frames the photo, it doesn't paint clipped pixels) and LR-EDIT-DETAIL-SHARPEN ✅ → 🟡 (Radius and Detail don't reach the render). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/display.rs | 19 +++ crates/engine/src/media.rs | 15 ++- crates/gpu/src/lib.rs | 10 +- crates/pipeline/src/finish.rs | 37 +++++- crates/pipeline/src/lib.rs | 5 +- crates/pipeline/src/local.rs | 7 +- crates/pipeline/src/visualize.rs | 139 ++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/detail.rs | 41 ++++++- crates/ui-egui/src/tests_alt_preview.rs | 93 +++++++++++++++ crates/ui-egui/src/tests_masking.rs | 12 +- crates/ui-egui/src/tests_preview_limit.rs | 7 +- docs/parity-checklist.md | 23 ++-- 13 files changed, 368 insertions(+), 42 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 f47e099b6..8a91a6644 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -152,6 +152,25 @@ mod tests { } } + /// ⌥-drag views on a profiled display (issue #937): the histogram stays the photo's (not the + /// view'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::ShadowClip); + 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 6d1bfe0de..886c2f21c 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -968,11 +968,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 view 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/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 76fb0f2c1..89315a344 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -118,7 +118,8 @@ pub fn last_fallback() -> Option { #[derive(Clone, Debug, PartialEq, Eq)] pub enum Fallback { /// The GPU was not tried: it is unavailable, disabled or closing ([`unavailable_reason`]), or - /// the request is one only the CPU renders (high bit depth, soft proof, HDR range overlay). + /// the request is one only the CPU renders (high bit depth, soft proof, HDR range or sharpening + /// mask overlay). NotTried, /// The render was tried on the GPU and failed, for this reason (the same text /// [`last_fallback`] reports). The CPU renders it instead. @@ -439,10 +440,13 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R /// Whether the GPU renders requests of this kind at all (a given render can still fall back to /// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on -/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it, +/// the CPU, and so does Visualize HDR, which needs the float HDR render, and the sharpening mask, +/// which needs the detail planes. [`try_render`] follows it, /// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart. pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool { - depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange + depth == lightcraft_pipeline::OutputDepth::U8 + && !proofed + && !matches!(overlay, lightcraft_pipeline::Overlay::HdrRange | lightcraft_pipeline::Overlay::SharpenMask) } /// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index eb65d51a2..1fc68be00 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -5,7 +5,7 @@ use lightcraft_color::spline::{Lut1, MonotoneCurve}; use lightcraft_color::transfer::linear_to_srgb; use lightcraft_develop::{DevelopSettings, LocalAdjustments, Process, ToneCurve, VignetteStyle}; use lightcraft_geom::Point; -use lightcraft_raster::Rgba8; +use lightcraft_raster::{Plane, Rgba8}; use crate::colorops::ColorOps; use crate::geometry::Frame; @@ -14,6 +14,36 @@ use crate::output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc} use crate::tone::ToneMap; use crate::{Prepared, SourceInfo, for_rows}; +/// Sharpening's Masking as 0..1. +fn sharpen_masking(s: &DevelopSettings) -> f32 { + (s.detail.sharpen_masking / 100.0) as f32 +} + +/// How much sharpening applies at a pixel whose fine detail (log luminance minus its texture +/// blur) is `det`, with Masking `masking` (0..1): everywhere at 0, only on stronger edges above. +#[inline] +fn sharpen_weight(masking: f32, det: f32) -> f32 { + if masking > 0.0 { smooth(masking * 0.25, masking * 0.25 + 0.15, det.abs()) } else { 1.0 } +} + +/// [`sharpen_weight`] at every pixel of `p` (the view [`crate::Overlay::SharpenMask`] shows). +pub(crate) fn sharpen_weight_plane(p: &Prepared, s: &DevelopSettings) -> Plane { + let masking = sharpen_masking(s); + if masking <= 0.0 { + return Plane::filled(p.log_l.width, p.log_l.height, 1.0); + } + // the detail band the render sharpens (made here when sharpening is off) + let fresh; + let blur = match &p.texture_blur { + Some(b) if (b.width, b.height) == (p.log_l.width, p.log_l.height) => &**b, + _ => { + fresh = lightcraft_raster::blur::gaussian(&p.log_l, crate::local::texture_sigma(p.px_per_long)); + &fresh + } + }; + p.log_l.zip_map(blur, |l, b| sharpen_weight(masking, l - b)) +} + #[inline] fn smooth(e0: f32, e1: f32, x: f32) -> f32 { let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); @@ -244,7 +274,7 @@ impl FinishParams { tex, dehaze, sharpen: (s.detail.sharpen_amount / 150.0) as f32, - sharpen_mask: (s.detail.sharpen_masking / 100.0) as f32, + sharpen_mask: sharpen_masking(s), air: air_pre * gain, air_pre, gain, @@ -509,8 +539,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 }; - delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m; + delta += sp * 1.3 * det.clamp(-0.8, 0.8) * sharpen_weight(sharpen_mask, det); } } // local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 455cec022..c87efaf0f 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -663,8 +663,11 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, } /// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a -/// fresh evaluation. +/// fresh evaluation. For [`Overlay::SharpenMask`], the weight sharpening gets at each pixel. fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option { + if o == Overlay::SharpenMask { + return Some(finish::sharpen_weight_plane(prep, &plan.settings)); + } 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 679acabfe..b6180086c 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -488,6 +488,11 @@ pub struct PlaneSigmas { pub chroma: Option, } +/// Gaussian sigma of the texture / sharpening detail band at `px_per_long` pixels per long edge. +pub(crate) 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); @@ -508,7 +513,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/visualize.rs b/crates/pipeline/src/visualize.rs index 8f3cf1884..3f1ab19fc 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -9,12 +9,20 @@ //! - **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`]. +//! - **Clipping while ⌥-dragging** Blacks / Whites / Exposure ([`Overlay::ShadowClip`], +//! [`Overlay::HighlightClip`]): every output channel at 0 (or 255) shown, everything else blank, +//! so the clipped areas, and the channels that clip, are all that's left to see. +//! - **Sharpening mask while ⌥-dragging Masking** ([`Overlay::SharpenMask`]): the weight the +//! render gives sharpening at each pixel, white on black (the CPU renderer draws it: it needs the +//! detail planes). +//! - **Grey while ⌥-dragging** Sharpening or luminance Noise Reduction ([`Overlay::Grey`]): the +//! render in grey, so detail and noise are judged without colour. use std::borrow::Cow; use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; use lightcraft_color::transfer::srgb_to_linear; -use lightcraft_color::{REC2020, SRGB}; +use lightcraft_color::{LUMA_709, REC2020, SRGB}; use lightcraft_develop::DevelopSettings; use lightcraft_raster::{Plane, Rgba8}; @@ -39,6 +47,17 @@ 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 (⌥ on Blacks): white, black where every channel is 0, and where only some + /// are, the colour of the channels that aren't. + ShadowClip, + /// Highlight clipping (⌥ on Whites / Exposure): black, white where every channel is 255, and + /// where only some are, the colour of those channels. + HighlightClip, + /// Sharpening's weight after Masking (⌥ on Masking): white where it sharpens, black where + /// Masking holds it back. + SharpenMask, + /// The render in grey (⌥ on the Sharpening and luminance Noise Reduction sliders). + Grey, } /// [`Overlay::HdrRange`] colours (sRGB) for 0–1, 1–2, 2–3 and ≥ 3 stops above SDR white. @@ -125,6 +144,10 @@ impl Overlay { Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64), Overlay::AllMasks { view, color, opacity } => (5, pack_mask(0, view, color, opacity) as f64), Overlay::HdrRange => (4, 0.0), + Overlay::ShadowClip => (6, 0.0), + Overlay::HighlightClip => (7, 0.0), + Overlay::SharpenMask => (8, 0.0), + Overlay::Grey => (9, 0.0), } } @@ -143,6 +166,10 @@ impl Overlay { opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8, } } + 6 => Overlay::ShadowClip, + 7 => Overlay::HighlightClip, + 8 => Overlay::SharpenMask, + 9 => Overlay::Grey, _ => Overlay::None, } } @@ -156,9 +183,19 @@ impl Overlay { Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity), Overlay::AllMasks { view, color, opacity } => 5 << 60 | pack_mask(0, view, color, opacity), Overlay::HdrRange => 0x4000, + Overlay::ShadowClip => 0x5000, + Overlay::HighlightClip => 0x6000, + Overlay::SharpenMask => 0x7000, + Overlay::Grey => 0x8000, } } + /// The ⌥-drag clipping views, which test the sRGB values the histogram counts (a display + /// render draws them in sRGB, then converts). + pub fn is_clipping(self) -> bool { + matches!(self, Overlay::ShadowClip | Overlay::HighlightClip) + } + /// 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 { @@ -181,12 +218,21 @@ 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`]); `masks` is the set of -/// alphas an [`Overlay::AllMasks`] shows, one per visible mask with components. +/// an [`Overlay::Mask`] shows, or the sharpening weight of an [`Overlay::SharpenMask`], at the +/// image's size (see [`Overlay::mask`]); `masks` is the set of alphas an [`Overlay::AllMasks`] +/// shows, one per visible mask with components. pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, masks: Option<&[Plane]>) { match o { // drawn by the renderer from the HDR rendition ([`hdr_range`]) Overlay::None | Overlay::HdrRange => {} + Overlay::ShadowClip => clipping(img, false), + Overlay::HighlightClip => clipping(img, true), + Overlay::Grey => grey(img), + Overlay::SharpenMask => { + if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) { + mask_view(img, a, MaskView::WhiteOnBlack, [255; 3], 100); + } + } Overlay::PointColorRange(i) => { if let Some(p) = plan.settings.point_colors.get(i as usize) { point_color_range(img, &PointK::new(p)); @@ -224,7 +270,7 @@ pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opa for (x, px) in row.iter_mut().enumerate() { let a = a.data[y * w + x].clamp(0.0, 1.0); let p = [px[0] as f32, px[1] as f32, px[2] as f32]; - let grey = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + let grey = luma8(*px); let out: [f32; 3] = match view { MaskView::Color => std::array::from_fn(|c| p[c] + (col[c] - p[c]) * a * op), MaskView::ColorOnBw => std::array::from_fn(|c| grey + (col[c] - grey) * a * op), @@ -238,6 +284,25 @@ pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opa }); } +/// Rec. 709 luma of an 8-bit pixel (0..255). +fn luma8(p: [u8; 4]) -> f32 { + LUMA_709[0] * p[0] as f32 + LUMA_709[1] * p[1] as f32 + LUMA_709[2] * p[2] as f32 +} + +/// The clipping view: with `high`, each channel at 255 on, the rest off (black where nothing +/// clips); otherwise each channel at 0 off, the rest on (white where nothing clips). +fn clipping(img: &mut Rgba8, high: bool) { + let on = move |v: u8| if (high && v == 255) || (!high && v != 0) { 255 } else { 0 }; + img.map_in_place(|p| [on(p[0]), on(p[1]), on(p[2]), 255]); +} + +fn grey(img: &mut Rgba8) { + img.map_in_place(|p| { + let g = luma8(p).round().clamp(0.0, 255.0) as u8; + [g, g, g, 255] + }); +} + /// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge. const SPOT_SIGMA: f64 = 0.006; @@ -255,7 +320,7 @@ fn spots(img: &mut Rgba8, t: u8, ppl: f64) { } let l = lightcraft_raster::Plane::from_fn(w, h, |x, y| { let p = img.data[y * w + x]; - (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) / 255.0 + luma8(p) / 255.0 }); let b = lightcraft_raster::blur::gaussian(&l, (SPOT_SIGMA * ppl).max(0.8) as f32); let thr = spot_threshold(t); @@ -275,7 +340,7 @@ fn point_color_range(img: &mut Rgba8, k: &PointK) { let c = std::array::from_fn(|r| m[r][0] * lin[0] + m[r][1] * lin[1] + m[r][2] * lin[2]); let [l, ch, h] = lab_to_lch(oklab_from_2020(c)); let a = k.weight(l, ch, h); - let grey = 0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32; + let grey = luma8(*px); for c in 0..3 { px[c] = (grey + (px[c] as f32 - grey) * a).round().clamp(0.0, 255.0) as u8; } @@ -296,7 +361,7 @@ pub fn hdr_range(img: &mut Rgba8, hdr: &crate::DeepImage, luma: [f32; 3]) { let i = (y * w + x) * 3; let Some(c) = v.get(i..i + 3) else { continue }; let lum = luma[0] * c[0] + luma[1] * c[1] + luma[2] * c[2]; - let grey = (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) * 0.55; + let grey = luma8(*p) * 0.55; if lum > 1.0 { let stops = lum.log2(); let band = HDR_BANDS[(stops as usize).min(HDR_BANDS.len() - 1)]; @@ -343,6 +408,66 @@ mod tests { assert_eq!(Overlay::from_parts(k, v), Overlay::Spots(37)); } + #[test] + fn clipping_views_show_only_the_clipped_channels() { + // left to right: black, a red that clips only in red, mid grey, white + let src = Rgb32f::from_fn(40, 10, |x, _| match x / 10 { + 0 => [0.0; 3], + 1 => [0.9, 0.02, 0.02], + 2 => [0.18; 3], + _ => [8.0; 3], + }); + let (info, s) = (SourceInfo::default(), DevelopSettings::default()); + let shot = |o: Overlay| render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(40, 10) }); + let plain = shot(Overlay::None).image; + let at = |i: &Rgba8, x: usize| i.data[5 * 40 + x]; + assert_eq!((at(&plain, 5), at(&plain, 35)), ([0, 0, 0, 255], [255; 4]), "the scene clips at both ends"); + assert_eq!(at(&plain, 15)[0], 255, "the red clips in red"); + let hi = shot(Overlay::HighlightClip); + assert_eq!([5, 15, 25, 35].map(|x| at(&hi.image, x)), [[0, 0, 0, 255], [255, 0, 0, 255], [0, 0, 0, 255], [255; 4]]); + let lo = shot(Overlay::ShadowClip); + assert_eq!(at(&lo.image, 5), [0, 0, 0, 255], "black where every channel clips"); + assert_eq!([25, 35].map(|x| at(&lo.image, x)), [[255; 4]; 2], "white where none does"); + // only the view changes: the histogram is the photo's + assert_eq!(hi.histogram.clipping(), shot(Overlay::None).histogram.clipping()); + for o in [Overlay::ShadowClip, Overlay::HighlightClip, Overlay::SharpenMask, Overlay::Grey] { + let (k, v) = o.to_parts(); + assert_eq!(Overlay::from_parts(k, v), o); + assert_ne!(o.key(), Overlay::None.key()); + } + } + + #[test] + fn sharpen_mask_view_follows_masking() { + // a hard vertical edge between two flat halves + let src = Rgb32f::from_fn(200, 100, |x, _| if x < 100 { [0.05; 3] } else { [0.4; 3] }); + let info = SourceInfo::default(); + let mut s = DevelopSettings::default(); + s.detail.sharpen_amount = 60.0; + let shot = + |s: &DevelopSettings| render(&src, &info, s, &RenderRequest { overlay: Overlay::SharpenMask, ..RenderRequest::fit(200, 100) }).image; + let at = |i: &Rgba8, x: usize| i.data[50 * 200 + x][0]; + // Masking 0: sharpening applies everywhere + let open = shot(&s); + assert!(open.data.iter().all(|p| *p == [255; 4]), "all white at Masking 0"); + // Masking 100: only the edge is sharpened + s.detail.sharpen_masking = 100.0; + let edge = shot(&s); + assert_eq!((at(&edge, 20), at(&edge, 180)), (0, 0), "flat areas held back"); + assert!(at(&edge, 100).max(at(&edge, 99)) > 200, "the edge sharpened: {}", at(&edge, 100)); + // the mask shows with sharpening off too + s.detail.sharpen_amount = 0.0; + assert_eq!(shot(&s).data, edge.data); + } + + #[test] + fn grey_view_drops_the_colour() { + let src = Rgb32f::from_fn(8, 8, |_, _| [0.4, 0.1, 0.05]); + let req = RenderRequest { overlay: Overlay::Grey, ..RenderRequest::fit(8, 8) }; + let v = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &req).image; + assert!(v.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[0] > 0)); + } + #[test] fn point_color_range_keeps_the_selected_colour_only() { // left half orange, right half blue diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..f9577b224 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,6 +54,8 @@ mod tests_album_picker; #[cfg(test)] mod tests_album_remove; #[cfg(test)] +mod tests_alt_preview; +#[cfg(test)] mod tests_appearance; #[cfg(test)] mod tests_before_after; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 68b5346e3..2bae040ba 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -320,11 +320,11 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { /// Whether an overlay can be rendered on a region tile without changing what the tile means. /// Mask overlays (one mask, or every visible mask at once) are evaluated from the same mask -/// alphas as the full render; the other diagnostic overlays either need whole-frame context or -/// deliberately replace the loupe. +/// alphas as the full render, and the clipping and grey views work per pixel of it; the other +/// diagnostic overlays need whole-frame context or deliberately replace the loupe. pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { use lightcraft_pipeline::Overlay; - matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. }) + matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. } | Overlay::ShadowClip | Overlay::HighlightClip | Overlay::Grey) } /// What a window render of the loupe needs to know about the view it belongs to. @@ -518,7 +518,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); - let overlay = view_overlay(app, &d); + let overlay = view_overlay(app, &d, ui.input(|i| i.modifiers.alt && i.pointer.primary_down())); // a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`; // `ready` first: `available` would wait for a device still being created) let gpu = interacting @@ -1338,13 +1338,42 @@ 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, +/// The view holding ⌥ (Option) while dragging slider `control` shows in the loupe, as in Lightroom +/// (issue #937): clipping on Blacks (shadows) and Whites / Exposure (highlights), the sharpening +/// mask on Masking, the image in grey on the other Sharpening and the luminance Noise Reduction +/// sliders. Releasing ⌥ or the slider brings the photo back; nothing is saved. +pub(crate) fn slider_preview(control: &str) -> Option { + SLIDER_PREVIEWS.iter().find(|(id, _)| *id == control).map(|(_, o)| *o) +} + +/// The controls with a [`slider_preview`], and the view each shows. +pub(crate) const SLIDER_PREVIEWS: [(&str, lightcraft_pipeline::Overlay); 10] = { + use lightcraft_pipeline::Overlay::{Grey, HighlightClip, ShadowClip, SharpenMask}; + [ + ("light.blacks", ShadowClip), + ("light.whites", HighlightClip), + ("light.exposure", HighlightClip), + ("detail.sharpenMasking", SharpenMask), + ("detail.sharpenAmount", Grey), + ("detail.sharpenRadius", Grey), + ("detail.sharpenDetail", Grey), + ("detail.nrLuminance", Grey), + ("detail.nrDetail", Grey), + ("detail.nrContrast", Grey), + ] +}; + +/// The diagnostic overlay the loupe shows (the ⌥-drag preview of the slider being dragged while +/// `alt`: ⌥ is held with the button down; the selected mask, Point Color's visualized range, /// Visualize Spots). -pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { +pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings, alt: bool) -> lightcraft_pipeline::Overlay { use lightcraft_pipeline::{MaskView, Overlay}; if app.ui.fullscreen { return Overlay::None; } + if alt && let Some(o) = app.ui.dragging_control.as_deref().and_then(slider_preview) { + return o; + } // Show every visible mask's region at once (Ctrl+Shift+O / Cmd+Shift+O), ahead of the selected-mask overlay // so the shortcut is not shadowed by the always-on single-mask overlay. if app.ui.right == RightPanel::Masking && app.ui.mask_overlay_all && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) { 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 000000000..24abcd7ce --- /dev/null +++ b/crates/ui-egui/src/tests_alt_preview.rs @@ -0,0 +1,93 @@ +//! Headless tests of the ⌥-drag previews (issue #937): holding ⌥ (Option) while dragging Blacks, +//! Whites, Exposure or a Sharpening / Noise Reduction slider shows a diagnostic view in the loupe. + +use std::time::Duration; + +use egui::{Event, Modifiers, PointerButton}; +use lightcraft_pipeline::Overlay; +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::detail::{SLIDER_PREVIEWS, slider_preview}; +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 ⌥"); + // ⌥ 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)); + // ⌥ 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}"); +} + +#[test] +fn each_slider_has_its_preview() { + assert_eq!(slider_preview("light.blacks"), Some(Overlay::ShadowClip)); + for c in ["light.whites", "light.exposure"] { + assert_eq!(slider_preview(c), Some(Overlay::HighlightClip), "{c}"); + } + assert_eq!(slider_preview("detail.sharpenMasking"), Some(Overlay::SharpenMask)); + for c in ["detail.sharpenAmount", "detail.sharpenRadius", "detail.sharpenDetail", "detail.nrLuminance", "detail.nrDetail", "detail.nrContrast"] { + assert_eq!(slider_preview(c), Some(Overlay::Grey), "{c}"); + } + for c in ["light.contrast", "color.saturation", "detail.nrColor"] { + assert_eq!(slider_preview(c), None, "{c}"); + } + // every mapped id is a real control, once (a rename would silently drop its preview) + for (i, (c, _)) in SLIDER_PREVIEWS.iter().enumerate() { + assert!(lightcraft_develop::controls::find(c).is_some(), "{c}"); + assert!(!SLIDER_PREVIEWS[..i].iter().any(|(d, _)| d == c), "{c} twice"); + } +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index bbea5356e..5e7beba9b 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -159,23 +159,23 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() { let d = develop(&h); assert!(d.masks[0].adjust.exposure > 0.0); assert!(d.masks[0].visible, "hiding the overlay must not disable the mask"); - assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None); + assert_eq!(crate::panels::detail::view_overlay(&h.app, &d, false), Overlay::None); let mask = d.masks[0].clone(); let r = h.request("ui.key", json!({"key": "o"}), T); assert_eq!(r["ok"], true, "{r}"); assert!(h.app.ui.mask_overlay, "O can restore the overlay"); assert_eq!(develop(&h).masks[0], mask, "toggling the overlay leaves the mask and its effect unchanged"); - assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::Mask { .. })); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::Mask { .. })); // Ctrl+Shift+O / Cmd+Shift+O shows every visible mask's region at once (the new command), ahead of the selected // mask overlay so it is not shadowed by the always-on single-mask overlay. exec(&mut h, "view.maskOverlayAll", json!({})); assert!(h.app.ui.mask_overlay_all, "the all-masks overlay command turns it on"); - assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::AllMasks { .. })); exec(&mut h, "view.maskOverlayAll", json!({})); assert!(!h.app.ui.mask_overlay_all, "the all-masks overlay command toggles it off"); - assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::AllMasks { .. })); // Failed edits and global adjustments must not hide the mask overlay. assert!(h.app.run("mask.adjust", json!({"id": 999, "values": {"exposure": 1.0}})).is_err()); @@ -274,12 +274,12 @@ fn mask_overlay_keys_and_pins() { assert_eq!(h.app.session.active_mask, Some(2)); // the loupe asks the renderer for the selected mask's overlay let d = develop(&h); - let o = crate::panels::detail::view_overlay(&h.app, &d); + let o = crate::panels::detail::view_overlay(&h.app, &d, false); assert_eq!(o, Overlay::Mask { id: 2, view: MaskView::Color, color: [230, 30, 40], opacity: 50 }); // O toggles it, Shift+O cycles the colour (and leaves the crop overlay alone) h.request("ui.key", json!({"key": "o"}), T); assert!(!h.app.ui.mask_overlay); - assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None); + assert_eq!(crate::panels::detail::view_overlay(&h.app, &d, false), Overlay::None); h.request("ui.key", json!({"key": "o"}), T); let crop = h.app.ui.crop_overlay; let colour = h.app.ui.mask_overlay_color; diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index dfddb9c4f..4be0ad35f 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -212,7 +212,10 @@ mod in_the_loupe { h.settle(SETTLE); let id = h.app.session.active().expect("a photo"); let d = h.app.session.develop_of(id).expect("settings"); - assert!(matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::Mask { .. }), "the mask overlay is shown"); + assert!( + matches!(crate::panels::detail::view_overlay(&h.app, &d, false), lightcraft_pipeline::Overlay::Mask { .. }), + "the mask overlay is shown" + ); assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "showing the overlay keeps zoom and pan"); let main = rendered_long_edge(&h); assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); @@ -250,7 +253,7 @@ mod in_the_loupe { let id = h.app.session.active().expect("a photo"); let d = h.app.session.develop_of(id).expect("settings"); assert!( - matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::AllMasks { .. }), + matches!(crate::panels::detail::view_overlay(&h.app, &d, false), lightcraft_pipeline::Overlay::AllMasks { .. }), "the all-masks overlay is shown" ); let main = rendered_long_edge(&h); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 043aebb50..8c1fa715e 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-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (⌥-drag previews on Blacks / Whites / Exposure / Sharpening / luminance NR, #937; LR-VIEW-CLIPPING and LR-EDIT-DETAIL-SHARPEN downgraded to 🟡: J doesn't paint clipped pixels, Radius / Detail unused; ⌥-drag previews on Blacks / Whites / Exposure / Sharpening / luminance NR, #937; LR-VIEW-CLIPPING and LR-EDIT-DETAIL-SHARPEN downgraded to 🟡: J doesn't paint clipped pixels, Radius / Detail unused; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); 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 @@ -28,10 +28,10 @@ cited here still exists. Formerly docs/parity.md. |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) | | B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | -| C. Views & navigation (VIEW) | 15 | 2 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | +| C. Views & navigation (VIEW) | 14 | 3 | 1 | 0 | 7/9 (78%) | 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) | 44 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | +| F. Edit panel — global adjustments (EDIT) | 43 | 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) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | | Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | -| **Total** | 418 | 62 | 59 | 35 | 193/201 (96%) | 148/161 (92%) | +| **Total** | 416 | 64 | 59 | 35 | 191/201 (95%) | 148/161 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 539 in-scope rows — P0 98.0% of 201 · P1 95.0% of 161 · P2 55.7% of 176. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.1%** of 539 in-scope rows — P0 97.5% of 201 · P1 95.0% of 161 · P2 55.7% of 176. ## Top gaps and shortcut notes @@ -140,7 +140,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | -| LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | +| LR-VIEW-CLIPPING | Clipping indicators | P0 | 🟡 | `cmd:view.clipping`, `crates/ui-egui/src/panels/detail.rs` (`clipping_overlay`, `slider_preview`) | J only frames the photo red (highlights) / blue (shadows) when any pixel clips; it doesn't paint the clipped pixels. ⌥-dragging Blacks / Whites / Exposure shows the clipped pixels and channels (`Overlay::ShadowClip` / `HighlightClip`, issue #937) | | LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | | LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | |, `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet; | @@ -180,12 +180,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` | ⌥-drag: highlight clipping view (#937) | | 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-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | ⌥-drag: highlight clipping view, black where nothing clips (#937) | +| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | ⌥-drag: shadow clipping view, white where nothing clips (#937) | | 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`) | | @@ -206,8 +206,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` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960) | +| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | 🟡 | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | ⌥-drag: Masking shows the sharpening mask white on black, the other sliders the image in grey (#937). Radius and Detail are saved but the render doesn't use them yet (fixed band at 0.18% of the long edge, `local::texture_sigma`) | +| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960); ⌥-drag: the image in grey (#937) | | LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | preview-size independent like luminance NR (#960) | | 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 | ⬜ | | | @@ -871,6 +871,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LR-META-INFO: Nikon DSLR raws show their lens from the maker note; status unchanged (✅) | | 2026-10-13 | patch | LR-IMP-LOCAL: browsing a Local folder again forgets the untouched records of files deleted or moved by another app (issue #1392); status unchanged | | 2026-10-13 | minor | LR-MERGE-PANO: a panorama keeps its photos' resolution (up to 512 MP / 65,000 px) instead of a fixed 40 MP, held to what fits in memory (issue #1455); status unchanged | +| 2026-10-11 | minor | ⌥-drag previews (issue #937) noted on LR-EDIT-LIGHT-EXPOSURE / WHITES / BLACKS and LR-EDIT-DETAIL-NR; LR-VIEW-CLIPPING ✅ → 🟡 (J frames the photo, doesn't paint clipped pixels), LR-EDIT-DETAIL-SHARPEN ✅ → 🟡 (Radius and Detail don't reach 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 | From c990dec0f0f15acb345f9340865eabe24a16272c Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Thu, 15 Oct 2026 11:27:07 +0200 Subject: [PATCH 05/27] LR-VIEW-ZOOM: tracker row describes the two-strike, expiring slow-drag verdict (#330) Co-Authored-By: Claude Opus 5.5 --- docs/parity-checklist.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..b2d471526 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-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **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 @@ -131,7 +131,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s; a drag within 20 ms forgets it. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | LR-VIEW-ZOOM: a slow slider-drag verdict needs two drags over 20 ms and expires after 30 s (doubling to 240 s); hidden-window frame times are ignored; the slow set forgets its oldest entry, not all (issue #330); status unchanged | | 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) | | 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | From 54c6c43b07eb6944fcd19d2741a2ad4b30652d11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:30:07 +0000 Subject: [PATCH 06/27] #1927: Constrain Crop tightens the stored crop Constrain Crop only applied at render time, and the Crop tool shows the uncropped view with the stored crop, so in the Crop panel (where the checkbox is) ticking it or moving a Geometry slider changed nothing. Session::set_develop now shrinks the stored crop about its centre until it fits the warped photo when Constrain Crop is turned on, or when an edit changes the warp (Geometry, Upright, lens corrections, section eyes) while it is on. A crop edit itself is kept as drawn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK --- crates/engine/src/lib.rs | 35 ++++++++++- crates/engine/src/tests_constrain_crop.rs | 72 +++++++++++++++++++++++ docs/parity-checklist.md | 2 +- docs/whats-new.md | 9 +++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/tests_constrain_crop.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a282ce3e..f472d7b60 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -874,7 +874,7 @@ impl Session { /// undo step; otherwise it is committed with a history entry. pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> { let now = (self.clock)(); - let settings = Arc::new(settings); + let settings = Arc::new(self.fit_constrained_crop(id, settings)); if let Some(i) = &self.interaction && i.photo == id { @@ -886,6 +886,37 @@ impl Session { self.commit(label, op) } + /// "Constrain Crop" keeps the stored crop inside the warped photo, as Lightroom does (issue + /// #1927): turning it on, or an edit that changes the warp (Geometry, Upright, lens corrections, + /// their sections' eyes) while it is on, shrinks the crop about its centre until no blank corner + /// is left, so the Crop tool shows what the render keeps. An edit of the crop itself is left as + /// drawn (the render still constrains it). + fn fit_constrained_crop(&self, id: PhotoId, mut d: DevelopSettings) -> DevelopSettings { + if !d.geometry.constrain_crop { + return d; + } + let Some(photo) = self.catalog.photo(id) else { return d }; + let before = &photo.develop; + let turned_on = !before.geometry.constrain_crop; + let warp_changed = before.geometry != d.geometry + || before.optics != d.optics + || before.orientation != d.orientation + || before.disabled_sections != d.disabled_sections; + let crop_edited = before.crop.geometry != d.crop.geometry; + if !(turned_on || (warp_changed && !crop_edited)) { + return d; + } + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + photo.width.max(1) as usize, + photo.height.max(1) as usize, + &d, + true, + photo.embedded_lens.as_ref(), + ); + d.crop.geometry.rect = frame.crop.rect; + d + } + /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the /// other selected photos: only the settings that changed; never spot removal or red eye (they /// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor @@ -1242,6 +1273,8 @@ mod tests_albums_local; #[cfg(test)] mod tests_color; #[cfg(test)] +mod tests_constrain_crop; +#[cfg(test)] mod tests_crop_angle; #[cfg(test)] mod tests_denoise; diff --git a/crates/engine/src/tests_constrain_crop.rs b/crates/engine/src/tests_constrain_crop.rs new file mode 100644 index 000000000..37c6d734d --- /dev/null +++ b/crates/engine/src/tests_constrain_crop.rs @@ -0,0 +1,72 @@ +//! Constrain Crop changes the stored crop, as in Lightroom (issue #1927): with it on, the crop the +//! Crop tool shows (the stored one) never takes in the blank corners a Geometry warp leaves, so +//! ticking it, or moving a Geometry slider while it is ticked, visibly tightens the crop. + +use lightcraft_geom::{CropGeometry, Rect}; +use lightcraft_pipeline::geometry::Frame; +use serde_json::json; + +use crate::Session; + +fn crop(s: &Session) -> CropGeometry { + s.develop_of(s.active().unwrap()).unwrap().crop.geometry +} + +/// Does the stored crop already fit the warped photo (the render's constraint would leave it alone)? +fn fits(s: &Session) -> bool { + let p = s.catalog.photo(s.active().unwrap()).unwrap(); + let mut d = (*p.develop).clone(); + d.geometry.constrain_crop = true; + let f = Frame::with_lens(p.width as usize, p.height as usize, &d, true, p.embedded_lens.as_ref()); + let (a, b) = (f.crop.rect, p.develop.crop.geometry.rect); + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9) +} + +fn smaller(a: Rect, b: Rect) -> bool { + a.width() < b.width() - 1e-3 && a.height() < b.height() - 1e-3 +} + +fn constrain(s: &mut Session, on: bool) { + s.execute("develop.merge", &json!({"settings": {"geometry": {"constrain_crop": on}}, "label": "Constrain Crop"})).unwrap(); +} + +fn vertical(s: &mut Session, v: f64) { + s.execute("develop.set", &json!({"control": "geometry.vertical", "value": v})).unwrap(); +} + +#[test] +fn a_geometry_slider_tightens_the_crop_while_constrained() { + let mut s = Session::with_demo(); + let full = crop(&s).rect; + constrain(&mut s, true); + assert_eq!(crop(&s).rect, full, "no warp yet: nothing to cut off"); + vertical(&mut s, -60.0); + assert!(smaller(crop(&s).rect, full), "the stored crop shrank: {:?}", crop(&s).rect); + assert!(fits(&s), "and holds no blank corner"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(crop(&s).rect, full, "undo puts the crop back with the slider"); +} + +#[test] +fn ticking_constrain_after_the_warp_tightens_the_crop() { + let mut s = Session::with_demo(); + let full = crop(&s).rect; + vertical(&mut s, -60.0); + assert_eq!(crop(&s).rect, full, "unconstrained: the crop keeps the blank corners"); + assert!(!fits(&s)); + constrain(&mut s, true); + assert!(smaller(crop(&s).rect, full) && fits(&s), "{:?}", crop(&s).rect); + // unticking keeps the tighter crop, as in Lightroom + let tight = crop(&s).rect; + constrain(&mut s, false); + assert_eq!(crop(&s).rect, tight); +} + +#[test] +fn a_crop_drawn_by_hand_is_kept_as_drawn() { + let mut s = Session::with_demo(); + constrain(&mut s, true); + vertical(&mut s, -60.0); + s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the crop edit itself is not refitted"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..3504eb77d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -220,7 +220,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | | LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | -| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | +| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`), `crates/engine/src/lib.rs` (`fit_constrained_crop`) | Ticking it, or changing Geometry, Upright or lens corrections while it is ticked, shrinks the stored crop about its centre until no blank corner is left, so the Crop tool shows it (it used to apply at render time only, outside the Crop tool: issue #1927; tests in `crates/engine/src/tests_constrain_crop.rs`). A crop drawn by hand is kept as drawn; unticking keeps the tighter crop | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | | LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | diff --git a/docs/whats-new.md b/docs/whats-new.md index 97e1367a8..0eabf447b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,14 @@ # What's new in LightCraft +## 2026-10-15 + +### Constrain Crop tightens the crop you see +- Geometry ▸ Constrain Crop now changes the crop itself, as in Lightroom (issue #1927). Ticking it, or moving a + Geometry slider, Upright or a lens correction while it is ticked, shrinks the crop about its centre until the blank + corners left by the correction are gone, and the Crop tool shows the tighter crop. Before, it only applied when the + photo was shown outside the Crop tool, so in the Crop panel, where the checkbox is, nothing seemed to change. A crop + you draw by hand is kept as drawn, and unticking keeps the tighter crop. + ## 2026-10-14 ### Lightroom radial masks on the right side From 50690745e82a5da8ecdd6ca714a07dcfc7675702 Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:40:09 +0000 Subject: [PATCH 07/27] Library: a picked photo's flag is drawn filled (#1013) The Pick flag on thumbnails, in Compare, in the loupe and on the Pick button was the same outline as the button's idle state, so a pick was hard to tell at a glance. A new filled pennant (`Icon::FlagPickFilled`) marks a pick wherever a photo's own flag is shown; buttons, filters and the reject flag keep their outlines. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh --- crates/ui-egui/src/icons.rs | 11 +++++++++++ crates/ui-egui/src/panels/bottombar.rs | 2 ++ crates/ui-egui/src/panels/compare.rs | 2 +- crates/ui-egui/src/panels/detail.rs | 2 +- crates/ui-egui/src/panels/grid.rs | 2 +- docs/parity-checklist.md | 2 +- 6 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 15434237a..0ef3e0448 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -37,6 +37,9 @@ pub enum Icon { StarFilled, Circle, FlagPick, + /// The Pick flag a photo carries: pennant filled, so a pick reads at a glance (#1013). The + /// outlined [`Icon::FlagPick`] stays for buttons and filters. + FlagPickFilled, FlagReject, Gear, Filmstrip, @@ -387,6 +390,14 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.line(&[(5.0, 17.0), (5.0, 3.5)]); pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); } + FlagPickFilled => { + pen.line(&[(5.0, 17.0), (5.0, 3.5)]); + // the notched pennant is concave: two convex halves meet at the notch, overlapping + // along the pole side so their anti-aliased edges leave no seam + pen.fill(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (5.0, 7.5)], color); + pen.fill(&[(5.0, 6.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)], color); + pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); + } FlagReject => { pen.line(&[(5.0, 17.0), (5.0, 3.5)]); pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 62cbe8968..69b76bcb8 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -132,6 +132,8 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: } else { t.icon }; + // the Pick button shows the flag the photo carries: filled while it is a pick + let icon = if on && f == Flag::Pick { Icon::FlagPickFilled } else { icon }; paint(pill_ui.painter(), r.shrink(3.0), icon, c); if resp.clicked() { let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}); diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 6c1b5ba18..75e1e91f7 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -345,7 +345,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo x += 5.0 * 13.0 + 4.0; let fr = Rect::from_min_size(pos2(x, cap.center().y - 7.0), vec2(14.0, 14.0)); match photo.flag { - Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPick, t.pick), + Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPickFilled, t.pick), Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject), _ => {} } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 44427565e..3f9f61dc9 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2696,7 +2696,7 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: } x += ph.rating as f32 * 9.0 + 2.0; match ph.flag { - Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPick, t.pick), + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), Flag::None => {} } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index c6365b784..f29e35b8d 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -709,7 +709,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } x += photo.rating as f32 * 13.0 + 4.0; match photo.flag { - Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPick, t.pick), + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagReject, t.reject), Flag::None => {} } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..52b74a897 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -103,7 +103,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | | LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | -| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file | +| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file; a pick shows as a filled flag on thumbnails, in Compare, in the loupe and on the Pick button, a reject outlined (#1013) | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | From 76689a563a96e0b3be142feb4bc2645e9ad17557 Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:51:16 +0000 Subject: [PATCH 08/27] Crop: a fresh crop starts locked to the photo's own shape (#991) With no aspect ever chosen, every crop started free, so the Lock had to be switched on photo after photo. Nothing chosen yet now means locked to Original, as Lightroom starts; switching the Lock off once is remembered as free, and an explicit ratio is remembered as before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh --- crates/ui-egui/src/headless.rs | 8 +++-- crates/ui-egui/src/lib.rs | 8 +++-- crates/ui-egui/src/state.rs | 3 +- crates/ui-egui/src/tests_masking.rs | 49 +++++++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 6178990b0..5e9dbb981 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2629,12 +2629,14 @@ mod tests { h.request("ui.key", json!({"key": "O", "shift": true}), t); assert_eq!(h.app.ui.crop_overlay_orient, 1); assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping"); - let locked = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().crop.aspect.is_some(); - assert!(!locked(&h)); - h.request("ui.key", json!({"key": "A"}), t); + // an untouched photo starts locked to its own shape (#991): A unlocks, A again locks + let locked = |h: &Headless| h.app.crop_tool_aspect().is_some(); assert!(locked(&h)); h.request("ui.key", json!({"key": "A"}), t); assert!(!locked(&h)); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + h.request("ui.key", json!({"key": "A"}), t); + assert!(locked(&h)); assert!(!h.app.ui.visualize_spots); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..4b1e2eb46 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -696,7 +696,10 @@ impl LightcraftApp { if d.crop != lightcraft_develop::Crop::default() { return d.crop.aspect; } - match self.ui.crop_default_aspect.as_ref()? { + // nothing chosen yet: locked to the photo's own shape, as Lightroom starts (#991); one + // Lock toggle (remembered as "free") unlocks it for good + let remembered = self.ui.crop_default_aspect.clone().unwrap_or_else(|| Value::String("original".into())); + match &remembered { Value::String(s) if s == "original" => { let p = self.session.catalog.photo(photo)?; let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100)); @@ -715,9 +718,10 @@ impl LightcraftApp { /// Called only after a crop gesture begins, inside its undo transaction. pub(crate) fn begin_crop(&mut self) -> Result<(), String> { + // nothing chosen yet: the photo's own shape, as `crop_tool_aspect` shows it + let aspect = self.ui.crop_default_aspect.clone().unwrap_or_else(|| serde_json::json!("original")); if self.session.interaction.is_some() && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) - && let Some(aspect) = self.ui.crop_default_aspect.clone() && aspect != serde_json::json!("free") && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect})) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f8c8cc0d4..7268d8a2b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -699,7 +699,8 @@ pub struct UiState { /// Mirroring of the triangle / spiral crop guides (0..4). pub crop_overlay_orient: u8, pub crop_overlay: CropOverlay, - /// Last explicitly chosen aspect, retained across photos and app launches. + /// Last explicitly chosen aspect, retained across photos and app launches; `None` (nothing + /// chosen yet) crops locked to the photo's own shape, as Lightroom starts (#991). pub crop_default_aspect: Option, pub show_filenames: bool, /// What the square grid's caption shows: `filename`, `exposure` (shutter · aperture · ISO · diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index bbea5356e..5ffc8d165 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -539,6 +539,55 @@ fn crop_drag_activates_the_remembered_ratio_and_undo_restores_the_photo() { assert_eq!(develop(&h), before); } +/// Issue #991: with no aspect ever chosen, a crop starts locked to the photo's own shape (Lightroom's +/// default), so a handle drag keeps the proportions; after the Lock is switched off once, the next +/// photo's crop starts free again. +#[test] +fn a_fresh_crop_starts_locked_to_original_until_the_lock_is_switched_off() { + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = None; + h.settle(SETTLE); + let before = develop(&h); + assert!(before.crop.aspect.is_none()); + let p = h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().clone(); + let own = (p.width * 100, p.height * 100); + assert_eq!(h.app.crop_tool_aspect(), Some(own), "the Lock shows engaged on an untouched photo"); + let drag = json!({"events": [ + {"kind": "down", "x": 0.995, "y": 0.995}, + {"kind": "drag", "x": 0.85, "y": 0.85}, + {"kind": "drag", "x": 0.75, "y": 0.75}, + {"kind": "up", "x": 0.75, "y": 0.75} + ]}); + let r = h.request("ui.pointer", drag.clone(), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(develop(&h).crop.aspect, Some(own), "the drag kept the photo's proportions"); + assert_eq!(h.app.ui.crop_default_aspect, None, "nothing was chosen explicitly"); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h), before); + // Lock off once: remembered as free, and from then on the same drag crops without a ratio + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = None; + h.settle(SETTLE); + h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap(); + h.settle(SETTLE); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + assert_eq!(h.app.crop_tool_aspect(), None); + let before = develop(&h); + // a rotating drag outside the box (any crop drag starts cropping); the release on its own + for events in [ + json!([{"kind": "down", "x": -0.05, "y": 0.2}, {"kind": "drag", "x": -0.05, "y": 0.25}, {"kind": "drag", "x": -0.05, "y": 0.3}]), + json!([{"kind": "up", "x": -0.05, "y": 0.3}]), + ] { + let r = h.request("ui.pointer", json!({ "events": events }), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_ne!(develop(&h).crop, before.crop, "the drag cropped"); + assert_eq!(develop(&h).crop.aspect, None, "and left the ratio free"); + let _ = drag; +} + /// A remembered ratio the crop command refuses (a hand-edited settings file) is forgotten, and the /// drag still crops: it must not block every crop of an untouched photo. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..4f1ab2a64 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -250,7 +250,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) | -| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step | +| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) | | LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | From 7db0a64d7e6b5c4e0f75d6f6f739619f63c6e160 Mon Sep 17 00:00:00 2001 From: ubergeneric <104942681+ubergeneric@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:28:12 +1100 Subject: [PATCH 09/27] Detail: Fit is as sharp as the photo (display fit, whole-pixel draw, Lanczos decode) At Fit the loupe was softer than a good downscale of the same file. Four resamples stacked up between the source and the screen: - the render edge is rounded up to a 64 px step, so the texture was drawn up to ~6 % smaller than its size and the GPU shrank it with 2-tap bilinear sampling; - the fitted texture sat on a fractional pixel position, so even at the right size every screen pixel was averaged with its neighbour (#1831 fixed the same for 100 %); - the render box truncated its short side (2560 x 1706 for a 2560 x 1707 source), a slightly anisotropic resample that blurred one axis only; - the codec's decode-time shrink at 2:1 and more, and SourceRef::Fit, used a box filter, which halves the contrast of detail near the output's limit. RenderJob::display_fit shrinks a full-quality render that is larger than its drawn size to exactly that size with Lanczos-3 on the worker (after the caches have the full render; drafts untouched; part of the key). The Detail view sets it for the After and Before renders at rest, and snap_fit_rect draws a texture that is its drawn size on whole physical pixels. The render boxes round their short side, and the 2:1-and-more shrinks use Lanczos-3. Refs #1472, #1042. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/convert.rs | 4 +- crates/engine/src/import.rs | 1 + crates/engine/src/media.rs | 64 ++++++++++++++++++++++++++++- crates/ui-egui/src/panels/detail.rs | 60 +++++++++++++++++++++++++-- 4 files changed, 124 insertions(+), 5 deletions(-) diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs index 02652db5a..403845071 100644 --- a/crates/codecs/src/convert.rs +++ b/crates/codecs/src/convert.rs @@ -129,7 +129,9 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3 let (image, alpha) = match opts.max_size { Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => { let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64); - let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell }; + // (Lanczos at 2:1 and more as well: a box average there halves the contrast of + // detail near the output's resolution limit and aliases what is finer) + let f = if factor >= 2.0 { Filter::Lanczos3 } else { Filter::Mitchell }; let img = fit(&image, mw as usize, mh as usize, f); let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f)); (img, alpha) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 9aa847b01..84af5acb5 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -1566,6 +1566,7 @@ impl Session { display: None, slide: None, preview_cut: None, + display_fit: None, }; let embedded = match (&self.media.preview_loader, c.kind) { (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..52c309847 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -277,7 +277,9 @@ impl SourceRef { if from.image.width.max(from.image.height) <= *max_edge { return Ok((**from).clone()); } - let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box); + // Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near + // the output's resolution limit and aliases what is finer + let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3); return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); } SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, @@ -761,6 +763,24 @@ pub struct RenderJob { /// A face tile that can be cut from the raw's embedded preview, tried before `source` /// ([`crate::Session::face_job`]; `None` for every other job). pub preview_cut: Option, + /// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once + /// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up + /// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at + /// small ratios and aliases it at large ones. The caches keep the full render. + pub display_fit: Option<(usize, usize)>, +} + +/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]). +pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 { + use lightcraft_raster::resample::{Filter, fit}; + let f = lightcraft_raster::Image { + width: img.width, + height: img.height, + data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(), + }; + let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3); + let c = |v: f32| v.round().clamp(0.0, 255.0) as u8; + Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() } } pub struct RenderResult { @@ -784,6 +804,14 @@ pub struct RenderResult { impl RenderJob { /// Render at draft quality (interactive drags). Gets its own result key. + /// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see + /// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again. + pub fn with_display_fit(mut self, w: usize, h: usize) -> Self { + self.display_fit = Some((w, h)); + self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + self + } + pub fn draft(mut self) -> Self { if self.request.quality != Quality::Draft { self.request.quality = Quality::Draft; @@ -994,6 +1022,15 @@ impl RenderJob { { cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); } + // the display fit, after the caches have the full render + if let Some((fw, fh)) = self.display_fit + && self.request.quality == Quality::Full + { + let (w, h) = (rendered.image.width, rendered.image.height); + if w > fw.max(1) || h > fh.max(1) { + rendered.image = fit_for_display(&rendered.image, fw, fh); + } + } RenderResult { request_id: self.request_id, source_key: self.source_key, @@ -1266,6 +1303,7 @@ impl crate::Session { display: None, slide: None, preview_cut: None, + display_fit: None, }) } @@ -1319,6 +1357,7 @@ impl crate::Session { display: None, slide: None, preview_cut: None, + display_fit: None, }) } @@ -1373,6 +1412,7 @@ impl crate::Session { display: None, slide: None, preview_cut, + display_fit: None, }) } @@ -1798,6 +1838,28 @@ mod thumbnail_hash_tests { mod tests { use super::*; + /// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept; + /// a draft, and a render no larger than the drawn size, are left as rendered. + #[test] + fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image; + let long = plain.width.max(plain.height); + assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}"); + let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height); + let aspect = |i: &Rgba8| i.width as f32 / i.height as f32; + assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept"); + let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk"); + let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image; + assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered"); + let a = s.loupe_job(id, 1024, 1024, true).unwrap(); + let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400); + assert_ne!(a.key, b.key, "the fit is part of the result key"); + } + #[test] fn renders_know_the_original_size_whichever_level_they_come_from() { // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 44427565e..ff8b96da5 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -145,6 +145,21 @@ pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect { Rect::from_center_size(area.center(), vec2(w * scale, h * scale)) } +/// `rect` moved onto whole physical pixels, at exactly the texture's size, when the texture is +/// already the size it is drawn at (within half a pixel), as a display-fitted render at Fit is: centred +/// on a fractional position, the GPU's bilinear filter would average every screen pixel with its +/// neighbour (the 100 % view does the same in [`fit_rect`]). Textures of other sizes are left to +/// the sampler. +pub(crate) fn snap_fit_rect(rect: Rect, size: [usize; 2], ppp: f32) -> Rect { + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let (w, h) = (size[0] as f32, size[1] as f32); + if (rect.width() * ppp - w).abs() > 0.5 || (rect.height() * ppp - h).abs() > 0.5 { + return rect; + } + let snap = |v: f32| (v * ppp).round() / ppp; + Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), vec2(w / ppp, h / ppp)) +} + /// Keep the viewport inside the photo; axes smaller than the viewport stay centred. fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) { let bound = |visible: f32, image: f32, centre: f32| { @@ -564,12 +579,24 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); } let want = plan.main_edge; - let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; + // The short side is rounded, not truncated: a truncated box (2560 x 1706 for a 2560 x 1707 + // source) made the render a slightly anisotropic resample of its source, a 2-tap blur along + // one axis only. A box that matches the source to rounding is rendered at exactly its size. + let short = |e: usize, a: f32| ((e as f32 / a).round() as usize).max(1); + let (rw, rh) = if aspect >= 1.0 { (want, short(want, aspect)) } else { (short(want, 1.0 / aspect), want) }; // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap let se = app.ui.settings.stand_in_edge(want, texture_side); - let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) }; + let (sw, sh) = if aspect >= 1.0 { (se, short(se, aspect)) } else { (short(se, 1.0 / aspect), se) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; + // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on + // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap + // sampling (the render is up to a 64 px step larger) + let job = if interacting { + job + } else { + job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize) + }; let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); app.renderer.request(Slot::Main, job, 100); @@ -625,6 +652,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if (split || show_before || split_view) && let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool) { + // (the same display fit as the After view, so both panes are the same texture size) + let job = if interacting { + job + } else { + job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize) + }; app.renderer.request(Slot::Before, job, 90); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more @@ -692,7 +725,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { return "none"; }; - let image_rect = fit_texture_rect(r, tex.size); + let image_rect = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp); p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); what }; @@ -3050,6 +3083,27 @@ mod pen_tests { } } +#[cfg(test)] +mod snap_tests { + use super::*; + + /// A texture drawn at its own size lands on whole physical pixels at exactly that size (no + /// bilinear blur); a texture of another size (zoomed, a stand-in) keeps the rect it was given. + #[test] + fn a_fitted_texture_is_drawn_on_whole_pixels_at_its_own_size() { + let r = Rect::from_min_size(pos2(394.3, 130.5), vec2(1088.4, 720.2)); + let s = snap_fit_rect(r, [1088, 720], 1.0); + assert_eq!((s.min.x, s.min.y, s.width(), s.height()), (394.0, 131.0, 1088.0, 720.0)); + // at 1.5 points per pixel the snap is to physical pixels and the size stays in points + let hi = snap_fit_rect(Rect::from_min_size(pos2(100.3, 50.2), vec2(725.4, 480.1)), [1088, 720], 1.5); + assert!(((hi.min.x * 1.5).fract()).abs() < 1e-3 && (hi.width() - 1088.0 / 1.5).abs() < 1e-3, "{hi:?}"); + let other = snap_fit_rect(r, [2560, 1694], 1.0); + assert_eq!(other, r); + let bad = snap_fit_rect(r, [1088, 720], f32::NAN); + assert_eq!((bad.width(), bad.height()), (1088.0, 720.0)); + } +} + #[cfg(test)] mod filter_pill_tests { use super::*; From 93920397263e64d5042b018fb4aa242903dac4c4 Mon Sep 17 00:00:00 2001 From: ubergeneric <104942681+ubergeneric@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:28:14 +1100 Subject: [PATCH 10/27] Detail: at rest the whole frame renders at the preview level, shrunk once Rendered at the drawn size, the pipeline itself shrinks the 2560 px preview source (Mitchell prefilter, then the bilinear sampling pass) before the display fit shrinks it again. At rest the whole-frame render now asks for no less than the preview level (the photo's own size if smaller, a user's explicit Preview size if lower, the texture limit at most): the source is copied 1:1 and shrunk once, by the Lanczos display fit. Drags still render drafts at the planned size. On a line-grating chart at Fit this takes 1.8 px bars from 0.83 to 1.00 of their contrast (1920 x 1200 window), at the cost of a larger settled render. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/detail.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index ff8b96da5..e2fc146c0 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -578,7 +578,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.size_hold.holding(now) { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); } - let want = plan.main_edge; + // At rest the whole frame renders at no less than the preview source level: the source is then + // copied 1:1 (no resample in the pipeline, whose prefilter and bilinear pass soften detail near + // the output's limit) and shrunk once, by the Lanczos display fit below. + let want = if interacting { + plan.main_edge + } else { + // (a user's explicit Preview size below the preview level still caps it) + let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize }; + let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long); + plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE)) + }; // The short side is rounded, not truncated: a truncated box (2560 x 1706 for a 2560 x 1707 // source) made the render a slightly anisotropic resample of its source, a 2-tap blur along // one axis only. A box that matches the source to rounding is rendered at exactly its size. @@ -591,7 +601,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let job = if interacting { job.draft() } else { job }; // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap - // sampling (the render is up to a 64 px step larger) + // sampling (the render is up to a 64 px step larger, or the preview level) let job = if interacting { job } else { From 86147e1eebd05440b8a4337bc534c2e4d043629b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:23:57 +0200 Subject: [PATCH 11/27] Keep the profile dropdown and its submenus inside the window (#2059) --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/edit.rs | 39 +++++++++++++----- crates/ui-egui/src/tests_profile_menu.rs | 52 ++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/tests_profile_menu.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..8bb9446e8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -140,6 +140,8 @@ mod tests_preview_limit; #[cfg(test)] mod tests_print; #[cfg(test)] +mod tests_profile_menu; +#[cfg(test)] mod tests_profiles_layout; #[cfg(test)] mod tests_quit_unsaved; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ded1c0bd0..89e134aed 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -169,7 +169,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // content, and the sliders below would run past its edge let max = ui.available_width() - 28.0 - ui.spacing().item_spacing.x; let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label, max); - egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); + let below = r.rect.bottom(); + egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d, below)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button( ui, @@ -429,8 +430,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } /// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and -/// Browse…. -fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { +/// Browse…. Like the menu bar's menus, the dropdown and each submenu stay inside the window and +/// scroll when their rows don't fit (issue #2059: a folder of many imported profiles ran past the +/// window's bottom edge). `below` is the dropdown's bottom edge, which the menu hangs from. +fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { + crate::menu_level::level(ui, 1, Some(below), |ui| profile_menu_rows(app, ui, d, below)); +} + +fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { use lightcraft_engine::presets::{PROFILES, profile_groups}; let t = Tokens::get(ui.ctx()); ui.set_min_width(200.0); @@ -463,12 +470,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } for g in profile_groups() { let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { - ui.set_min_width(170.0); - for p in PROFILES.iter().filter(|p| p.group == g) { - item(ui, "group", p.id, p.name, &mut pick); - } + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in PROFILES.iter().filter(|p| p.group == g) { + item(ui, "group", p.id, p.name, &mut pick); + } + }); }); register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } } // imported LUT profile groups let mut lut_groups: Vec = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect(); @@ -477,12 +489,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) for g in &lut_groups { // a folder's name as it is, and its own widget id even when it matches a built-in group let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { - ui.set_min_width(170.0); - for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { - item(ui, "group", &p.id, &p.name, &mut pick); - } + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { + item(ui, "group", &p.id, &p.name, &mut pick); + } + }); }); register(ui.ctx(), format!("profileMenu:lutGroupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } } ui.separator(); if let Some((name, _)) = app.session.profile_info(cur) { diff --git a/crates/ui-egui/src/tests_profile_menu.rs b/crates/ui-egui/src/tests_profile_menu.rs new file mode 100644 index 000000000..3bd644467 --- /dev/null +++ b/crates/ui-egui/src/tests_profile_menu.rs @@ -0,0 +1,52 @@ +//! #2059: the profile dropdown and its submenus stay inside the window. A folder of many imported +//! profiles used to make its submenu run past the window's bottom edge, and the profiles below it +//! could not be reached. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(5); +const SETTLE: Duration = Duration::from_secs(10); +const FOLDER: &str = "Imported"; + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn go(h: &mut Headless, method: &str, id: &str) { + let r = h.request(method, json!({"id": id}), T); + assert_eq!(r["ok"], true, "{method} {id}: {r}"); + h.settle(SETTLE); +} + +#[test] +fn a_folder_of_many_imported_profiles_scrolls_inside_the_window() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + for i in 0..80 { + app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile { + id: format!("lut:imported-{i}"), + name: format!("Imported {i}"), + group: FOLDER.into(), + file: String::new(), + }); + } + let mut h = Headless::new(app, [1200.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + go(&mut h, "ui.clickWidget", "dropdown:profile"); + let menu = widget(&h, "menu-level:1").expect("the profile menu is open"); + assert!(menu.bottom() <= h.size.y + 0.5, "the profile menu {menu:?} runs past the window's bottom {}", h.size.y); + go(&mut h, "ui.hoverWidget", &format!("profileMenu:lutGroupMenu:{FOLDER}")); + let sub = widget(&h, "menu-level:2").expect("the imported folder's submenu is open"); + assert!(sub.bottom() <= h.size.y + 0.5, "the submenu {sub:?} runs past the window's bottom {}", h.size.y); + // Its 80 rows don't all fit: some are scrolled out of view rather than drawn off screen. + let rows: Vec = (0..80).filter_map(|i| widget(&h, &format!("profileMenu:group:lut:imported-{i}"))).collect(); + assert!(!rows.is_empty(), "no imported profile rows drawn"); + assert!(rows.iter().any(|r| !sub.contains_rect(*r)), "every row fits a 900 pt window"); +} From 23f00822eb87b047ff9d2adc2dfb1339d945d158 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 12:37:56 +0000 Subject: [PATCH 12/27] #1927: Auto Sync fits each photo's own crop under Constrain Crop The crop fit ran before the Auto Sync delta, so a Geometry edit carried the active photo's fitted crop rect into the delta and onto every other selected photo, replacing (and could enlarge) their own crops. The delta is now taken from the edit as asked, and each target fits its own crop. Review on #1990. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK --- crates/engine/src/lib.rs | 17 ++++++++------ crates/engine/src/tests_constrain_crop.rs | 28 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f472d7b60..e60cf5cf8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -874,14 +874,16 @@ impl Session { /// undo step; otherwise it is committed with a history entry. pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> { let now = (self.clock)(); - let settings = Arc::new(self.fit_constrained_crop(id, settings)); + // Auto Sync carries the edit as asked; each photo then fits its own crop (review of #1990) + let requested = settings; + let settings = Arc::new(self.fit_constrained_crop(id, requested.clone())); if let Some(i) = &self.interaction && i.photo == id { return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) }); } let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?]; - ops.extend(self.auto_sync_ops(id, &settings, label)); + ops.extend(self.auto_sync_ops(id, &requested, label)); let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } }; self.commit(label, op) } @@ -917,10 +919,11 @@ impl Session { d } - /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the - /// other selected photos: only the settings that changed; never spot removal or red eye (they - /// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor - /// history / snapshot restores. + /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`, as asked, + /// before its crop is fitted) over to the other selected photos: only the settings that changed; + /// never spot removal or red eye (they belong to one photo's pixels), nor the rendering process + /// (Sync doesn't carry it either), nor history / snapshot restores. Constrain Crop then fits each + /// photo's own crop: the active photo's fitted crop is never copied over another's. fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec { if !self.auto_sync || self.active() != Some(id) @@ -946,7 +949,7 @@ impl Session { .filter(|x| **x != id) .filter_map(|x| self.develop_of(*x).map(|d| (*x, d))) .filter_map(|(x, d)| { - let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0); + let synced = self.fit_constrained_crop(x, lightcraft_develop::apply_partial(&d, &delta, 1.0)); (synced != *d).then(|| self.develop_op(x, synced, label)).flatten() }) .collect() diff --git a/crates/engine/src/tests_constrain_crop.rs b/crates/engine/src/tests_constrain_crop.rs index 37c6d734d..51dcee1c0 100644 --- a/crates/engine/src/tests_constrain_crop.rs +++ b/crates/engine/src/tests_constrain_crop.rs @@ -70,3 +70,31 @@ fn a_crop_drawn_by_hand_is_kept_as_drawn() { s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap(); assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the crop edit itself is not refitted"); } + +/// Review of #1990: with Auto Sync, a Geometry edit on the active photo fits each selected photo's +/// own crop; the active photo's fitted crop is never copied over another photo's (smaller) crop. +#[test] +fn auto_sync_fits_each_photos_own_crop() { + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (a, b) = (ids[0], ids[1]); + // B: a small crop off-centre; both constrained + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap(); + constrain(&mut s, true); + s.selection = crate::Selection { ids: vec![a], active: Some(a) }; + constrain(&mut s, true); + let b_before = s.develop_of(b).unwrap().crop.geometry.rect; + // A active, both selected, Auto Sync on: a Geometry edit on A + s.selection = crate::Selection { ids: vec![a, b], active: Some(a) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + vertical(&mut s, -60.0); + let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap()); + assert_eq!(db.geometry.vertical, -60.0, "the edit reached B"); + let b_after = db.crop.geometry.rect; + assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own"); + let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9; + assert!(inside(b_after, b_before), "B's crop only shrinks within itself: {b_before:?} -> {b_after:?}"); + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + assert!(fits(&s), "and fits B's warp"); +} From debfa644ec06a53c66f5c21219044f913d57ef61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:48:52 +0000 Subject: [PATCH 13/27] #1927: a slider drag and unrelated section eyes don't misplace crops Two more cases from the review of #1990: the end of a slider drag replayed the crop Constrain Crop had fitted during the drag, so Auto Sync still copied the active photo's fitted crop to the others; the end of an interaction now starts again from the original crop when the drag didn't edit the crop. And any section eye counted as a warp change, so turning Light off could refit a hand-drawn crop: only the Geometry and Optics eyes do now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK --- crates/engine/src/lib.rs | 22 ++++++++--- crates/engine/src/tests_constrain_crop.rs | 47 +++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e60cf5cf8..da435b7d7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -184,6 +184,10 @@ pub struct Interaction { pub label: String, pub photo: PhotoId, pub original: Arc, + /// Whether an edit during the interaction changed the crop itself. If none did, the crop the + /// photo shows was fitted by Constrain Crop, and the final commit starts again from the original + /// crop, so Auto Sync carries the edit and not the active photo's fitted crop. + pub crop_edited: bool, } /// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole. @@ -877,9 +881,11 @@ impl Session { // Auto Sync carries the edit as asked; each photo then fits its own crop (review of #1990) let requested = settings; let settings = Arc::new(self.fit_constrained_crop(id, requested.clone())); - if let Some(i) = &self.interaction + let crop_edited = self.develop_of(id).is_some_and(|d| d.crop.geometry != requested.crop.geometry); + if let Some(i) = &mut self.interaction && i.photo == id { + i.crop_edited |= crop_edited; return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) }); } let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?]; @@ -903,7 +909,7 @@ impl Session { let warp_changed = before.geometry != d.geometry || before.optics != d.optics || before.orientation != d.orientation - || before.disabled_sections != d.disabled_sections; + || ["geometry", "optics"].iter().any(|s| before.section_enabled(s) != d.section_enabled(s)); let crop_edited = before.crop.geometry != d.crop.geometry; if !(turned_on || (warp_changed && !crop_edited)) { return d; @@ -971,7 +977,7 @@ impl Session { } let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?; let original = self.develop_of(id).unwrap_or_default(); - self.interaction = Some(Interaction { label: label.into(), photo: id, original }); + self.interaction = Some(Interaction { label: label.into(), photo: id, original, crop_edited: false }); Ok(()) } @@ -982,9 +988,15 @@ impl Session { if *cur == *i.original { return Ok(()); } - // Restore the original silently, then commit the final value as one step. + // Restore the original silently, then commit the final value as one step. A crop nobody + // edited goes back to the original's: Constrain Crop fits it again for the final warp, and + // Auto Sync carries the edit, not the active photo's fitted crop (review of #1990). self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?; - self.set_develop(i.photo, (*cur).clone(), &i.label) + let mut last = (*cur).clone(); + if !i.crop_edited { + last.crop.geometry = i.original.crop.geometry; + } + self.set_develop(i.photo, last, &i.label) } pub fn cancel_interaction(&mut self) -> Result<()> { diff --git a/crates/engine/src/tests_constrain_crop.rs b/crates/engine/src/tests_constrain_crop.rs index 51dcee1c0..9f01ed05d 100644 --- a/crates/engine/src/tests_constrain_crop.rs +++ b/crates/engine/src/tests_constrain_crop.rs @@ -98,3 +98,50 @@ fn auto_sync_fits_each_photos_own_crop() { s.selection = crate::Selection { ids: vec![b], active: Some(b) }; assert!(fits(&s), "and fits B's warp"); } + +/// Review of #1990 (CodeRabbit): the same with a slider drag (an interaction), the path the +/// window uses: the drag fits A's crop live, and its end must not carry that crop over to B. +#[test] +fn auto_sync_after_a_slider_drag_fits_each_photos_own_crop() { + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (a, b) = (ids[0], ids[1]); + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap(); + constrain(&mut s, true); + s.selection = crate::Selection { ids: vec![a], active: Some(a) }; + constrain(&mut s, true); + let b_before = s.develop_of(b).unwrap().crop.geometry.rect; + s.selection = crate::Selection { ids: vec![a, b], active: Some(a) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + s.execute("develop.beginInteraction", &json!({"label": "Vertical"})).unwrap(); + for v in [-20.0, -40.0, -60.0] { + vertical(&mut s, v); + } + s.execute("develop.endInteraction", &json!({})).unwrap(); + let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap()); + assert_eq!((da.geometry.vertical, db.geometry.vertical), (-60.0, -60.0)); + let b_after = db.crop.geometry.rect; + assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own"); + let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9; + assert!(inside(b_after, b_before), "{b_before:?} -> {b_after:?}"); + for photo in [a, b] { + s.selection = crate::Selection { ids: vec![photo], active: Some(photo) }; + assert!(fits(&s), "each crop fits its photo's warp"); + } +} + +/// Review of #1990 (CodeRabbit): only the Geometry and Optics eyes change the warp; turning +/// another section's eye off leaves a crop drawn by hand as it is. +#[test] +fn an_unrelated_section_eye_leaves_a_hand_drawn_crop() { + let mut s = Session::with_demo(); + constrain(&mut s, true); + vertical(&mut s, -60.0); + s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + s.execute("develop.sectionEnabled", &json!({"section": "light", "enabled": false})).unwrap(); + assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the Light eye is no warp change"); + s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": false})).unwrap(); + s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": true})).unwrap(); + assert!(fits(&s), "the Geometry eye is one: the crop fits again"); +} From 00f55ef352e9b0b4a5ac1828bb6492ec110ea403 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:28:49 +0000 Subject: [PATCH 14/27] #2266: a presets.json that can't be written counts as unsaved Preset commands change the preset list outside pending_log, and a failed presets.json write was only logged: unsaved() stayed None, so nothing warned and the web backup (#1987) would zip a stale presets.json; on desktop a full disk lost the preset silently. The failure is now kept (presets_error), counted by unsaved() (plus one change), retried by persist_if_dirty with the same back-off as failed appends, and cleared once the write succeeds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK --- crates/engine/src/library.rs | 25 ++++++++++++++++++++----- crates/engine/src/tests_persist.rs | 25 +++++++++++++++++++++++++ docs/whats-new.md | 7 +++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index e91f0bfff..32ad1da53 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -84,6 +84,9 @@ pub struct Library { /// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes /// exist only in memory ([`Session::unsaved`]). pub unsaved_error: Option, + /// Why the last write of presets.json failed; `None` once it is written. While set, the preset + /// changes exist only in memory and count as unsaved ([`Session::unsaved`], issue #2266). + presets_error: Option, /// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off). retry_at: Option, /// What forgetting untouched Local records did when the library opened. @@ -577,6 +580,7 @@ impl Session { report, last_error: None, unsaved_error: None, + presets_error: None, retry_at: None, forgot_local: None, presets_saved, @@ -651,10 +655,17 @@ impl Session { let Some(lib) = self.library.as_mut() else { return Ok(()) }; if state.hash == lib.presets_saved.hash { lib.presets_saved = state; + lib.presets_error = None; } else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { - // the state stays stale: the next save tries again + // the state stays stale: retried by the next command and the frame loop (backing off); + // meanwhile the presets count as unsaved, so the UI warns and a backup refuses log::error!("library: presets: {e}"); + lib.presets_error = Some(format!("presets: {e}")); + lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF); } else { + if lib.presets_error.take().is_some() { + log::info!("library: presets written to disk"); + } lib.presets_saved = state; } Ok(()) @@ -664,7 +675,8 @@ impl Session { /// changed outside [`Session::execute`]). pub fn persist_if_dirty(&mut self) { let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t); - if self.library.is_some() && !self.pending_log.is_empty() && !backing_off { + let presets_unsaved = self.library.as_ref().is_some_and(|l| l.presets_error.is_some()); + if self.library.is_some() && (!self.pending_log.is_empty() || presets_unsaved) && !backing_off { let _ = self.persist(); } else if let Some(lib) = self.library.as_mut() { poll_compaction(lib); @@ -672,11 +684,14 @@ impl Session { } /// Changes applied in memory but not yet written to disk because saving failed: the number of - /// queued ops and why (`None` when everything is saved, or no library is open). Retried by - /// every command and by [`Session::persist_if_dirty`]. + /// queued ops (plus one for presets.json) and why (`None` when everything is saved, or no library + /// is open). Retried by every command and by [`Session::persist_if_dirty`]. pub fn unsaved(&self) -> Option<(usize, &str)> { let lib = self.library.as_ref()?; - lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e)) + let ops = lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()); + let presets = lib.presets_error.as_deref(); + let n = ops.map_or(0, |_| self.pending_log.len()) + usize::from(presets.is_some()); + ops.or(presets).map(|why| (n, why)) } /// Flush everything and write a snapshot (on quit). Ends an open interaction first. diff --git a/crates/engine/src/tests_persist.rs b/crates/engine/src/tests_persist.rs index 4fb3f0234..7ebf8464b 100644 --- a/crates/engine/src/tests_persist.rs +++ b/crates/engine/src/tests_persist.rs @@ -191,3 +191,28 @@ fn close_fails_when_nothing_can_be_saved() { s.close_library().unwrap(); assert_eq!(s.unsaved(), None); } + +/// Issue #2266: a presets.json that can't be written isn't only logged: the preset counts as +/// unsaved (the top bar warns, the web backup refuses) until a later save writes it. +#[test] +fn a_preset_that_cannot_be_written_counts_as_unsaved_until_it_is() { + let files = Flaky::default(); + let mut s = Session::new(); + let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, true).unwrap(); + assert!(s.unsaved().is_none()); + files.set_failing(true); + s.execute("preset.create", &json!({"name": "Mine"})).unwrap(); + let (n, why) = s.unsaved().expect("the preset is unsaved"); + assert_eq!(n, 1); + assert!(why.contains("presets") && why.contains("disk full"), "{why}"); + let stored = |f: &Flaky| f.files.files.lock().unwrap().get("presets.json").cloned().unwrap_or_default(); + assert!(!String::from_utf8_lossy(&stored(&files)).contains("Mine")); + // still failing: still unsaved + s.persist().unwrap(); + assert!(s.unsaved().is_some()); + files.set_failing(false); + s.persist().unwrap(); + assert!(s.unsaved().is_none(), "written once the disk works again"); + assert!(String::from_utf8_lossy(&stored(&files)).contains("Mine")); +} diff --git a/docs/whats-new.md b/docs/whats-new.md index 2f02ffb91..57077fe4d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-18 + +### A preset that couldn't be saved is no longer lost silently +- When presets.json can't be written (a full disk, or the browser's storage quota), the new or changed preset now + counts as unsaved, like other changes: the top bar warns, saving is retried, and quitting asks first (issue #2266). + Before, the failure was only logged, and the preset could be lost without any warning. + ## 2026-10-14 ### Model downloads time out on a name that doesn't resolve From 15e9a24891ced4a08c95bd8f292a27836bd123b5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:35:49 +0200 Subject: [PATCH 15/27] Draw the Before label on a dark pill so it reads on light photos (#2247) --- crates/ui-egui/src/panels/detail.rs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 94462e6dc..5769e0356 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -715,14 +715,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); } else if show_before { shown = draw(Slot::Before, img_rect, canvas); - p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text); + // on the photo itself, so it gets the hover label's dark pill to stay readable over light pictures + let g = p.layout_no_wrap(crate::i18n::tr("Before").to_string(), t.font(12.0), Color32::WHITE); + let bg = label_badge_rect(img_rect, g.size()); + p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); + p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); } else if let Some((_key, label)) = &hover_key && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id) { let hover_rect = fit_texture_rect(img_rect, tex.size); p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE); - let bg = Rect::from_min_size(hover_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0)); + let bg = label_badge_rect(hover_rect, g.size()); p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); shown = "hover"; @@ -910,6 +914,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// The dark pill behind a label drawn over the photo (`Before`, a hovered preset's name): at the +/// photo's top left, inset 8 points, with the `text` size plus padding. +fn label_badge_rect(photo: Rect, text: egui::Vec2) -> Rect { + Rect::from_min_size(photo.left_top() + vec2(8.0, 8.0), text + vec2(16.0, 8.0)) +} + /// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date /// and size, or with the camera and exposure. fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { @@ -2709,10 +2719,21 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: #[cfg(test)] mod preview_geometry_tests { - use super::{fit_rect, fit_texture_rect}; + use super::{fit_rect, fit_texture_rect, label_badge_rect}; use crate::state::Zoom; use egui::{Rect, pos2, vec2}; + /// The "Before" label sits on a dark pill that wraps the text with padding on every side. + #[test] + fn before_label_badge_wraps_the_text() { + let text = vec2(40.0, 14.0); + let photo = Rect::from_min_size(pos2(20.0, 30.0), vec2(400.0, 300.0)); + let bg = label_badge_rect(photo, text); + assert_eq!(bg.min, pos2(28.0, 38.0)); + assert_eq!(bg.size(), vec2(56.0, 22.0)); + assert!(photo.contains_rect(bg)); + } + #[test] fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() { let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); From 81f02a6947190fcaa27339ab69a6bae9c444fda2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:48:56 +0000 Subject: [PATCH 16/27] #2253: crop ratios keep the orientation asked for set_aspect always turned the ratio to the image's orientation, so a Custom ratio taller than wide came out landscape on a landscape photo, and a ratio picked after X (crop.rotateAspect) turned the crop back. A named ratio now takes the crop's present orientation (landscape, or portrait after X; a square crop follows the image), a custom [w, h] is used as given, and crop.aspect takes orientation: "crop" | "given" to choose. The window reapplies the remembered ratio to an untouched photo with orientation "crop", as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK --- crates/engine/src/cmd/develop.rs | 32 +++++++++--- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_crop_aspect.rs | 68 ++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 +- docs/parity-checklist.md | 2 +- docs/whats-new.md | 7 +++ 6 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/tests_crop_aspect.rs diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index e3d59c567..38bf7a3da 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -77,13 +77,22 @@ fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> { Ok((a as u32, b as u32)) } -fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) { +/// How a new aspect ratio is turned (issue #2253). +#[derive(Clone, Copy, PartialEq)] +enum Turn { + /// As the crop is now: a named ratio ("2x3", "original"…) lands landscape on a landscape crop + /// and portrait on a portrait one (after X), as in Lightroom. A square crop follows the image. + LikeCrop, + /// As given: a custom `[w, h]` taller than wide is a portrait crop. + AsGiven, +} + +fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>, turn: Turn) { + let r = d.crop.geometry.rect_px(w, h); + let landscape = if (r.width() - r.height()).abs() > 1e-6 * r.width().max(r.height()) { r.width() > r.height() } else { w >= h }; + let aspect = aspect.map(|(aw, ah)| if turn == Turn::LikeCrop && (aw >= ah) != landscape { (ah, aw) } else { (aw, ah) }); d.crop.aspect = aspect; - let a = aspect.map(|(aw, ah)| { - // orientation of the crop follows the image (landscape image → landscape crop) - let (aw, ah) = (aw as f64, ah as f64); - if (w >= h) == (aw >= ah) { aw / ah } else { ah / aw } - }); + let a = aspect.map(|(aw, ah)| aw as f64 / ah as f64); let angle = d.crop.geometry.angle; d.crop.geometry = crop_fit_angle(w, h, angle, a); } @@ -628,7 +637,7 @@ pub fn specs() -> Vec { "Crop Aspect", [], None, - "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}", + "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h], orientation?: \"crop\"|\"given\"} — a named ratio takes the crop's present orientation (landscape, or portrait after X); [w,h] is used as given (taller than wide → portrait); `orientation` overrides either", has_active, |s, p| { let id = active(s, "crop.aspect")?; @@ -647,6 +656,13 @@ pub fn specs() -> Vec { Ok(()) }); } + let turn = match p.get("orientation").and_then(Value::as_str) { + Some("crop") => Turn::LikeCrop, + Some("given") => Turn::AsGiven, + Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")), + None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven, + None => Turn::LikeCrop, + }; let aspect = match p.get("aspect") { Some(Value::String(a)) if a == "toggle" => None, Some(Value::String(a)) if a == "free" => None, @@ -667,7 +683,7 @@ pub fn specs() -> Vec { if aspect.is_none() { d.crop.aspect = None; } else { - set_aspect(d, w, h, aspect); + set_aspect(d, w, h, aspect, turn); } Ok(()) }) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25e6ef52f..676544258 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1251,6 +1251,8 @@ mod tests_color; #[cfg(test)] mod tests_crop_angle; #[cfg(test)] +mod tests_crop_aspect; +#[cfg(test)] mod tests_denoise; #[cfg(test)] mod tests_export; diff --git a/crates/engine/src/tests_crop_aspect.rs b/crates/engine/src/tests_crop_aspect.rs new file mode 100644 index 000000000..3cfee0c05 --- /dev/null +++ b/crates/engine/src/tests_crop_aspect.rs @@ -0,0 +1,68 @@ +//! Issue #2253: a crop ratio keeps the orientation asked for. A named ratio ("2x3", "4x5", +//! "original") takes the crop's present orientation, landscape or portrait after X, as in +//! Lightroom; a custom `[w, h]` is used as given, so 2 × 3 is a portrait crop. + +use serde_json::{Value, json}; + +use crate::Session; + +/// The demo photo, landscape, with its crop's width and height in pixels. +fn crop_px(s: &Session) -> (f64, f64) { + let p = s.catalog.photo(s.active().unwrap()).unwrap(); + let (w, h) = (p.width as f64, p.height as f64); + let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) }; + let r = p.develop.crop.geometry.rect_px(w, h); + (r.width(), r.height()) +} + +fn landscape_photo() -> Session { + let mut s = Session::with_demo(); + let id = s.catalog.photos().find(|p| p.width > p.height && !p.develop.orientation.swaps_axes()).map(|p| p.id).unwrap(); + s.selection = crate::Selection { ids: vec![id], active: Some(id) }; + s +} + +fn aspect(s: &mut Session, p: Value) { + s.execute("crop.aspect", &p).unwrap(); +} + +fn ratio(s: &Session) -> f64 { + let (w, h) = crop_px(s); + w / h +} + +#[test] +fn a_named_ratio_follows_the_crop_and_x_keeps_it_portrait() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": "2x3"})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "landscape crop: {}", ratio(&s)); + s.execute("crop.rotateAspect", &json!({})).unwrap(); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "X turns it portrait: {}", ratio(&s)); + // another named ratio after X stays portrait (it used to turn back to the image's landscape) + aspect(&mut s, json!({"aspect": "4x5"})); + assert!((ratio(&s) - 0.8).abs() < 0.01, "still portrait: {}", ratio(&s)); + aspect(&mut s, json!({"aspect": "16x9"})); + assert!((ratio(&s) - 9.0 / 16.0).abs() < 0.01, "{}", ratio(&s)); +} + +#[test] +fn a_custom_ratio_is_used_as_given() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": [2, 3]})); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "custom 2 × 3 is portrait: {}", ratio(&s)); + aspect(&mut s, json!({"aspect": [3, 2]})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s)); +} + +/// The window reapplies the last ratio to an untouched photo with `orientation: "crop"`: on a +/// landscape photo it lands landscape, whatever photo it was chosen on. +#[test] +fn orientation_overrides_and_is_checked() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": [2, 3], "orientation": "crop"})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s)); + aspect(&mut s, json!({"aspect": "2x3", "orientation": "given"})); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "{}", ratio(&s)); + let e = s.execute("crop.aspect", &json!({"aspect": "2x3", "orientation": "sideways"})).unwrap_err(); + assert!(e.to_string().contains("orientation"), "{e}"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..83603e038 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -719,7 +719,8 @@ impl LightcraftApp { && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) && let Some(aspect) = self.ui.crop_default_aspect.clone() && aspect != serde_json::json!("free") - && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect})) + // the remembered ratio, turned like the new photo's crop (as a named ratio is) + && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect, "orientation": "crop"})) { // A remembered value the command refuses (a hand-edited settings file) would refuse // every crop drag on an untouched photo: forget it and let this drag go ahead. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4e0531793..3ebda6d38 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -250,7 +250,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) | -| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step | +| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step | | LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 2f02ffb91..d99edeba3 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-18 + +### Portrait crop ratios stay portrait +- A crop ratio no longer flips to the photo's orientation (issue #2253): after X turns the crop portrait, picking + another ratio (4 × 5, 2 × 3…) keeps it portrait, as in Lightroom, and a Custom ratio taller than wide gives a + portrait crop. + ## 2026-10-14 ### Model downloads time out on a name that doesn't resolve From 266e66837670a65343e182caa66c0a46be29c011 Mon Sep 17 00:00:00 2001 From: chordpli Date: Mon, 19 Oct 2026 09:05:04 +0900 Subject: [PATCH 17/27] LR-LIB-STACK: a double-click on a stack badge toggles the stack once (#2233) A click on a grid stack badge expands or collapses the stack. A double-click, as in Lightroom, toggled it twice: the second click undid the first and the stack stayed as it was. The badge now ignores the second click of a double-click. --- crates/ui-egui/src/panels/grid.rs | 5 ++-- crates/ui-egui/src/tests_grid.rs | 41 +++++++++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index c6365b784..749403144 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1010,7 +1010,7 @@ pub fn label_param(l: Option) -> String { } /// Stack badge at the cell's top-left: the photo count on a collapsed stack's top, `i/n` on the -/// members of an expanded stack. Clicking it expands/collapses the stack. +/// members of an expanded stack. Clicking or double-clicking it expands/collapses the stack. pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: lightcraft_catalog::StackId, pos: usize, r: Rect, square: bool) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.catalog.stack(sid) else { return }; @@ -1034,7 +1034,8 @@ pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: } paint(p, Rect::from_min_size(br.min + vec2(6.0, 3.0), vec2(14.0, 14.0)), Icon::Stack, Color32::WHITE); p.galley(pos2(br.min.x + 24.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - if resp.clicked() { + // a double-click (Lightroom's way) toggles once: its second click would undo the first + if resp.clicked() && !resp.double_clicked() { let _ = app.run("stack.toggle", json!({"ids": [id.0]})); } } diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests_grid.rs index 7aafb212e..5a724de26 100644 --- a/crates/ui-egui/src/tests_grid.rs +++ b/crates/ui-egui/src/tests_grid.rs @@ -172,3 +172,44 @@ fn an_unedited_raws_thumbnail_skips_the_sensor_decode() { assert!(!skips_the_real_render(false, true)); assert!(!skips_the_real_render(false, false)); } + +/// Press and release the primary button at `at`, one frame apart from the next click (as a real +/// double-click comes in: one click per frame). +fn click_at(h: &mut Headless, at: egui::Pos2) { + h.events.push(egui::Event::PointerMoved(at)); + h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.step(); +} + +/// Double-clicking a stack badge expands or collapses the stack, as in Lightroom (issue #2233): its +/// second click used to undo the first, and the stack stayed as it was. A single click still toggles it. +#[test] +fn double_clicking_a_stack_badge_toggles_the_stack_once() { + let mut h = grid(ViewMode::PhotoGrid); + let top = PhotoId(2001); + let sid = StackId(21); + let collapsed = |h: &Headless| h.app.session.catalog.stack(sid).unwrap().collapsed; + select(&mut h, top); + let r = h.request("engine.execute", json!({"command": "stack.toggle", "params": {"ids": [top.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(collapsed(&h)); + for want in [false, true] { + let badge = widget(&h, &format!("stack:{}", top.0)).expect("stack badge drawn").center(); + click_at(&mut h, badge); + click_at(&mut h, badge); + h.settle(SETTLE); + assert_eq!(collapsed(&h), want, "a double-click toggles the stack once"); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "a double-click on the badge doesn't open the photo"); + // the next double-click is apart from this one + for _ in 0..60 { + h.step(); + } + } + let badge = widget(&h, &format!("stack:{}", top.0)).unwrap().center(); + click_at(&mut h, badge); + h.settle(SETTLE); + assert!(!collapsed(&h), "a single click toggles it"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4e0531793..3caa9a0ec 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -107,7 +107,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | -| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | +| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse (a click or double-click on a grid badge toggles the stack once, #2233), auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | From de98d3a1baf2239412d6ac8e3f87fa9ffde47626 Mon Sep 17 00:00:00 2001 From: chordpli Date: Mon, 19 Oct 2026 09:28:31 +0900 Subject: [PATCH 18/27] LR-LIB-STACK: the badge double-click test checks the second click lands on the badge (#2233) --- crates/ui-egui/src/tests_grid.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests_grid.rs index 5a724de26..6c85ecf59 100644 --- a/crates/ui-egui/src/tests_grid.rs +++ b/crates/ui-egui/src/tests_grid.rs @@ -199,6 +199,10 @@ fn double_clicking_a_stack_badge_toggles_the_stack_once() { for want in [false, true] { let badge = widget(&h, &format!("stack:{}", top.0)).expect("stack badge drawn").center(); click_at(&mut h, badge); + // the first click re-lays the grid; the second must still land on this badge, or the test + // would pass without the fix (a missed second click toggles nothing) + let moved = widget(&h, &format!("stack:{}", top.0)).expect("stack badge drawn"); + assert!(moved.contains(badge), "the badge moved from under the pointer: {moved:?}"); click_at(&mut h, badge); h.settle(SETTLE); assert_eq!(collapsed(&h), want, "a double-click toggles the stack once"); From 58c38ead2f32cf8eba9a61030d47049ab23df125 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Mon, 19 Oct 2026 10:13:51 +0200 Subject: [PATCH 19/27] LR-VIEW-ZOOM: describe the slow-drag verdict as per photo and control Co-Authored-By: Claude Opus 5.5 --- docs/parity-checklist.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 57c5937af..50b2860d9 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-18 · **Change:** minor (LR-VIEW-ZOOM: two-strike, expiring slow-drag verdict; merged with LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** patch (LR-VIEW-ZOOM: slow-drag verdict is per photo and control) · **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 @@ -131,7 +131,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s; a drag within 20 ms forgets it. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s; a drag within 20 ms forgets it. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-19 | patch | LR-VIEW-ZOOM: the slow slider-drag verdict is described as per photo and control, as the code keys it; status unchanged | | 2026-10-15 | minor | LR-VIEW-ZOOM: a slow slider-drag verdict needs two drags over 20 ms and expires after 30 s (doubling to 240 s); hidden-window frame times are ignored; the slow set forgets its oldest entry, not all (issue #330); status unchanged | | 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | | 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | From 5f17b48c8d4e6760d3f15b5586ab9fe5058e072e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 08:49:40 +0000 Subject: [PATCH 20/27] #2253: crop.aspect checks orientation before the current/toggle paths A bad `orientation` was refused only on the refit path: "current" and an unlocked "toggle" returned before the check, so {"aspect": "current", "orientation": "bogus"} succeeded silently, and a value that isn't a string fell back to the default. It is parsed first now, so every path refuses a wrong word, a number, a boolean or null, and nothing changes (CodeRabbit on PR #2280). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK --- crates/engine/src/cmd/develop.rs | 15 ++++++++------- crates/engine/src/tests_crop_aspect.rs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 38bf7a3da..e4dc9e3fd 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -642,6 +642,14 @@ pub fn specs() -> Vec { |s, p| { let id = active(s, "crop.aspect")?; let (w, h) = crop_dims(s, id); + // checked first, whatever the aspect: a bad value is an error on every path + let turn = match p.get("orientation") { + Some(Value::String(o)) if o == "crop" => Turn::LikeCrop, + Some(Value::String(o)) if o == "given" => Turn::AsGiven, + Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")), + None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven, + None => Turn::LikeCrop, + }; let locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some()); let lock_current = match p.get("aspect").and_then(Value::as_str) { Some("current") => true, @@ -656,13 +664,6 @@ pub fn specs() -> Vec { Ok(()) }); } - let turn = match p.get("orientation").and_then(Value::as_str) { - Some("crop") => Turn::LikeCrop, - Some("given") => Turn::AsGiven, - Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")), - None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven, - None => Turn::LikeCrop, - }; let aspect = match p.get("aspect") { Some(Value::String(a)) if a == "toggle" => None, Some(Value::String(a)) if a == "free" => None, diff --git a/crates/engine/src/tests_crop_aspect.rs b/crates/engine/src/tests_crop_aspect.rs index 3cfee0c05..1b8698691 100644 --- a/crates/engine/src/tests_crop_aspect.rs +++ b/crates/engine/src/tests_crop_aspect.rs @@ -66,3 +66,21 @@ fn orientation_overrides_and_is_checked() { let e = s.execute("crop.aspect", &json!({"aspect": "2x3", "orientation": "sideways"})).unwrap_err(); assert!(e.to_string().contains("orientation"), "{e}"); } + +/// A bad `orientation` is refused on every path, the lock-as-is ones ("current", an unlocked +/// "toggle") included, and whether it is a wrong word or not a string at all; nothing changes. +#[test] +fn a_bad_orientation_is_refused_before_anything_is_locked() { + let mut s = landscape_photo(); + let before = s.develop_of(s.selection.active.unwrap()).unwrap().crop.clone(); + for aspect in [json!("current"), json!("toggle"), json!("2x3"), json!([2, 3]), json!("free")] { + for orientation in [json!("sideways"), json!(1), json!(true), json!(null)] { + let e = s.execute("crop.aspect", &json!({"aspect": aspect, "orientation": orientation})).unwrap_err(); + assert!(e.to_string().contains("orientation"), "{aspect} {orientation}: {e}"); + assert_eq!(s.develop_of(s.selection.active.unwrap()).unwrap().crop, before, "{aspect} {orientation}: untouched"); + } + } + // the valid words still lock as is + aspect(&mut s, json!({"aspect": "current", "orientation": "crop"})); + assert!(s.develop_of(s.selection.active.unwrap()).unwrap().crop.aspect.is_some()); +} From e2ea657668800e0c955b05a008ab8f5981f56d70 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:29:48 +0000 Subject: [PATCH 21/27] #2114: hidden Local locations come back one at a time "Show N hidden locations" put every location removed from Local back at once. With several hidden, the row now lists them (name, path on hover): each comes back on its own, or all with Restore All. With one hidden, the row restores it directly, as before. The command side already had local.restoreHidden {path}; only the panel called it without a path. The headless test hides a second place, opens the list from the row, restores one, checks the other stays hidden, then restores the rest. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK --- crates/ui-egui/src/headless.rs | 32 ++++++++++++++++++++++ crates/ui-egui/src/panels/left.rs | 44 ++++++++++++++++++++++++++----- docs/parity-checklist.md | 2 +- docs/whats-new.md | 6 +++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 1d8b58401..e7c0c0122 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2833,8 +2833,39 @@ mod tests { assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}"); assert!(h.app.session.browse.is_some(), "hiding does not stop browsing"); assert!(dir.is_dir(), "the folder itself is untouched"); + // a second hidden place: the row lists them, and each comes back on its own (issue #2114: + // the row used to bring every hidden place back at once) + let dir2 = std::env::temp_dir().join(format!("lc-ui-hide-{}-b", std::process::id())); + let _ = std::fs::remove_dir_all(&dir2); + std::fs::create_dir_all(&dir2).unwrap(); + let path2 = dir2.to_string_lossy().to_string(); + for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), t); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + let menu = |h: &mut Headless| -> Vec { + let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), t); + w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect() + }; + assert!(menu(&mut h).is_empty(), "the list opens from the row"); assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); h.settle(SETTLE); + let items = menu(&mut h); + assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}"); + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true); + h.settle(SETTLE); + assert!(!h.app.ui.hidden_locations.iter().any(|x| x == &path), "{:?}", h.app.ui.hidden_locations); + assert!(h.app.ui.hidden_locations.iter().any(|x| x == &path2), "the other place stays hidden"); + assert!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); + // one place left: the row brings it back itself; several: Restore All + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + if !h.app.ui.hidden_locations.is_empty() { + assert!(hidden_before, "{:?}", h.app.ui.hidden_locations); + assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); + h.settle(SETTLE); + } assert!(h.app.ui.hidden_locations.is_empty()); assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); if !hidden_before { @@ -2842,6 +2873,7 @@ mod tests { assert!(ids(&mut h).contains(&format!("source:local:{path}"))); } let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); } /// Local: a kept folder stays a root while its subfolders (and other locations) are diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 3bb8deba2..107151b1e 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -793,6 +793,8 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { struct LocalNow { places: LocalPlaces, current: Option, + /// The places removed from Local, as (name, path), in the order they were hidden. + hidden: Vec<(String, String)>, } /// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, @@ -817,11 +819,14 @@ fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option { } let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); let current = browsing.as_ref().map(|b| b.path.clone()); + // a hidden built-in place keeps its name (Home, not the user's folder name) + let name_of = |path: &str| builtin.iter().find(|(_, p)| same_folder(p, path)).map_or_else(|| folder_label(path), |(n, _)| n.clone()); + let hidden = app.ui.hidden_locations.iter().map(|h| (name_of(h), h.clone())).collect(); let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); if current.is_some() { app.ui.local_browse_root = places.browse_root.clone(); } - Some(LocalNow { places, current }) + Some(LocalNow { places, current, hidden }) } /// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places. @@ -846,18 +851,43 @@ fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { } } } - let hidden = app.ui.hidden_locations.len(); + // the way back for hidden places: one comes back from the row itself; several are listed, so + // each comes back on its own, or all at once (issue #2114: the row used to bring every one back) + let hidden = now.hidden.len(); if hidden > 0 { let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden); - if row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) - .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")) - .clicked() - { - let _ = app.run("local.restoreHidden", json!({})); + let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) + .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")); + if let [(_, only)] = now.hidden.as_slice() { + if resp.clicked() { + let _ = app.run("local.restoreHidden", json!({"path": only})); + } + } else { + egui::Popup::menu(&resp).show(|ui| hidden_menu(app, ui, &now.hidden)); } } } +/// The list behind "Show N hidden locations": each place by name (its path on hover), then +/// Restore All. +fn hidden_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, hidden: &[(String, String)]) { + for (name, path) in hidden { + let r = ui.button(name).on_hover_text(path); + register(ui.ctx(), format!("local:restoreHidden:{path}"), r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({"path": path})); + ui.close(); + } + } + ui.separator(); + let r = ui.button(crate::i18n::tr("Restore All")); + register(ui.ctx(), "local:restoreHidden:all", r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({})); + ui.close(); + } +} + /// Whether two paths name the same folder, however they are spelled (separators, trailing /// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`). pub(crate) fn same_folder(a: &str, b: &str) -> bool { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4e0531793..dde3ca5a1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back, each on its own from the row's list when several are hidden, or all at once with Restore All (issue #2114); a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | diff --git a/docs/whats-new.md b/docs/whats-new.md index 2f02ffb91..1df8b1e12 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,11 @@ # What's new in LightCraft +## 2026-10-19 + +### Hidden Local locations come back one at a time +- With several locations removed from Local, "Show N hidden locations" now lists them, so each comes back on its own + (Restore All is there too). The row used to bring every hidden location back at once (issue #2114). + ## 2026-10-14 ### Model downloads time out on a name that doesn't resolve From 3a52cbe46467fa13fe2fa7f36516dc9ab2ee07fc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:41:05 +0000 Subject: [PATCH 22/27] #2114: the headless test clicks Restore All and the single-place row The test checked that Restore All was listed but clicked it only when the machine already had a hidden location, so CI never exercised that path. It now hides two places and restores them with Restore All, then hides one and checks the row restores it directly, without a list. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK --- crates/ui-egui/src/headless.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e7c0c0122..b5a674486 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2858,16 +2858,25 @@ mod tests { assert!(!h.app.ui.hidden_locations.iter().any(|x| x == &path), "{:?}", h.app.ui.hidden_locations); assert!(h.app.ui.hidden_locations.iter().any(|x| x == &path2), "the other place stays hidden"); assert!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); - // one place left: the row brings it back itself; several: Restore All + // two hidden again: Restore All brings every one back + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations); assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); h.settle(SETTLE); - if !h.app.ui.hidden_locations.is_empty() { - assert!(hidden_before, "{:?}", h.app.ui.hidden_locations); - assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); - h.settle(SETTLE); - } - assert!(h.app.ui.hidden_locations.is_empty()); + assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); + // one hidden: the row brings it back itself, no list + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path2}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert!(menu(&mut h).is_empty(), "no list for a single hidden place"); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); if !hidden_before { // (with Home hidden for this test, the folder is back inside Home's tree instead) assert!(ids(&mut h).contains(&format!("source:local:{path}"))); From f08522b8a9785c376949e58a1a04295d71b31c6f Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Tue, 20 Oct 2026 12:56:48 +0200 Subject: [PATCH 23/27] LR-VIEW-ZOOM: the 20 ms rule clears a slow verdict only on full-size drags (docs) Only a drag timed at full size (median of its full-size drafts, after the first, at most 20 ms) forgets a slow-drag verdict; a drag that drafts small from its start, or times fewer than two full-size drafts, leaves it unchanged, as `DragScale::finish` does. Co-Authored-By: Claude Opus 5.5 --- docs/parity-checklist.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 50b2860d9..95a272c46 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-19 · **Change:** patch (LR-VIEW-ZOOM: slow-drag verdict is per photo and control) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** patch (LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict) · **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 @@ -131,7 +131,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s; a drag within 20 ms forgets it. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-20 | patch | LR-VIEW-ZOOM: the 20 ms rule clears a slow-drag verdict only on a drag timed at full size; a drag that drafts small leaves it unchanged, as the code does; status unchanged | | 2026-10-19 | patch | LR-VIEW-ZOOM: the slow slider-drag verdict is described as per photo and control, as the code keys it; status unchanged | | 2026-10-15 | minor | LR-VIEW-ZOOM: a slow slider-drag verdict needs two drags over 20 ms and expires after 30 s (doubling to 240 s); hidden-window frame times are ignored; the slow set forgets its oldest entry, not all (issue #330); status unchanged | | 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | From 30fea8d34c95faf2af95523e05f5501fa9b014e2 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Tue, 20 Oct 2026 13:14:16 +0200 Subject: [PATCH 24/27] LR-VIEW-CLIPPING: profiled diagnostic views keep the sRGB histogram; GPU equivalence for the clipping overlays On a profiled display the Grey and Sharpening-mask views rendered in the display's primaries, so their histogram counted display-primary pixels, not the photo's sRGB ones. They now render in sRGB like the clipping views (`Overlay::renders_in_srgb`) and are converted for the display after; they show no photo colour, so nothing is lost. The GPU equivalence test now covers Grey (mean/max LSB limits) and the shadow/highlight clipping views (share of differing pixels, as for Visualize Spots: both renderers draw them with the same `visualize::apply`, and a 1-LSB difference at 0/255 flips a pixel). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/display.rs | 22 +++++++++++++++++----- crates/engine/src/media.rs | 10 +++++----- crates/gpu/tests/equivalence.rs | 21 +++++++++++++++++++++ crates/pipeline/src/visualize.rs | 9 +++++---- 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index 8a91a6644..518ce54aa 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -152,19 +152,31 @@ mod tests { } } - /// ⌥-drag views on a profiled display (issue #937): the histogram stays the photo's (not the - /// view's), and the clipping view is tested and drawn in sRGB, so it stays black and white. + /// ⌥-drag views on a profiled display (issue #937): the histogram stays the photo's sRGB one + /// (not the view's, nor the display-primary pixels'), 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::ShadowClip); + let mut run = |o, d: Option>| { + session.loupe_job(id, 320, 240, true).unwrap().with_display(d).with_overlay(o).run().rendered.unwrap() + }; + let plain = run(Overlay::None, Some(d.clone())); + let clip = run(Overlay::ShadowClip, Some(d.clone())); let (a, b) = (plain.histogram.mean_luma(), clip.histogram.mean_luma()); assert!((a - b).abs() < 0.02, "the photo's histogram: {a} vs {b}"); + // each channel's mean level: the photo's, as a render without a display profile counts it + let means = |h: &Histogram| { + [&h.r, &h.g, &h.b].map(|c| c.iter().enumerate().map(|(i, n)| i as f64 * f64::from(*n)).sum::() / f64::from(h.total.max(1))) + }; + let srgb = means(&run(Overlay::None, None).histogram); + for o in [Overlay::ShadowClip, Overlay::HighlightClip, Overlay::Grey, Overlay::SharpenMask] { + let v = means(&run(o, Some(d.clone())).histogram); + assert!(srgb.iter().zip(v).all(|(p, v)| (p - v).abs() < 0.05), "{o:?}: the photo's sRGB histogram {srgb:?}, got {v:?}"); + } 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(); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index d04132e0b..e25bd0232 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -968,10 +968,10 @@ 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(); - // a clipping view 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 }; + // a clipping or grey view: rendered in sRGB (the clipping test and the histogram + // read sRGB values), then converted for the display below + let srgb_view = self.request.overlay.renders_in_srgb(); + let request = if srgb_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 @@ -980,7 +980,7 @@ impl RenderJob { // 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) }; + let converted = if srgb_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}"); } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index c877e3d9d..150df5b9c 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -680,6 +680,27 @@ 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); } + // The ⌥-drag views (issue #937): clipping and grey, on an edit that clips both ends. + let mut s = DevelopSettings::default(); + typical(&mut s); + s.light.whites = 60.0; + s.light.blacks = -60.0; + check("grey overlay", &src, &raw, &s, &RenderRequest { overlay: Overlay::Grey, ..RenderRequest::fit(640, 640) }); + // The clipping views test each channel for exactly 0 / 255 (both renderers draw them with + // `visualize::apply` on their 8-bit image): like Visualize Spots, a 1-LSB difference at the + // threshold flips a pixel, so compare the share of differing pixels, and that the views agree + // on what clips. + for (overlay, edge) in [(Overlay::ShadowClip, 0u8), (Overlay::HighlightClip, 255)] { + let req = RenderRequest { overlay, ..RenderRequest::fit(640, 640) }; + let plain = render(&src, &raw, &s, &RenderRequest::fit(640, 640)).image; + 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; + let clipped = plain.data.iter().filter(|p| p[..3].contains(&edge)).count() as f64 / plain.data.len() as f64; + eprintln!("{overlay:<28?} clipped {:.3}% differing {:.4}%", clipped * 100.0, differ * 100.0); + assert!(clipped > 0.001, "{overlay:?}: the edit clips nothing to compare"); + assert!(differ < 0.002, "{overlay:?}: {differ}"); + } } #[test] diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index 3f1ab19fc..af5c2c290 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -190,10 +190,11 @@ impl Overlay { } } - /// The ⌥-drag clipping views, which test the sRGB values the histogram counts (a display - /// render draws them in sRGB, then converts). - pub fn is_clipping(self) -> bool { - matches!(self, Overlay::ShadowClip | Overlay::HighlightClip) + /// The ⌥-drag views that show none of the photo's colours: the clipping views (which test the + /// sRGB values the histogram counts) and the grey ones. A display render draws them in sRGB, + /// then converts, so their histogram (taken before the overlay) is the photo's sRGB one. + pub fn renders_in_srgb(self) -> bool { + matches!(self, Overlay::ShadowClip | Overlay::HighlightClip | Overlay::SharpenMask | Overlay::Grey) } /// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components). From 1c4b9c3c6152b02a5579b60867196df0a6b34134 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 14:48:33 +0000 Subject: [PATCH 25/27] Integration: #1863 fill the active Pick filter too (from #1044) Co-authored-by: haranishi <223786623+haranishi@users.noreply.github.com> --- crates/ui-egui/src/panels/filterbar.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index b35335cc9..24ec07c1e 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -162,6 +162,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { Flag::Reject if on => t.reject, _ => Color32::TRANSPARENT, }; + let icon = if on && flag == Flag::Pick { Icon::FlagPickFilled } else { icon }; let resp = toggle(ui, id, on, tip, |p, r, c| paint(p, r, icon, if color == Color32::TRANSPARENT { c } else { color })); if resp.clicked() { filter(app, json!({"flag": if on { Value::Null } else { json!(id) }})); From 346a5be628e0854395368d54d5721d61c6054019 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 14:50:35 +0000 Subject: [PATCH 26/27] Integration: #2012 fit the hover preview to the drawn size; keep the zoomed whole frame canvas-sized --- crates/ui-egui/src/headless.rs | 37 +++++++++++++++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 5 +++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index ea75f22d5..a29b85839 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -4387,6 +4387,43 @@ mod tests { h.settle(SETTLE); } + /// A hover preview gets the loupe's display fit: it is the size the photo is drawn at, as the + /// loupe render is, not a preview-level render the GPU shrinks. + #[test] + fn a_hover_preview_is_fitted_to_the_drawn_size() { + use crate::render::Slot; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.profiles"}), t); + h.settle(SETTLE); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); + let size = |s: Slot| h.app.renderer.textures.get(&s).map(|t| t.size); + let (main, hover) = (size(Slot::Main).expect("loupe render"), size(Slot::Hover).expect("hover render")); + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "the loupe render is fitted: {main:?}"); + assert!(hover[0].abs_diff(main[0]) <= 1 && hover[1].abs_diff(main[1]) <= 1, "hover {hover:?} vs loupe {main:?}"); + } + + /// Zoomed in under a window render, the hidden whole-frame render stays canvas-sized instead of + /// rising to the preview level. + #[test] + fn a_zoomed_view_keeps_its_whole_frame_render_canvas_sized() { + use crate::render::Slot; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + h.settle(SETTLE); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0)); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let main = h.app.renderer.textures.get(&Slot::Main).expect("whole-frame render").size; + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "{main:?}"); + } + #[test] fn trackpad_navigation_keeps_the_image_cursor_between_events() { let mut h = demo([1000.0, 700.0]); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 17b38b320..065868759 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -581,7 +581,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // At rest the whole frame renders at no less than the preview source level: the source is then // copied 1:1 (no resample in the pipeline, whose prefilter and bilinear pass soften detail near // the output's limit) and shrunk once, by the Lanczos display fit below. - let want = if interacting { + // (zoomed in under a window render, the whole frame is hidden: it stays canvas-sized) + let want = if interacting || plan.window_edge.is_some() { plan.main_edge } else { // (a user's explicit Preview size below the preview level still caps it) @@ -624,6 +625,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // once it is ready (nothing is committed) let hover_key = match app.hover_preview.clone() { Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| { + // (the same display fit as the After view, drawn texel for texel like it) + let job = job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize); let key = job.key; app.renderer.request(Slot::Hover, job, 105); (key, h.label) From 5aa5c6681ffe4a6c8d3d3432c71dcd72af11ae13 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 14:50:55 +0000 Subject: [PATCH 27/27] Integration: #995 time drag drafts only while the GPU draws the view --- crates/ui-egui/src/panels/detail.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 090a10dad..a1ef79609 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -568,8 +568,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); } // time the full-size drafts where they are what shows: under a window the whole-frame draft - // is small anyway, and the frame's time is the window's upload - if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none()) { + // is small anyway, and the frame's time is the window's upload. A view the GPU doesn't draw + // (⌥ pressed mid-drag) renders on the CPU and says nothing about the GPU drafts. + if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none() && gpu) { app.drag_scale.observe(key, ms, app.perf.host_frame_ms); } // a pinch changes the zoom every frame: keep the sizes of before it until it is quiet