From d6cfab8223cde8f35a3ee68b436458b781c382a3 Mon Sep 17 00:00:00 2001 From: Sajidur Rahman Date: Sun, 11 Oct 2026 03:24:09 +0600 Subject: [PATCH] LR-EDIT-COLOR-GRADING: grading tabs, per-wheel eye, precision guides (#737, #559) Color Grading gets 3-Way / Shadows / Midtones / Highlights tabs (3-Way by default). 3-Way shows three equal wheels; a range tab shows one large wheel with that range's Hue, Saturation and Luminance sliders. Blending and Balance are in every tab. Precision (#559): - Shift-drag on a wheel locks the hue and changes saturation only; the whole spoke is drawn as a guide. Shift can be pressed or released mid-drag. - A white dot marks each wheel's neutral centre. - The large wheel has dashed 25/50/75 % saturation rings. - 3-Way shows the hovered wheel's hue and saturation above it. Each wheel has an eye (bottom-left, on hover) that hides its effect for viewing only: RenderJob::with_hidden_grading renders those wheels neutral under their own key, never cached as the photo's preview. Not an edit, not saved, reset on another photo; export is unaffected. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media.rs | 39 ++++++ crates/ui-egui/src/icons.rs | 34 +++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/detail.rs | 2 + crates/ui-egui/src/panels/edit.rs | 199 ++++++++++++++++++++++------ crates/ui-egui/src/state.rs | 24 +++- crates/ui-egui/src/tests_grading.rs | 121 +++++++++++++++++ docs/parity-checklist.md | 6 +- 8 files changed, 382 insertions(+), 45 deletions(-) create mode 100644 crates/ui-egui/src/tests_grading.rs diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6d5b548f..6cad69d5 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -733,6 +733,28 @@ impl RenderJob { self } + /// Render with some colour grading wheels (shadows, midtones, highlights) switched off for + /// viewing (the wheel eyes in the Edit panel). The photo's settings are untouched; the result + /// gets its own key and is never cached as the photo's preview or thumbnail. + pub fn with_hidden_grading(mut self, hidden: [bool; 3]) -> Self { + if !hidden.iter().any(|h| *h) { + return self; + } + let mut s = (*self.settings).clone(); + let g = &mut s.grading; + for (off, wheel) in hidden.into_iter().zip([&mut g.shadows, &mut g.midtones, &mut g.highlights]) { + if off { + *wheel = lightcraft_develop::Wheel::default(); + } + } + let bits = hidden.iter().enumerate().fold(0u64, |b, (i, h)| b | ((*h as u64) << i)); + self.key ^= s.hash64() ^ self.settings.hash64() ^ bits.wrapping_mul(0xd6e8_feb8_6659_fd93); + self.settings = Arc::new(s); + self.cache = None; + self.view_cache = None; + self + } + /// Reuse `stages` across this view's renders. pub fn with_stages(mut self, stages: Arc) -> Self { self.stages = Some(stages); @@ -1466,6 +1488,23 @@ mod tests { assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix"); } + #[test] + fn hidden_grading_wheels_render_neutral_without_touching_the_photo() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + s.execute("develop.set", &serde_json::json!({"values": {"grading.shadows.sat": 60, "grading.highlights.sat": 40}})).unwrap(); + let job = s.loupe_job(id, 64, 64, true).unwrap(); + let same = job.clone().with_hidden_grading([false; 3]); + assert_eq!(same.key, job.key, "nothing hidden: the job is unchanged"); + assert!(same.view_cache.is_some()); + let hidden = job.clone().with_hidden_grading([true, false, false]); + assert_eq!(hidden.settings.grading.shadows.sat, 0.0); + assert_eq!(hidden.settings.grading.highlights.sat, 40.0); + assert_ne!(hidden.key, job.key, "a hidden wheel gets its own result key"); + assert!(hidden.view_cache.is_none() && hidden.cache.is_none(), "never cached as the photo's preview"); + assert_eq!(s.catalog.photo(id).unwrap().develop.grading.shadows.sat, 60.0, "the edit is untouched"); + } + #[test] fn decoder_info_survives_render_jobs_cache_and_eviction() { let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 9a471889..4c8235dd 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -83,6 +83,14 @@ pub enum Icon { Chat, /// Face boxes on/off: four corner brackets round a small face. FaceBox, + /// Colour grading, all three wheels: three small circles. + GradeAll, + /// Colour grading, shadows wheel: a filled circle. + GradeShadows, + /// Colour grading, midtones wheel: a half-filled circle. + GradeMidtones, + /// Colour grading, highlights wheel: an open circle with rays. + GradeHighlights, } struct Pen<'a> { @@ -224,6 +232,32 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(10.0, 4.0, 9.0, 32.0, 148.0); pen.circle(10.0, 10.0, 2.5); } + GradeAll => { + pen.circle(10.0, 6.0, 3.2); + pen.circle(5.5, 13.5, 3.2); + pen.circle(14.5, 13.5, 3.2); + } + GradeShadows => { + pen.circle(10.0, 10.0, 6.0); + pen.dot(10.0, 10.0, 6.0); + } + GradeMidtones => { + pen.circle(10.0, 10.0, 6.0); + let half: Vec<(f32, f32)> = (0..=12) + .map(|i| { + let a = (90.0 + 180.0 * i as f32 / 12.0).to_radians(); + (10.0 + 6.0 * a.cos(), 10.0 + 6.0 * a.sin()) + }) + .collect(); + pen.fill(&half, pen.s.color); + } + GradeHighlights => { + pen.circle(10.0, 10.0, 4.0); + for i in 0..8 { + let a = (i as f32 * 45.0).to_radians(); + pen.line(&[(10.0 + 6.0 * a.cos(), 10.0 + 6.0 * a.sin()), (10.0 + 8.0 * a.cos(), 10.0 + 8.0 * a.sin())]); + } + } EyeOff => { pen.arc(10.0, 16.0, 9.0, 212.0, 328.0); pen.arc(10.0, 4.0, 9.0, 32.0, 148.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0..0e48e366 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -49,6 +49,8 @@ mod tests_date_picker; #[cfg(test)] mod tests_filmstrip; #[cfg(test)] +mod tests_grading; +#[cfg(test)] mod tests_grid; #[cfg(test)] mod tests_keymap; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae2..b6f6d444 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -288,6 +288,7 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt }; // a drag renders drafts of the window, as it does of the whole frame let job = if c.interacting { job.draft() } else { job }; + let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) }; let look = job.settings.hash64(); let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look }; // windows of other photos, looks and zooms of this side can't be drawn any more @@ -425,6 +426,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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 }; let job = job.with_overlay(view_overlay(app, &d)).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); } // hovering a preset or profile: the photo with that look, shown instead of the loupe render diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 56f405ea..b2281fdc 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -254,7 +254,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.ui.toggle_flyout("grading"); } if open { - grading(app, ui, d); + grading(app, ui, d, id); } ui.add_space(8.0); }); @@ -1116,55 +1116,172 @@ fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { // ------------------------------------------------------------------------------ colour grading -fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - let t = Tokens::get(ui.ctx()); +/// The colour grading tabs: key, tooltip, icon. +const GRADING_TABS: [(&str, &str, Icon); 4] = [ + ("3way", "3-Way", Icon::GradeAll), + ("shadows", "Shadows", Icon::GradeShadows), + ("midtones", "Midtones", Icon::GradeMidtones), + ("highlights", "Highlights", Icon::GradeHighlights), +]; + +/// The wheels, in the order of `UiState::grading_hidden`. +const GRADING_WHEELS: [(&str, &str); 3] = [("shadows", "Shadows"), ("midtones", "Midtones"), ("highlights", "Highlights")]; + +fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId) { + let mode = GRADING_TABS.iter().find(|(k, ..)| *k == app.ui.grading_mode).map_or("3way", |(k, ..)| *k); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 2 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (key, tip, icon) in GRADING_TABS { + if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, tip).clicked() { + app.ui.grading_mode = key.to_string(); + } + } + }); + }); let w = ui.available_width(); - let (area, _) = ui.allocate_exact_size(vec2(w, 250.0), Sense::hover()); - let big = 44.0; - let small = 38.0; - let wheels = [ - ("midtones", "Midtones", pos2(area.center().x, area.top() + 60.0), big), - ("shadows", "Shadows", pos2(area.left() + w * 0.28, area.top() + 170.0), small), - ("highlights", "Highlights", pos2(area.left() + w * 0.72, area.top() + 170.0), small), - ]; - for (key, label, c, rad) in wheels { - let wh = match key { - "shadows" => d.grading.shadows, - "midtones" => d.grading.midtones, - _ => d.grading.highlights, - }; - let r = Rect::from_center_size(c, vec2(rad * 2.0, rad * 2.0)); - let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag()); - register(ui.ctx(), format!("wheel:{key}"), r); - paint_wheel(ui.painter(), c, rad); - let a = (wh.hue as f32).to_radians(); - let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0); - ui.painter().circle_filled(dot, 5.0, Color32::WHITE); - ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK)); - ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, crate::i18n::tr(label), t.font(12.0), t.text_label); - if resp.drag_started() { - let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)})); + if mode == "3way" { + // three wheels of one size: midtones on top, shadows and highlights below + let rad = 40.0; + let (area, _) = ui.allocate_exact_size(vec2(w, 276.0), Sense::hover()); + let at = [ + pos2(area.left() + w * 0.28, area.top() + 196.0), + pos2(area.center().x, area.top() + 70.0), + pos2(area.left() + w * 0.72, area.top() + 196.0), + ]; + for (i, c) in at.into_iter().enumerate() { + grading_wheel(app, ui, d, photo, i, c, rad, true); } - if (resp.dragged() || resp.clicked()) - && let Some(q) = resp.interact_pointer_pos() - { - let v = q - c; - let hue = ((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0; - let sat = (v.length() / rad * 100.0).min(100.0); - let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); + for c in ["grading.shadows.lum", "grading.midtones.lum", "grading.highlights.lum"] { + control(app, ui, d, c, true); } - if resp.drag_stopped() || resp.clicked() { - let _ = app.run("develop.endInteraction", json!({})); - } - if resp.double_clicked() { - let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}})); + } else { + let i = GRADING_WHEELS.iter().position(|(k, _)| *k == mode).unwrap_or(1); + let rad = (w * 0.3).clamp(40.0, 80.0); + let (area, _) = ui.allocate_exact_size(vec2(w, rad * 2.0 + 40.0), Sense::hover()); + grading_wheel(app, ui, d, photo, i, pos2(area.center().x, area.top() + rad + 10.0), rad, false); + for part in ["hue", "sat", "lum"] { + control(app, ui, d, &format!("grading.{mode}.{part}"), true); } } - for c in ["grading.shadows.lum", "grading.midtones.lum", "grading.highlights.lum", "grading.blending", "grading.balance"] { + for c in ["grading.blending", "grading.balance"] { control(app, ui, d, c, true); } } +/// One colour grading wheel (`i` into [`GRADING_WHEELS`]) centred on `c`: drag to pick hue and +/// saturation, double-click to reset, and an eye at its bottom-left corner that hides its effect +/// on the photo for viewing. `readout`: show hue and saturation centred above the wheel while +/// hovered (3-Way); otherwise the wheel is the large one and gets saturation guide rings. +#[allow(clippy::too_many_arguments)] +fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId, i: usize, c: Pos2, rad: f32, readout: bool) { + let t = Tokens::get(ui.ctx()); + let Some(&(key, label)) = GRADING_WHEELS.get(i) else { return }; + let wh = match key { + "shadows" => d.grading.shadows, + "midtones" => d.grading.midtones, + _ => d.grading.highlights, + }; + let hidden = app.ui.grading_hidden_for(photo).get(i).copied().unwrap_or(false); + let r = Rect::from_center_size(c, vec2(rad * 2.0, rad * 2.0)); + // the corners (eye, readout) count as the wheel for hovering + let zone = r.expand(10.0); + let hovering = ui.rect_contains_pointer(zone); + let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag()); + register(ui.ctx(), format!("wheel:{key}"), r); + paint_wheel(ui.painter(), c, rad); + if hidden { + ui.painter().circle_filled(c, rad, Color32::from_black_alpha(150)); + } + // the large single wheel: dashed rings at 25 / 50 / 75 % saturation to judge strength by eye + if !readout { + for f in [0.25f32, 0.5, 0.75] { + let ring_rad = rad * f; + let n = (ring_rad * std::f32::consts::TAU / 2.0).max(8.0) as usize; + let ring: Vec = (0..=n).map(|k| c + egui::Vec2::angled(k as f32 / n as f32 * std::f32::consts::TAU) * ring_rad).collect(); + ui.painter().extend(egui::Shape::dashed_line(&ring, Stroke::new(0.8, Color32::from_white_alpha(45)), 4.0, 3.5)); + } + } + let a = (wh.hue as f32).to_radians(); + let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0); + // Shift-drag: saturation only, the hue locked to the spoke the point is on (as in Lightroom). + // Pressing or releasing Shift mid-drag turns the lock on or off there. + let lock_id = egui::Id::new(("wheelHueLock", key)); + let shift = ui.input(|i| i.modifiers.shift); + let lock = match resp.interact_pointer_pos() { + Some(q) if resp.dragged() && shift => { + // a point at the centre has no hue yet: lock to where the pointer is + let start = if wh.sat >= 1.0 { wh.hue as f32 } else { wheel_pick(q - c, rad, None).0 }; + Some(ui.data_mut(|m| *m.get_temp_mut_or_insert_with(lock_id, || start))) + } + _ => { + ui.data_mut(|m| m.remove::(lock_id)); + None + } + }; + // while locked: the whole spoke, a guide for the saturation-only move + if let Some(h) = lock { + let a = h.to_radians(); + ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * rad], Stroke::new(1.5, Color32::from_white_alpha(170))); + } + // the neutral centre (issue #559), under the point + ui.painter().circle_filled(c, 2.0, Color32::WHITE); + ui.painter().circle_filled(dot, 5.0, if hidden { Color32::from_gray(150) } else { Color32::WHITE }); + ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK)); + ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, crate::i18n::tr(label), t.font(12.0), t.text_label); + if resp.drag_started() { + let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)})); + } + if (resp.dragged() || resp.clicked()) + && let Some(q) = resp.interact_pointer_pos() + { + let (hue, sat) = wheel_pick(q - c, rad, lock); + let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); + } + if resp.drag_stopped() || resp.clicked() { + let _ = app.run("develop.endInteraction", json!({})); + } + if resp.double_clicked() { + let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}})); + } + // hue and saturation readout: centred above the wheel, shown like the eye (no fade) + if readout && (hovering || resp.dragged()) { + // set a little below the gap's midpoint: the eye reads it as centred there + ui.painter().text( + pos2(c.x, r.top() - 13.0), + Align2::CENTER_CENTER, + format!("H {}° S {}", wh.hue.round() as i64, wh.sat.round() as i64), + t.font(11.0), + t.text_label, + ); + } + // the eye: on top of the wheel, shown while hovering or while the wheel is hidden + if hovering || hidden { + let eye = Rect::from_center_size(r.left_bottom() + vec2(2.0, -2.0), vec2(16.0, 16.0)); + let er = ui.interact(eye, egui::Id::new(("wheelEye", key)), Sense::click()); + register(ui.ctx(), format!("wheelEye:{key}"), eye); + paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, if hidden { t.text_disabled } else { t.icon }); + let er = er.on_hover_text(crate::i18n::tr(if hidden { "Show this wheel's effect" } else { "Hide this wheel's effect" })); + if er.clicked() { + app.ui.set_grading_hidden(photo, i, !hidden); + } + } +} + +/// Hue (degrees) and saturation (0..100) for a pointer at offset `v` from a wheel's centre. +/// `lock`: keep this hue and take the saturation from `v` projected onto its spoke (Shift-drag). +pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option) -> (f32, f32) { + let rad = rad.max(1.0); + match lock { + Some(h) => { + let a = h.to_radians(); + let along = v.x * a.cos() - v.y * a.sin(); + (h, (along / rad * 100.0).clamp(0.0, 100.0)) + } + None => (((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0, (v.length() / rad * 100.0).min(100.0)), + } +} + fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) { let mut mesh = Mesh::default(); let n = 48; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b178a167..f9ae346c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -422,8 +422,12 @@ pub struct UiState { pub curve_channel: String, /// Selected mixer mode: "hue" | "saturation" | "luminance" | "all". pub mixer_mode: String, - /// Selected colour grading wheel: "3way" | "shadows" | "midtones" | "highlights" | "global". + /// Selected colour grading tab: "3way" | "shadows" | "midtones" | "highlights". pub grading_mode: String, + /// Colour grading wheels (shadows, midtones, highlights) hidden for viewing by their eye, and + /// the photo that applies to: not an edit, and forgotten on moving to another photo or quitting. + #[serde(skip)] + pub grading_hidden: Option<(lightcraft_catalog::PhotoId, [bool; 3])>, /// Active on-canvas tool: "", "brush", "linear", "radial", "wbPicker", "straighten", "remove". pub tool: String, pub brush_size: f32, @@ -845,6 +849,7 @@ impl Default for UiState { curve_channel: "parametric".into(), mixer_mode: "hue".into(), grading_mode: "3way".into(), + grading_hidden: None, tool: String::new(), brush_size: 0.04, brush_feather: 50.0, @@ -888,6 +893,23 @@ impl Default for UiState { } impl UiState { + /// Which colour grading wheels (shadows, midtones, highlights) are hidden for viewing `photo`. + pub fn grading_hidden_for(&self, photo: lightcraft_catalog::PhotoId) -> [bool; 3] { + match self.grading_hidden { + Some((p, h)) if p == photo => h, + _ => [false; 3], + } + } + + /// Show or hide colour grading wheel `i` (0 shadows, 1 midtones, 2 highlights) on `photo`. + pub fn set_grading_hidden(&mut self, photo: lightcraft_catalog::PhotoId, i: usize, hidden: bool) { + let mut h = self.grading_hidden_for(photo); + if let Some(slot) = h.get_mut(i) { + *slot = hidden; + } + self.grading_hidden = h.iter().any(|x| *x).then_some((photo, h)); + } + pub fn section_open(&self, id: &str) -> bool { self.open_sections.iter().any(|s| s == id) } diff --git a/crates/ui-egui/src/tests_grading.rs b/crates/ui-egui/src/tests_grading.rs new file mode 100644 index 00000000..8b6ec7c7 --- /dev/null +++ b/crates/ui-egui/src/tests_grading.rs @@ -0,0 +1,121 @@ +//! Headless tests of the Color Grading flyout (issue #737): the 3-Way / Shadows / Midtones / +//! Highlights tabs, the equal-size wheels, and each wheel's eye that hides its effect for viewing. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn demo() -> 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, 2400.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("color") { + h.app.ui.toggle_section("color"); + } + if !h.app.ui.flyout_open("grading") { + h.app.ui.toggle_flyout("grading"); + } + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +#[test] +fn three_way_is_the_default_with_three_equal_wheels() { + let h = demo(); + assert_eq!(h.app.ui.grading_mode, "3way"); + let sizes: Vec = ["shadows", "midtones", "highlights"].iter().map(|k| widget(&h, &format!("wheel:{k}")).unwrap().width()).collect(); + assert!(sizes.iter().all(|w| (w - sizes[0]).abs() < 0.5), "{sizes:?}"); + for tab in ["3way", "shadows", "midtones", "highlights"] { + assert!(widget(&h, &format!("icon:grading-{tab}")).is_some(), "tab {tab}"); + } +} + +#[test] +fn a_range_tab_shows_one_bigger_wheel_and_its_hue_and_saturation() { + let mut h = demo(); + let small = widget(&h, "wheel:shadows").unwrap().width(); + click(&mut h, "icon:grading-shadows"); + assert_eq!(h.app.ui.grading_mode, "shadows"); + assert!(widget(&h, "wheel:shadows").unwrap().width() > small); + assert!(widget(&h, "wheel:midtones").is_none() && widget(&h, "wheel:highlights").is_none()); + for c in ["grading.shadows.hue", "grading.shadows.sat", "grading.shadows.lum", "grading.blending", "grading.balance"] { + assert!(h.app.widgets.iter().any(|(w, _)| w.contains(c)), "slider {c}"); + } + assert!(!h.app.widgets.iter().any(|(w, _)| w.contains("grading.midtones.lum"))); +} + +#[test] +fn the_eye_hides_a_wheel_for_viewing_only_and_resets_on_another_photo() { + let mut h = demo(); + let id = h.app.session.active().unwrap(); + h.app.session.execute("develop.set", &json!({"values": {"grading.midtones.sat": 50}})).unwrap(); + h.step(); + let r = h.request("ui.hoverWidget", json!({"id": "wheel:midtones"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + click(&mut h, "wheelEye:midtones"); + assert_eq!(h.app.ui.grading_hidden_for(id), [false, true, false]); + // not an edit + assert_eq!(h.app.session.catalog.photo(id).unwrap().develop.grading.midtones.sat, 50.0); + // the eye stays on screen while the wheel is hidden, and shows it again + h.request("ui.move", json!({"x": 5.0, "y": 5.0}), T); + h.step(); + assert!(widget(&h, "wheelEye:midtones").is_some()); + click(&mut h, "wheelEye:midtones"); + assert_eq!(h.app.ui.grading_hidden_for(id), [false; 3]); + // hidden state belongs to one photo + h.app.ui.set_grading_hidden(id, 0, true); + let other = lightcraft_catalog::PhotoId(id.0 + 1); + assert_eq!(h.app.ui.grading_hidden_for(other), [false; 3]); +} + +#[test] +fn hovering_a_three_way_wheel_shows_its_hue_and_saturation() { + let mut h = demo(); + h.app.session.execute("develop.set", &json!({"values": {"grading.shadows.hue": 220, "grading.shadows.sat": 45}})).unwrap(); + h.step(); + let readout = "H 220° S 45"; + assert!(!h.painted_text().iter().any(|t| t == readout), "hidden until hovered"); + let r = h.request("ui.hoverWidget", json!({"id": "wheel:shadows"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.painted_text().iter().any(|t| t == readout), "{:?}", h.painted_text()); +} + +#[test] +fn shift_drag_keeps_the_hue_and_changes_only_saturation() { + use crate::panels::edit::wheel_pick; + let close = |a: f32, b: f32| (a - b).abs() < 0.01; + // free: hue from the angle (screen y points down), saturation from the distance + let (h, s) = wheel_pick(egui::vec2(0.0, -20.0), 40.0, None); + assert!(close(h, 90.0) && close(s, 50.0), "{h} {s}"); + // locked at 0°: a rough drag up and right keeps the hue; only the part along the spoke counts + let (h, s) = wheel_pick(egui::vec2(30.0, -12.0), 40.0, Some(0.0)); + assert!(close(h, 0.0) && close(s, 75.0), "{h} {s}"); + // past the centre or the rim: clamped to 0..100 + assert_eq!(wheel_pick(egui::vec2(-10.0, 0.0), 40.0, Some(0.0)).1, 0.0); + assert_eq!(wheel_pick(egui::vec2(90.0, 0.0), 40.0, Some(0.0)).1, 100.0); + // a degenerate radius doesn't divide by zero + assert!(wheel_pick(egui::vec2(1.0, 1.0), 0.0, None).1.is_finite()); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 09941108..a359f897 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-10 · **Change:** minor (renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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 @@ -196,7 +196,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode | | LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | ✅ | `ctl:bw.*`, `cmd:develop.autoBwMix` | eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) | | LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | `cmd:pointColor.pick`, `cmd:pointColor.delete` | | -| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*` | | +| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; Shift-drag locks the hue and changes saturation only, showing the spoke as a guide; the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | | LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | | LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | @@ -777,4 +777,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | -| 2026-09-30 – 2026-10-10 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) | +| 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) |