# Conflicts:
#	docs/parity-checklist.md
This commit is contained in:
Claude committed 2026-10-11 12:00:17 +00:00
commit 111bb42aa3
8 files changed
+382 -45

No files matched your search

+39
View File
@@ -758,6 +758,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<StageCache>) -> Self {
self.stages = Some(stages);
@@ -1515,6 +1537,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| {
+34
View File
@@ -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> {
@@ -240,6 +248,32 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option<
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);
+2
View File
@@ -63,6 +63,8 @@ mod tests_dialog_fit;
#[cfg(test)]
mod tests_filmstrip;
#[cfg(test)]
mod tests_grading;
#[cfg(test)]
mod tests_grid;
#[cfg(test)]
mod tests_info_dialogs;
+2
View File
@@ -319,6 +319,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
@@ -456,6 +457,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
+158 -41
View File
@@ -267,7 +267,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);
});
@@ -1159,55 +1159,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<Pos2> = (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::<f32>(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, 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;
+23 -1
View File
@@ -463,8 +463,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,
@@ -900,6 +904,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,
@@ -944,6 +949,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)
}
+121
View File
@@ -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<egui::Rect> {
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<f32> = ["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());
}
+3 -3
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
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` | |
@@ -786,4 +786,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-11 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes from sensor data (57% of its C-RAW files opened as a preview: QP-map escape); status unchanged (🟡) |
| 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. |
| 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`) |