Merge pull request #41 from storytold/fix/issue-12-14-curve

Tone curve: drag points, reset curves, point-curve presets (fixes #12, #13, #14)
This commit is contained in:
Brandon Thomas 2026-10-04 03:05:47 -04:00 committed by GitHub
commit e3b61fc883
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 822 additions and 8 deletions

View File

@ -154,6 +154,8 @@ fn services(originals: Originals, backend: Option<Backend>, 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,

View File

@ -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())?;

View File

@ -0,0 +1,357 @@
//! 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, always, 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
),
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::<Vec<_>>())
}
fn to_points(v: &[[f64; 2]]) -> Vec<Point> {
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<CurvePreset> {
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<CurvePreset> {
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<String> {
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<String> {
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<Value> {
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<Value> {
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<Value> {
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<Vec<CurvePreset>, 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<CurvePreset> = serde_json::from_value(items).map_err(|e| format!("not a curve preset file: {e}"))?;
let list: Vec<CurvePreset> = 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<CurvePreset> = 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<Value> {
const C: &str = "curve.importPresets";
let mut paths: Vec<String> =
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<Value> {
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<Vec<String>> =
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<CurvePreset> = 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()}))
}

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());

View File

@ -125,6 +125,9 @@ pub struct Session {
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
pub filter_presets: Vec<cmd::filters::FilterPreset>,
/// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]),
/// persisted in prefs.json.
pub curve_presets: Vec<cmd::curves::CurvePreset>,
/// Saved colour-label name sets.
pub label_sets: Vec<cmd::manage::LabelSet>,
/// 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(),

View File

@ -112,6 +112,8 @@ struct PrefsFile {
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
/// Filter presets.
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
/// Point-curve presets.
curve_presets: Vec<crate::cmd::curves::CurvePreset>,
/// Colour-label name sets.
label_sets: Vec<crate::cmd::manage::LabelSet>,
/// 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(),

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());
}

View File

@ -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<String> = s.curve_presets.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["Faded Red", "Linear (imported)"]);
let _ = std::fs::remove_dir_all(&lib);
}

View File

@ -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)]
@ -60,6 +62,10 @@ pub struct Services {
pub pick_preset_files: Option<PickFiles>,
/// Save dialog for an exported `.lcpreset` file.
pub save_preset_file: Option<SaveFile>,
/// Open dialog for point-curve preset files (`.lccurve`).
pub pick_curve_preset_files: Option<PickFiles>,
/// Save dialog for an exported `.lccurve` file.
pub save_curve_preset_file: Option<SaveFile>,
pub write: Option<WriteFn>,
/// Thread-safe writer: with it, UI-started exports run in the background (desktop only).
pub write_shared: Option<SharedWrite>,

View File

@ -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<Re
}
return Some(r);
}
"file.importCurvePresets" => {
let paths: Vec<String> = 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", &params).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(),

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);
}
@ -677,13 +690,37 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
};
let line: Vec<Pos2> = (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<usize> = ui.data(|dd| dd.get_temp(drag_id));
// the point being dragged (stored as `Option<usize>` so a stale value can be cleared)
let mut dragging: Option<usize> = ui.data(|dd| dd.get_temp::<Option<usize>>(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 +733,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),
@ -747,10 +785,102 @@ 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: 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)");
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;

View File

@ -0,0 +1,154 @@
//! 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");
}
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 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");
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:?}");
}

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` | 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`) | |