From e1acdb876d47d231e2a61186a810901640cc4735 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 02:36:01 -0400 Subject: [PATCH 1/3] LR-EDIT-CURVE: drag point-curve points in both axes The editor stored the dragged point index as Option but read it back as usize, so the index was lost after the first frame: pressing on a point did nothing and only click-to-add (overwrite) worked. Points now drag in X and Y (X clamped between the neighbours), picked at the press origin, with a grab cursor, a highlighted point and an input/output readout; one undo step per drag via develop.beginInteraction / endInteraction. Fixes #12 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/edit.rs | 37 ++++++++++--- crates/ui-egui/src/tests_curve.rs | 86 +++++++++++++++++++++++++++++++ docs/parity.md | 2 +- 4 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/tests_curve.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6ec391b4..642d89ff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,8 @@ pub mod state; pub mod theme; pub mod widgets; +#[cfg(test)] +mod tests_curve; #[cfg(test)] mod tests_masking; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index dd1872bd..a22effc3 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -677,13 +677,37 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev }; let line: Vec = (0..=96).map(|i| to_screen(i as f64 / 96.0, curve.eval(i as f64 / 96.0).clamp(0.0, 1.0))).collect(); p.add(egui::Shape::line(line, Stroke::new(2.0, color))); - for q in &pts { - p.circle_filled(to_screen(q.x, q.y), 4.0, color); - } - // interaction: drag the nearest point, click empty space to add, double-click to delete + // interaction: drag a point (both axes, between its neighbours), drag empty space to add and + // drag a new point, click empty space to add, double-click a point to delete it let drag_id = egui::Id::new(("curve-drag", &ch)); - let mut dragging: Option = ui.data(|dd| dd.get_temp(drag_id)); + // the point being dragged (stored as `Option` so a stale value can be cleared) + let mut dragging: Option = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); let nearest = |q: Pos2| pts.iter().enumerate().map(|(i, p)| (i, to_screen(p.x, p.y).distance(q))).min_by(|a, b| a.1.total_cmp(&b.1)); + let hovered = resp.hover_pos().and_then(nearest).filter(|(_, dist)| *dist < 10.0).map(|(i, _)| i); + if dragging.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else if hovered.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + for (i, q) in pts.iter().enumerate() { + let c = to_screen(q.x, q.y); + if dragging == Some(i) || (dragging.is_none() && hovered == Some(i)) { + p.circle_filled(c, 5.5, color); + p.circle_stroke(c, 5.5, Stroke::new(1.0, t.canvas)); + } else { + p.circle_filled(c, 4.0, color); + } + } + // input / output readout of the point being dragged (0–255 like the histogram) + if let Some(q) = dragging.and_then(|i| pts.get(i)) { + p.text( + r.left_top() + vec2(6.0, 4.0), + egui::Align2::LEFT_TOP, + format!("{} / {}", (q.x * 255.0).round(), (q.y * 255.0).round()), + t.font(11.0), + t.text_dim, + ); + } let mut new_pts = None; if resp.double_clicked() && let Some(q) = resp.interact_pointer_pos() @@ -696,8 +720,9 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev v.remove(i); new_pts = Some(v); } else if resp.drag_started() - && let Some(q) = resp.interact_pointer_pos() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) { + // pick the point under the press, not where the pointer is once the drag threshold passed let _ = app.run("develop.beginInteraction", json!({"label": "Tone Curve"})); match nearest(q) { Some((i, dist)) if dist < 10.0 => dragging = Some(i), diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests_curve.rs new file mode 100644 index 00000000..ebe3f9ca --- /dev/null +++ b/crates/ui-egui/src/tests_curve.rs @@ -0,0 +1,86 @@ +//! Headless tests of the point-curve editor: dragging points, resetting, presets. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +/// Detail view with the Edit panel's Curve flyout open on `channel`. +fn curve_open(channel: &str) -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 1400.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.right = crate::state::RightPanel::Edit; + if !h.app.ui.section_open("light") { + h.app.ui.toggle_section("light"); + } + if !h.app.ui.flyout_open("curve") { + h.app.ui.toggle_flyout("curve"); + } + h.app.ui.curve_channel = channel.into(); + h.step(); + h.step(); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { + let id = h.app.session.active().expect("active photo"); + (*h.app.session.develop_of(id).unwrap_or_default()).clone() +} + +fn pts(v: &[lightcraft_geom::Point]) -> Vec<(f64, f64)> { + v.iter().map(|p| (p.x, p.y)).collect() +} + +/// Side of the (square) curve graph in screen points. +fn curve_side(h: &mut Headless) -> f64 { + let r = h.request("ui.widgets", json!({"filter": "curve"}), T); + let w = r["result"].as_array().unwrap().iter().find(|w| w["id"] == "curve").cloned().unwrap_or_else(|| panic!("curve widget on screen: {r}")); + w["rect"][2].as_f64().unwrap() +} + +#[test] +fn dragging_a_point_moves_it_in_both_axes_with_one_undo_step() { + let mut h = curve_open("master"); + exec(&mut h, "develop.curve", json!({"channel": "master", "points": [[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]]})); + h.step(); + let side = curve_side(&mut h); + let undo_before = h.app.session.undo.len(); + // grab the middle point and drag it up-left: x 0.5 → 0.4, y 0.5 → 0.7 + let r = h.request("ui.dragWidget", json!({"id": "curve", "fx": 0.5, "fy": 0.5, "dx": -0.1 * side, "dy": -0.2 * side, "steps": 12}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let c = pts(&develop(&h).curve.master); + assert_eq!(c.len(), 3, "dragging moves the point, it doesn't add one: {c:?}"); + assert!((c[1].0 - 0.4).abs() < 0.03, "x follows the pointer: {c:?}"); + assert!((c[1].1 - 0.7).abs() < 0.03, "y follows the pointer: {c:?}"); + assert_eq!(h.app.session.undo.len(), undo_before + 1, "one undo step per drag"); +} + +#[test] +fn dragging_a_point_past_its_neighbour_is_clamped() { + let mut h = curve_open("red"); + exec(&mut h, "develop.curve", json!({"channel": "red", "points": [[0.0, 0.0], [0.3, 0.3], [0.6, 0.6], [1.0, 1.0]]})); + h.step(); + let side = curve_side(&mut h); + // drag the point at 0.3 far to the right (past 0.6) + let r = h.request("ui.dragWidget", json!({"id": "curve", "fx": 0.3, "fy": 0.7, "dx": 0.5 * side, "dy": 0.0, "steps": 12}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let c = pts(&develop(&h).curve.red); + assert_eq!(c.len(), 4, "{c:?}"); + assert!(c[1].0 < c[2].0 && c[1].0 > 0.55, "clamped just left of its neighbour: {c:?}"); + assert_eq!(c[2], (0.6, 0.6), "the neighbour stays: {c:?}"); +} diff --git a/docs/parity.md b/docs/parity.md index 2b28322c..23434a54 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` | no curve presets (linear / medium / strong) | +| 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-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`) | | From ae29c0569f9c527fa2c697b94a863b3e74088447 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 02:43:46 -0400 Subject: [PATCH 2/3] 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 / Reset All Curves. Fixes #13 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/curves.rs | 58 +++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 ++ crates/engine/src/tests.rs | 32 +++++++++++++++++ crates/ui-egui/src/panels/edit.rs | 57 +++++++++++++++++++++++++++++- crates/ui-egui/src/tests_curve.rs | 34 ++++++++++++++++++ docs/parity.md | 2 +- 6 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/cmd/curves.rs diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs new file mode 100644 index 00000000..848a02a4 --- /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 9c462423..ac379986 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 baf1b5b7..281a7a5f 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 a22effc3..ef5b94a3 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 ebe3f9ca..7d283b4a 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 23434a54..e49254a8 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`) | | From 966db71295684d43044605f46efa5b613ec1c475 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 02:58:01 -0400 Subject: [PATCH 3/3] LR-EDIT-CURVE: point-curve presets (built-in, save, delete, import/export) A Point Curve dropdown under the curve graph shows the preset the photo's point curves match (or Custom) and lists our own built-ins (Linear, Medium Contrast, Strong Contrast: symmetric S-curves, values chosen here) and the user's presets; Save Point Curve... names and stores the current four channels, right-click deletes, Import/Export Presets... read and write .lccurve JSON files. Presets set only the point curves (parametric curve and Refine Saturation stay) and are saved with the library preferences. Engine commands: curve.presets, curve.applyPreset, curve.savePreset, curve.deletePreset, curve.importPresets, curve.exportPresets; UI commands file.importCurvePresets / file.exportCurvePresets (file dialogs on desktop). Fixes #14 Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/web.rs | 2 + apps/lightcraft/src/main.rs | 18 ++ crates/engine/src/cmd/curves.rs | 321 +++++++++++++++++++++++++++++- crates/engine/src/lib.rs | 4 + crates/engine/src/library.rs | 4 + crates/engine/src/tests_prefs.rs | 59 ++++++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus.rs | 46 +++++ crates/ui-egui/src/panels/edit.rs | 54 ++++- crates/ui-egui/src/tests_curve.rs | 34 ++++ docs/parity.md | 2 +- 11 files changed, 534 insertions(+), 14 deletions(-) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index f112fb33..63308367 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -154,6 +154,8 @@ fn services(originals: Originals, backend: Option, ctx: egui::Context) // Preset files: browser pickers are asynchronous; not wired on the web yet. pick_preset_files: None, save_preset_file: None, + pick_curve_preset_files: None, + save_curve_preset_file: None, write: Some(Box::new(download)), // downloads happen on the main thread: exports run in the foreground on the web write_shared: None, diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 2c8a5c82..975283af 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -181,6 +181,24 @@ fn services() -> Services { .save_file() .map(|p| p.to_string_lossy().to_string()) })), + pick_curve_preset_files: Some(Box::new(|| { + rfd::FileDialog::new() + .set_title("Import Point Curve Presets") + .add_filter("Point Curve Presets", &["lccurve", "json"]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_curve_preset_file: Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .set_title("Export Point Curve Presets") + .add_filter("Point Curve Presets", &["lccurve"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| { if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs index 848a02a4..77b061b7 100644 --- a/crates/engine/src/cmd/curves.rs +++ b/crates/engine/src/cmd/curves.rs @@ -1,9 +1,13 @@ -//! Tone-curve commands: reset a channel or the whole curve. +//! Tone-curve commands: reset a channel or the whole curve; point-curve presets (built-in Linear / +//! Medium / Strong Contrast plus the user's, saved with the library preferences, imported and +//! exported as small JSON files). use lightcraft_develop::ToneCurve; +use lightcraft_geom::Point; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_active, str_param}; +use super::{CommandSpec, always, bad, cmd, has_active, str_param}; use crate::{Result, Session}; /// Channels `curve.reset` accepts. @@ -46,13 +50,308 @@ fn reset(s: &mut Session, p: &Value) -> Result { } 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 - )] + 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 + ), + cmd!( + query "curve.presets", + "Point Curve Presets", + [], + None, + "{} → {presets: [{name, master, red, green, blue: [[x,y],...], builtin?}], current: name of the preset the active photo's point curves match, or null}", + always, + |s, _| Ok(list(s)) + ), + cmd!( + "curve.applyPreset", + "Apply Point Curve Preset", + [], + None, + "{name} — sets the active photo's point curves (all four channels; the parametric curve stays)", + has_active, + apply_preset + ), + cmd!( + "curve.savePreset", + "Save Point Curve Preset", + [], + None, + "{name} — the active photo's point curves under a name (replaces a user preset of that name; built-in names are reserved), saved with the library", + has_active, + save_preset + ), + cmd!( + "curve.deletePreset", + "Delete Point Curve Preset", + [], + None, + "{name} — a user preset (built-ins can't be deleted)", + always, + delete_preset + ), + cmd!( + "curve.importPresets", + "Import Point Curve Presets", + [], + None, + "{path | paths: [file]} — .lccurve JSON files ({format, version, presets: [...]}, an array of presets, or one preset); same-named user presets are replaced → {imported: [name], failed: [[file, error]]}", + always, + import_presets + ), + cmd!( + "curve.exportPresets", + "Export Point Curve Presets", + [], + None, + "{path, names?: [name] (default: every user preset)} — writes a .lccurve JSON file → {path, count}", + always, + export_presets + ), + ] +} +// ---------------------------------------------------------------- point-curve presets + +/// File extension of exported curve presets (JSON). +pub const CURVE_PRESET_EXT: &str = "lccurve"; +/// `format` tag of an exported curve-preset file. +const FILE_FORMAT: &str = "lightcraft.curvePresets"; + +/// A named set of point curves (master + red/green/blue, points in 0..1; empty = linear). +/// Built-ins ship with the app; the user's are saved with the library preferences. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CurvePreset { + pub name: String, + pub master: Vec<[f64; 2]>, + pub red: Vec<[f64; 2]>, + pub green: Vec<[f64; 2]>, + pub blue: Vec<[f64; 2]>, + /// Built-in presets can't be deleted or overwritten (never stored). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub builtin: bool, +} + +/// Points clamped to 0..1, sorted by x, duplicates in x dropped; a linear curve becomes empty. +fn normalize(v: &[[f64; 2]]) -> Vec<[f64; 2]> { + let mut out: Vec<[f64; 2]> = + v.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect(); + out.sort_by(|a, b| a[0].total_cmp(&b[0])); + out.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6); + if out.len() < 2 || out.iter().all(|p| (p[0] - p[1]).abs() < 1e-9) { + out.clear(); + } + out +} + +fn from_points(v: &[Point]) -> Vec<[f64; 2]> { + normalize(&v.iter().map(|p| [p.x, p.y]).collect::>()) +} + +fn to_points(v: &[[f64; 2]]) -> Vec { + normalize(v).iter().map(|p| Point::new(p[0], p[1])).collect() +} + +impl CurvePreset { + /// The point curves of `c` under `name`. + pub fn from_curve(name: &str, c: &ToneCurve) -> CurvePreset { + CurvePreset { + name: name.into(), + master: from_points(&c.master), + red: from_points(&c.red), + green: from_points(&c.green), + blue: from_points(&c.blue), + builtin: false, + } + } + /// Set the four point curves of `c` (the parametric curve and Refine Saturation stay). + pub fn apply(&self, c: &mut ToneCurve) { + c.master = to_points(&self.master); + c.red = to_points(&self.red); + c.green = to_points(&self.green); + c.blue = to_points(&self.blue); + } + /// Does `c` carry exactly this preset's point curves? + pub fn matches(&self, c: &ToneCurve) -> bool { + let same = |a: &[[f64; 2]], b: &[Point]| { + let (a, b) = (normalize(a), from_points(b)); + a.len() == b.len() && a.iter().zip(&b).all(|(p, q)| (p[0] - q[0]).abs() < 1e-6 && (p[1] - q[1]).abs() < 1e-6) + }; + same(&self.master, &c.master) && same(&self.red, &c.red) && same(&self.green, &c.green) && same(&self.blue, &c.blue) + } + fn is_linear(&self) -> bool { + [&self.master, &self.red, &self.green, &self.blue].iter().all(|v| normalize(v).is_empty()) + } +} + +/// The presets that ship with the app: a linear curve and two symmetric S-curves (our own +/// values: the quarter-tones pulled 0.05 / 0.11 towards the ends, midpoint fixed). +pub fn builtin_presets() -> Vec { + let s = |name: &str, master: Vec<[f64; 2]>| CurvePreset { name: name.into(), master, builtin: true, ..Default::default() }; + vec![ + s("Linear", Vec::new()), + s("Medium Contrast", vec![[0.0, 0.0], [0.25, 0.2], [0.5, 0.5], [0.75, 0.8], [1.0, 1.0]]), + s("Strong Contrast", vec![[0.0, 0.0], [0.25, 0.14], [0.5, 0.5], [0.75, 0.86], [1.0, 1.0]]), + ] +} + +/// Built-in presets, then the user's. +pub fn all_presets(s: &Session) -> Vec { + let mut v = builtin_presets(); + v.extend(s.curve_presets.iter().cloned()); + v +} + +/// Name of the preset whose point curves `c` carries (built-ins first), if any. +pub fn matching_preset(s: &Session, c: &ToneCurve) -> Option { + all_presets(s).into_iter().find(|p| p.matches(c)).map(|p| p.name) +} + +fn is_builtin_name(name: &str) -> bool { + builtin_presets().iter().any(|p| p.name.eq_ignore_ascii_case(name)) +} + +/// Add or replace (same name, ignoring case) a user preset. +fn upsert(s: &mut Session, p: CurvePreset) { + match s.curve_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&p.name)) { + Some(x) => *x = p, + None => s.curve_presets.push(p), + } +} + +fn name_param(p: &Value, c: &str) -> Result { + str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`")) +} + +fn list(s: &Session) -> Value { + let current = s.active().and_then(|id| s.develop_of(id)).and_then(|d| matching_preset(s, &d.curve)); + json!({"presets": all_presets(s), "current": current}) +} + +fn apply_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "curve.applyPreset"; + let name = name_param(p, C)?; + let preset = + all_presets(s).into_iter().find(|x| x.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(C, format!("no curve preset `{name}`")))?; + let id = s.active().ok_or_else(|| bad(C, "no active photo"))?; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + preset.apply(&mut d.curve); + s.set_develop(id, d, &format!("Point Curve: {}", preset.name))?; + Ok(json!({"name": preset.name})) +} + +fn save_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "curve.savePreset"; + let name = name_param(p, C)?; + if is_builtin_name(&name) { + return Err(bad(C, format!("`{name}` is a built-in preset: choose another name"))); + } + let id = s.active().ok_or_else(|| bad(C, "no active photo"))?; + let preset = CurvePreset::from_curve(&name, &s.develop_of(id).unwrap_or_default().curve); + if preset.is_linear() { + return Err(bad(C, "the point curve is linear: shape it first")); + } + upsert(s, preset); + s.save_prefs()?; + Ok(list(s)) +} + +fn delete_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "curve.deletePreset"; + let name = name_param(p, C)?; + if is_builtin_name(&name) { + return Err(bad(C, "built-in presets can't be deleted")); + } + let n = s.curve_presets.len(); + s.curve_presets.retain(|x| !x.name.eq_ignore_ascii_case(&name)); + if n == s.curve_presets.len() { + return Err(bad(C, format!("no curve preset `{name}`"))); + } + s.save_prefs()?; + Ok(list(s)) +} + +/// Presets in a curve-preset file: `{format, version, presets: [...]}`, a bare array, or one preset. +pub fn parse_file(bytes: &[u8]) -> std::result::Result, String> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not a curve preset file: {e}"))?; + let items = match &v { + Value::Object(o) if o.contains_key("presets") => o["presets"].clone(), + Value::Array(_) => v.clone(), + Value::Object(_) => Value::Array(vec![v.clone()]), + _ => return Err("not a curve preset file".into()), + }; + let list: Vec = serde_json::from_value(items).map_err(|e| format!("not a curve preset file: {e}"))?; + let list: Vec = list.into_iter().filter(|p| !p.name.trim().is_empty()).collect(); + if list.is_empty() { + return Err("no curve presets in the file".into()); + } + Ok(list) +} + +/// The JSON of a curve-preset file holding `presets`. +pub fn to_file(presets: &[CurvePreset]) -> String { + let presets: Vec = presets.iter().map(|p| CurvePreset { builtin: false, ..p.clone() }).collect(); + serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 1, "presets": presets})).unwrap_or_default() +} + +fn import_presets(s: &mut Session, p: &Value) -> Result { + const C: &str = "curve.importPresets"; + let mut paths: Vec = + p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + if let Some(x) = str_param(p, "path") { + paths.push(x.to_string()); + } + if paths.is_empty() { + return Err(bad(C, "missing `path` / `paths`")); + } + let (mut imported, mut failed) = (Vec::new(), Vec::new()); + for f in &paths { + match std::fs::read(f).map_err(|e| e.to_string()).and_then(|b| parse_file(&b)) { + Ok(list) => { + for mut preset in list { + preset.name = preset.name.trim().to_string(); + preset.builtin = false; + if is_builtin_name(&preset.name) { + preset.name = format!("{} (imported)", preset.name); + } + imported.push(preset.name.clone()); + upsert(s, preset); + } + } + Err(e) => failed.push(json!([f, e])), + } + } + if !imported.is_empty() { + s.save_prefs()?; + } + Ok(json!({"imported": imported, "failed": failed})) +} + +fn export_presets(s: &mut Session, p: &Value) -> Result { + const C: &str = "curve.exportPresets"; + let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let mut path = std::path::PathBuf::from(path); + if path.extension().is_none() { + path.set_extension(CURVE_PRESET_EXT); + } + let names: Option> = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + let chosen: Vec = match &names { + Some(n) => all_presets(s).into_iter().filter(|x| n.iter().any(|m| m.eq_ignore_ascii_case(&x.name))).collect(), + None => s.curve_presets.clone(), + }; + if chosen.is_empty() { + return Err(bad(C, "no curve presets to export (save one first, or pass names)")); + } + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; + } + std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 77a7e1da..982b74af 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -125,6 +125,9 @@ pub struct Session { pub metadata_presets: Vec, /// Saved filter-bar settings (`filter.*`), persisted in prefs.json. pub filter_presets: Vec, + /// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]), + /// persisted in prefs.json. + pub curve_presets: Vec, /// Saved colour-label name sets. pub label_sets: Vec, /// Look-alike signatures by content key (Find Similar). @@ -195,6 +198,7 @@ impl Session { export_presets: Vec::new(), metadata_presets: Vec::new(), filter_presets: Vec::new(), + curve_presets: Vec::new(), label_sets: Vec::new(), signatures: Default::default(), lut_profiles: Vec::new(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 096bef99..a252e07b 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -112,6 +112,8 @@ struct PrefsFile { metadata_presets: Vec, /// Filter presets. filter_presets: Vec, + /// Point-curve presets. + curve_presets: Vec, /// Colour-label name sets. label_sets: Vec, /// Imported LUT profiles. @@ -189,6 +191,7 @@ impl Session { self.export_presets = prefs.export_presets; self.metadata_presets = prefs.metadata_presets; self.filter_presets = prefs.filter_presets; + self.curve_presets = prefs.curve_presets; self.label_sets = prefs.label_sets; self.lut_profiles = prefs.lut_profiles; crate::cmd::lut_profiles::register_all(self); @@ -309,6 +312,7 @@ impl Session { export_presets: self.export_presets.clone(), metadata_presets: self.metadata_presets.clone(), filter_presets: self.filter_presets.clone(), + curve_presets: self.curve_presets.clone(), label_sets: self.label_sets.clone(), lut_profiles: self.lut_profiles.clone(), keyword_sets: self.keyword_sets.clone(), diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 0fa9b3ab..8dc2256c 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -202,3 +202,62 @@ fn metadata_presets_apply_to_photos_and_imports() { assert!(s.import_defaults.metadata_preset.is_none(), "deleting the import preset clears it"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn curve_presets_save_apply_export_import_and_persist() { + let lib = temp_dir("curves"); + let file = lib.join("out").join("looks"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, true).unwrap(); + let r = s.execute("curve.presets", &json!({})).unwrap(); + let names: Vec<&str> = r["presets"].as_array().unwrap().iter().filter_map(|p| p["name"].as_str()).collect(); + assert_eq!(names, ["Linear", "Medium Contrast", "Strong Contrast"]); + assert_eq!(r["current"], "Linear", "a fresh photo has linear point curves"); + // built-in: an S-curve on the master channel, parametric untouched + s.execute("develop.set", &json!({"control": "curve.shadows", "value": 20})).unwrap(); + s.execute("curve.applyPreset", &json!({"name": "strong contrast"})).unwrap(); + let d = s.develop_of(s.active().unwrap()).unwrap(); + assert_eq!(d.curve.master.len(), 5); + assert!(d.curve.master[1].y < 0.25 && d.curve.master[3].y > 0.75, "an S-curve"); + assert_eq!(d.curve.shadows, 20.0); + assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Strong Contrast"); + // save a custom one (with a red channel) + s.execute("develop.curve", &json!({"channel": "red", "points": [[0.0, 0.1], [1.0, 0.9]]})).unwrap(); + assert!(s.execute("curve.savePreset", &json!({"name": "Linear"})).is_err(), "built-in names are reserved"); + s.execute("curve.savePreset", &json!({"name": "Faded Red"})).unwrap(); + assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Faded Red"); + // Linear resets every point channel + s.execute("curve.applyPreset", &json!({"name": "Linear"})).unwrap(); + let d = s.develop_of(s.active().unwrap()).unwrap(); + assert!(d.curve.master.is_empty() && d.curve.red.is_empty()); + s.execute("curve.applyPreset", &json!({"name": "Faded Red"})).unwrap(); + assert_eq!(s.develop_of(s.active().unwrap()).unwrap().curve.red.len(), 2); + // export (extension added), delete, re-import + let r = s.execute("curve.exportPresets", &json!({"path": file.to_string_lossy()})).unwrap(); + assert_eq!(r["count"], 1); + let path = r["path"].as_str().unwrap().to_string(); + assert!(path.ends_with(".lccurve")); + assert!(s.execute("curve.deletePreset", &json!({"name": "Medium Contrast"})).is_err()); + s.execute("curve.deletePreset", &json!({"name": "faded red"})).unwrap(); + assert!(s.curve_presets.is_empty()); + let r = s.execute("curve.importPresets", &json!({"path": path})).unwrap(); + assert_eq!(r["imported"], json!(["Faded Red"])); + assert_eq!(s.curve_presets.len(), 1); + // a file carrying a built-in name doesn't shadow it; junk fails cleanly + let other = lib.join("one.lccurve"); + std::fs::write(&other, r#"{"name": "Linear", "master": [[0, 0.05], [1, 1]]}"#).unwrap(); + let junk = lib.join("junk.lccurve"); + std::fs::write(&junk, "not json").unwrap(); + let r = s.execute("curve.importPresets", &json!({"paths": [other.to_string_lossy(), junk.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"], json!(["Linear (imported)"])); + assert_eq!(r["failed"].as_array().unwrap().len(), 1); + s.close_library().unwrap(); + } + // saved with the library preferences + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let names: Vec = s.curve_presets.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, ["Faded Red", "Linear (imported)"]); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 642d89ff..e42ed5ea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -62,6 +62,10 @@ pub struct Services { pub pick_preset_files: Option, /// Save dialog for an exported `.lcpreset` file. pub save_preset_file: Option, + /// Open dialog for point-curve preset files (`.lccurve`). + pub pick_curve_preset_files: Option, + /// Save dialog for an exported `.lccurve` file. + pub save_curve_preset_file: Option, pub write: Option, /// Thread-safe writer: with it, UI-started exports run in the background (desktop only). pub write_shared: Option, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c25df368..2958889e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -116,6 +116,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), ("file.importPresets", "Import Profiles & Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), + // Edit panel ▸ Curve ▸ Point Curve dropdown + ("file.importCurvePresets", "Import Point Curve Presets…", None, ""), + ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), ("app.openLibrary", "Open Library…", None, "File"), ("app.about", "About LightCraft", None, "Help"), @@ -1005,6 +1008,48 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let paths: Vec = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => match app.services.pick_curve_preset_files.as_mut() { + Some(f) => f(), + None => return Some(Err("no file dialog on this platform".into())), + }, + }; + if paths.is_empty() { + return Some(Ok(Value::Null)); + } + let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); + if let Ok(v) = &r { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let mut msg = format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }); + if failed > 0 { + msg += &format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }); + } + app.toast(&ctx, msg); + } + return Some(r); + } + "file.exportCurvePresets" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => match app.services.save_curve_preset_file.as_mut() { + Some(f) => f("Point Curves.lccurve"), + None => return Some(Err("no file dialog on this platform".into())), + }, + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = json!({"path": path}); + if let Some(n) = p.get("names") { + params["names"] = n.clone(); + } + let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.toast(&ctx, format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); + } + return Some(r); + } "app.export" => crate::control::export_active(app, p), "app.showInFinder" => show_in_finder(app), "app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => { @@ -1037,6 +1082,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) } "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), + "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), "view.compare" => app.session.catalog.len() > 1, "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, "app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(), diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ef5b94a3..e2ecca26 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -790,10 +790,60 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let _ = id; } -/// The row under the curve graph: reset every curve. -fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, _d: &DevelopSettings) { +/// The row under the curve graph: point-curve presets and reset every curve. +fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + use lightcraft_engine::cmd::curves::{all_presets, matching_preset}; + let t = Tokens::get(ui.ctx()); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { ui.horizontal(|ui| { + ui.label(RichText::new("Point Curve").font(t.font(12.0)).color(t.text_label)); + let current = matching_preset(&app.session, &d.curve); + let r = crate::widgets::dropdown(ui, "curvePreset", current.as_deref().unwrap_or("Custom"), t.font(12.0), t.text); + egui::Popup::menu(&r).show(|ui| { + ui.set_min_width(180.0); + let presets = all_presets(&app.session); + let mut user_seen = false; + for p in &presets { + if !p.builtin && !user_seen { + user_seen = true; + ui.separator(); + } + let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), &p.name); + register(ui.ctx(), format!("curvePreset:{}", p.name), r.rect); + if r.clicked() { + let _ = app.run("curve.applyPreset", json!({"name": p.name})); + ui.close(); + } + if !p.builtin { + r.context_menu(|ui| { + if ui.button("Delete Preset").clicked() { + let _ = app.run("curve.deletePreset", json!({"name": p.name})); + ui.close(); + } + }); + } + } + ui.separator(); + let shaped = current.as_deref() != Some("Linear"); + let r = ui.add_enabled(shaped, egui::Button::new("Save Point Curve…")); + register(ui.ctx(), "curvePresetMenu:save", r.rect); + if r.clicked() { + crate::panels::dialogs::prompt(app, "Save Point Curve Preset", "Preset name", "", "curve.savePreset", json!({}), "name"); + ui.close(); + } + if ui.add_enabled(app.services.pick_curve_preset_files.is_some(), egui::Button::new("Import Presets…")).clicked() { + let _ = app.run("file.importCurvePresets", json!({})); + ui.close(); + } + let can_export = app.services.save_curve_preset_file.is_some() && !app.session.curve_presets.is_empty(); + if ui.add_enabled(can_export, egui::Button::new("Export Presets…")).clicked() { + let _ = app.run("file.exportCurvePresets", json!({})); + ui.close(); + } + if presets.iter().any(|p| !p.builtin) { + ui.label(RichText::new("Right-click a preset to delete it").font(t.font(11.0)).color(t.text_dim)); + } + }); 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)"); diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests_curve.rs index 7d283b4a..ca5848be 100644 --- a/crates/ui-egui/src/tests_curve.rs +++ b/crates/ui-egui/src/tests_curve.rs @@ -103,6 +103,40 @@ fn reset_button_resets_every_curve() { assert_eq!(c, lightcraft_develop::ToneCurve::default()); } +#[test] +fn point_curve_preset_dropdown_applies_and_saves() { + let mut h = curve_open("master"); + click_widget(&mut h, "dropdown:curvePreset", 1); + click_widget(&mut h, "curvePreset:Medium Contrast", 1); + let c = develop(&h).curve; + assert_eq!(c.master.len(), 5, "the preset's S-curve: {:?}", c.master); + assert!(c.master[1].y < 0.25 && c.master[3].y > 0.75); + // shape it further, then save it from the dropdown (name prompt) + exec(&mut h, "develop.curve", json!({"channel": "blue", "points": [[0.0, 0.08], [1.0, 1.0]]})); + h.step(); + click_widget(&mut h, "dropdown:curvePreset", 1); + click_widget(&mut h, "curvePresetMenu:save", 1); + let mut dlg = h.app.ui.dialog.take().expect("a name prompt opens"); + match &mut dlg { + crate::state::Dialog::TextPrompt { command, value, .. } => { + assert_eq!(command, "curve.savePreset"); + *value = "Cool Shadows".into(); + } + _ => panic!("a name prompt opens"), + } + let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg); + assert!(r.is_ok(), "{r:?}"); + assert_eq!(h.app.session.curve_presets.len(), 1); + // Linear from the dropdown flattens every channel + click_widget(&mut h, "dropdown:curvePreset", 1); + click_widget(&mut h, "curvePreset:Linear", 1); + let c = develop(&h).curve; + assert!(c.master.is_empty() && c.blue.is_empty()); + click_widget(&mut h, "dropdown:curvePreset", 1); + click_widget(&mut h, "curvePreset:Cool Shadows", 1); + assert_eq!(develop(&h).curve.blue.len(), 2); +} + #[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 e49254a8..4f45ebbd 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`, `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-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `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); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON | | 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`) | |