LR-EDIT-CURVE: reset a curve channel or the whole tone curve

New engine command curve.reset {channel?: all|point|parametric|master|red|
green|blue} (default all = the whole Tone Curve), one undo step, usable from
CLI / MCP / control channel. In the editor: double-click a channel selector
resets that channel, a Reset button under the graph resets every curve
(point curves and parametric), and right-clicking the graph offers
Reset <channel> / Reset All Curves.

Fixes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-04 02:59:25 -04:00
co-authored by Claude Opus 5.5
parent e1acdb876d
commit ae29c0569f
6 changed files with 183 additions and 2 deletions
+58
View File
@@ -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<Value> {
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<CommandSpec> {
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
)]
}
+2
View File
@@ -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());
+32
View File
@@ -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());
}
+56 -1
View File
@@ -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:<target>`).
fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) {
let active = app.ui.tool == tool;
+34
View File
@@ -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");
+1 -1
View File
@@ -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`) | |