diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs new file mode 100644 index 000000000..848a02a43 --- /dev/null +++ b/crates/engine/src/cmd/curves.rs @@ -0,0 +1,58 @@ +//! Tone-curve commands: reset a channel or the whole curve. + +use lightcraft_develop::ToneCurve; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_active, str_param}; +use crate::{Result, Session}; + +/// Channels `curve.reset` accepts. +pub const RESET_CHANNELS: [&str; 7] = ["all", "point", "parametric", "master", "red", "green", "blue"]; + +/// `c` with `channel` back at its default: `all` (the whole Tone Curve, incl. Refine Saturation), +/// `point` (the four point curves), `parametric` (region sliders and splits) or one point channel. +pub fn reset_channel(c: &mut ToneCurve, channel: &str) -> std::result::Result<(), String> { + let d = ToneCurve::default(); + match channel { + "all" => *c = d, + "point" => { + c.master.clear(); + c.red.clear(); + c.green.clear(); + c.blue.clear(); + } + "parametric" => { + (c.highlights, c.lights, c.darks, c.shadows) = (d.highlights, d.lights, d.darks, d.shadows); + (c.split_shadows, c.split_mid, c.split_highlights) = (d.split_shadows, d.split_mid, d.split_highlights); + } + "master" | "rgb" | "luma" => c.master.clear(), + "red" => c.red.clear(), + "green" => c.green.clear(), + "blue" => c.blue.clear(), + other => return Err(format!("unknown channel `{other}` (one of {})", RESET_CHANNELS.join(", "))), + } + Ok(()) +} + +fn reset(s: &mut Session, p: &Value) -> Result { + const C: &str = "curve.reset"; + let id = s.active().ok_or_else(|| bad(C, "no active photo"))?; + let channel = str_param(p, "channel").unwrap_or("all"); + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + reset_channel(&mut d.curve, channel).map_err(|e| bad(C, e))?; + let label = if channel == "all" { "Reset Tone Curve".to_string() } else { format!("Reset Tone Curve ({channel})") }; + s.set_develop(id, d, &label)?; + Ok(json!({"channel": channel})) +} + +pub fn specs() -> Vec { + vec![cmd!( + "curve.reset", + "Reset Tone Curve", + [], + None, + "{channel?: all|point|parametric|master|red|green|blue (default all)} — the active photo's tone curve (or one channel) back to linear", + has_active, + reset + )] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9c462423f..ac3799865 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -9,6 +9,7 @@ mod browse; mod color; pub(crate) mod convert; mod cull; +pub mod curves; mod develop; mod edit; mod export; @@ -118,6 +119,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(library::specs()); v.extend(develop::specs()); v.extend(color::specs()); + v.extend(curves::specs()); v.extend(masks::specs()); v.extend(organize::specs()); v.extend(keywords::specs()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index baf1b5b76..281a7a5f6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -671,3 +671,35 @@ fn mask_components_invert_duplicate_rename_change_mode_and_delete() { assert!(active_dev(&s).masks.is_empty()); assert_eq!(s.active_mask, None); } + +#[test] +fn curve_reset_by_channel_and_whole() { + let mut s = demo(); + let s_curve = [[0.0, 0.0], [0.25, 0.15], [0.75, 0.85], [1.0, 1.0]]; + for ch in ["master", "red", "green", "blue"] { + s.execute("develop.curve", &json!({"channel": ch, "points": s_curve})).unwrap(); + } + s.execute("develop.set", &json!({"control": "curve.shadows", "value": 30})).unwrap(); + s.execute("develop.set", &json!({"control": "curve.refineSaturation", "value": 50})).unwrap(); + // one channel + s.execute("curve.reset", &json!({"channel": "red"})).unwrap(); + let c = active_dev(&s).curve; + assert!(c.red.is_empty() && c.green.len() == 4 && c.master.len() == 4, "only red resets"); + assert_eq!(c.shadows, 30.0); + // parametric only + s.execute("curve.reset", &json!({"channel": "parametric"})).unwrap(); + let c = active_dev(&s).curve; + assert_eq!(c.shadows, 0.0); + assert_eq!(c.master.len(), 4, "point curves stay"); + // all point curves, then undo restores them in one step + s.execute("curve.reset", &json!({"channel": "point"})).unwrap(); + let c = active_dev(&s).curve; + assert!(c.master.is_empty() && c.green.is_empty() && c.blue.is_empty()); + assert_eq!(c.refine_saturation, 50.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(active_dev(&s).curve.master.len(), 4); + // everything (the default) + s.execute("curve.reset", &json!({})).unwrap(); + assert_eq!(active_dev(&s).curve, lightcraft_develop::ToneCurve::default()); + assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err()); +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index a22effc37..ef5b94a32 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -598,7 +598,18 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev if sel { ui.painter().line_segment([r.left_bottom() + vec2(4.0, 1.0), r.right_bottom() + vec2(-4.0, 1.0)], Stroke::new(2.0, t.text)); } - if resp.clicked() { + let name = match ch { + "parametric" => "Parametric curve", + "master" => "Point curve", + "red" => "Red channel", + "green" => "Green channel", + _ => "Blue channel", + }; + let resp = resp.on_hover_text(format!("{name} — double-click to reset it")); + if resp.double_clicked() { + app.ui.curve_channel = ch.into(); + let _ = app.run("curve.reset", json!({"channel": ch})); + } else if resp.clicked() { app.ui.curve_channel = ch.into(); } } @@ -614,6 +625,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let r = Rect::from_min_size(pos2(outer.left() + 24.0, outer.top() + 6.0), vec2(side, side)); let resp = ui.interact(r, egui::Id::new(("curve", &ch)), Sense::click_and_drag()); register(ui.ctx(), "curve", r); + resp.context_menu(|ui| curve_reset_menu(app, ui, &ch)); let p = ui.painter(); p.rect_filled(r, 2.0, t.canvas); for i in 1..4 { @@ -652,6 +664,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev }) .collect(); p.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::from_gray(220)))); + curve_footer(app, ui, d); for c in ["curve.highlights", "curve.lights", "curve.darks", "curve.shadows"] { control(app, ui, d, c, true); } @@ -772,10 +785,52 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let _ = app.run("develop.endInteraction", json!({})); } ui.data_mut(|dd| dd.insert_temp(drag_id, dragging)); + curve_footer(app, ui, d); control(app, ui, d, "curve.refineSaturation", true); let _ = id; } +/// The row under the curve graph: reset every curve. +fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, _d: &DevelopSettings) { + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = text_button(ui, "curveReset", "Reset", false) + .on_hover_text("Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)"); + if r.clicked() { + let _ = app.run("curve.reset", json!({"channel": "all"})); + } + }); + }); + }); +} + +/// Right-click menu of the curve graph. +fn curve_reset_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, ch: &str) { + let label = if ch == "parametric" { "Reset Parametric Curve".to_string() } else { format!("Reset {} Channel", channel_label(ch)) }; + let r = ui.button(label); + register(ui.ctx(), "curveMenu:resetChannel", r.rect); + if r.clicked() { + let _ = app.run("curve.reset", json!({"channel": ch})); + ui.close(); + } + let r = ui.button("Reset All Curves"); + register(ui.ctx(), "curveMenu:resetAll", r.rect); + if r.clicked() { + let _ = app.run("curve.reset", json!({"channel": "all"})); + ui.close(); + } +} + +fn channel_label(ch: &str) -> &'static str { + match ch { + "red" => "Red", + "green" => "Green", + "blue" => "Blue", + _ => "RGB", + } +} + /// Toggle for a targeted-adjustment tool (`tool` = `tat:`). fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) { let active = app.ui.tool == tool; diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests_curve.rs index ebe3f9ca8..7d283b4a9 100644 --- a/crates/ui-egui/src/tests_curve.rs +++ b/crates/ui-egui/src/tests_curve.rs @@ -69,6 +69,40 @@ fn dragging_a_point_moves_it_in_both_axes_with_one_undo_step() { assert_eq!(h.app.session.undo.len(), undo_before + 1, "one undo step per drag"); } +fn click_widget(h: &mut Headless, id: &str, count: u64) { + let r = h.request("ui.clickWidget", json!({"id": id, "count": count}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); +} + +const S_CURVE: [[f64; 2]; 4] = [[0.0, 0.0], [0.25, 0.15], [0.75, 0.85], [1.0, 1.0]]; + +#[test] +fn double_clicking_a_channel_resets_only_that_channel() { + let mut h = curve_open("master"); + exec(&mut h, "develop.curve", json!({"channel": "master", "points": S_CURVE})); + exec(&mut h, "develop.curve", json!({"channel": "green", "points": S_CURVE})); + h.step(); + click_widget(&mut h, "curveChannel:green", 2); + let c = develop(&h).curve; + assert!(c.green.is_empty(), "green resets: {:?}", c.green); + assert_eq!(c.master.len(), 4, "the other channels stay"); + assert_eq!(h.app.ui.curve_channel, "green"); +} + +#[test] +fn reset_button_resets_every_curve() { + let mut h = curve_open("red"); + for ch in ["master", "red", "blue"] { + exec(&mut h, "develop.curve", json!({"channel": ch, "points": S_CURVE})); + } + exec(&mut h, "develop.set", json!({"control": "curve.highlights", "value": -40})); + h.step(); + click_widget(&mut h, "button:curveReset", 1); + let c = develop(&h).curve; + assert_eq!(c, lightcraft_develop::ToneCurve::default()); +} + #[test] fn dragging_a_point_past_its_neighbour_is_clamped() { let mut h = curve_open("red"); diff --git a/docs/parity.md b/docs/parity.md index 23434a54e..e49254a8b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -211,7 +211,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | | LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | | | 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`, `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 empty space to add and drag; double-click removes; no curve presets (linear / medium / strong) | +| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `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 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); no curve presets (linear / medium / strong) | | LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | | | LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | | | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | |