mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 05:28:25 -06:00
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:
co-authored by
Claude Opus 5.5
parent
e1acdb876d
commit
ae29c0569f
@@ -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
|
||||
)]
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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`) | |
|
||||
|
||||
Reference in New Issue
Block a user