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