mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
8 files changed
+382
-45
No files matched your search
@@ -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| {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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`) |
|
||||
Reference in new issue
Block a user