From 1bc25d363f7beb30512f81178729a91f1877a09d Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:54:37 -0700 Subject: [PATCH] New Document: save named presets (Saved category) (#1516) Save Preset... in File > New stores width, height, resolution, mode, depth, background (with its colour) and display units in preferences. They are listed under a Saved category, where a preset can be selected or deleted. Adds document.presets.save/list/get/delete; get returns params for file.new, which gains an optional backgroundColor. Bad saved entries are skipped one at a time. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 31 +- crates/engine/src/document_preset_cmds.rs | 419 ++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/presets/mod.rs | 10 + crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/new_doc_ui.rs | 267 +++++++++++++- docs/control-protocol.md | 14 + docs/scorecard.md | 4 +- scorecard/ui.toml | 4 +- 22 files changed, 764 insertions(+), 27 deletions(-) create mode 100644 crates/engine/src/document_preset_cmds.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 7abc459b..e877372d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -167,7 +167,7 @@ pub(crate) fn color_param(p: &Value, key: &str, default: [f32; 4]) -> [f32; 4] { _ => default, } } -fn parse_hex(s: &str) -> Option<[f32; 4]> { +pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { let s = s.trim_start_matches('#'); let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|v| v as f32 / 255.0); match s.len() { @@ -314,35 +314,29 @@ fn build() -> Vec { "New…", ["File"], Some("Cmd+N"), - r##"{"width":u32=1920,"height":u32=1080,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","resolution":ppi=72,"name":str}"##, + r##"{"width":u32=1920,"height":u32=1080,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":ppi=72,"name":str}"##, always, |s, p| { + use crate::document_preset_cmds::{MAX_DIMENSION, MAX_RESOLUTION, background_color, color_mode, sample_type}; // A size given as a float (`512.0`, as JSON from a UI field) is still that size (#254). let px = |k: &str, d: u32| match p.get(k) { Some(v) => v .as_u64() - .map(|n| n.clamp(1, 300_000) as u32) - .or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round().clamp(1.0, 300_000.0) as u32)) + .map(|n| n.clamp(1, MAX_DIMENSION as u64) as u32) + .or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round().clamp(1.0, MAX_DIMENSION as f64) as u32)) .unwrap_or(d), None => d, }; let (w, h) = (px("width", 1920), px("height", 1080)); - let mode = match p.get("mode").and_then(Value::as_str).unwrap_or("rgb") { - "gray" | "grayscale" => ColorMode::Grayscale, - "cmyk" => ColorMode::Cmyk, - "lab" => ColorMode::Lab, - _ => ColorMode::Rgb, - }; - let depth = match p.get("depth").and_then(Value::as_u64).unwrap_or(8) { - 16 => SampleType::U16, - 32 => SampleType::F32, - _ => SampleType::U8, - }; + let mode = color_mode(p.get("mode").and_then(Value::as_str).unwrap_or("rgb")).unwrap_or(ColorMode::Rgb); + let depth = sample_type(p.get("depth").and_then(Value::as_u64).unwrap_or(8)).unwrap_or(SampleType::U8); let name = p.get("name").and_then(Value::as_str).unwrap_or("Untitled").to_string(); - let res = p.get("resolution").and_then(Value::as_f64).unwrap_or(72.0).clamp(1.0, 30_000.0) as f32; - let bgc = s.tools.background; + let res = p.get("resolution").and_then(Value::as_f64).unwrap_or(72.0).clamp(1.0, MAX_RESOLUTION) as f32; let mut doc = match p.get("background").and_then(Value::as_str).unwrap_or("white") { - "backgroundColor" => Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(bgc[0], bgc[1], bgc[2], 1.0)), + "backgroundColor" => { + let bgc = background_color(p, s.tools.background, "file.new")?; + Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(bgc[0], bgc[1], bgc[2], 1.0)) + } "transparent" => { let mut d = Document::new(name, Size::new(w, h), mode, depth); d.layers.push(Layer::raster("Layer 1", d.pixel_format())); @@ -1121,6 +1115,7 @@ fn build() -> Vec { v.extend(crate::notes_cmds::specs()); v.extend(crate::proof_sim::specs()); v.extend(crate::presets::specs()); + v.extend(crate::document_preset_cmds::specs()); v.extend(crate::render_cmds::specs()); v.extend(crate::slice_cmds::specs()); v.extend(crate::web_cmds::specs()); diff --git a/crates/engine/src/document_preset_cmds.rs b/crates/engine/src/document_preset_cmds.rs new file mode 100644 index 00000000..6b5f4721 --- /dev/null +++ b/crates/engine/src/document_preset_cmds.rs @@ -0,0 +1,419 @@ +//! Named New Document configurations. The existing preferences services persist these with the +//! other presets; selecting one only returns settings, and document creation stays in `file.new`. + +use photocraft_color::{ColorMode, SampleType}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::presets::{always, bad, req_str}; +use crate::{Result, Session}; + +pub(crate) const MAX_DIMENSION: u32 = 300_000; +pub(crate) const MAX_RESOLUTION: f64 = 30_000.0; +const MAX_PRESETS: usize = 256; + +/// The formats accepted by `file.new`, also used when validating saved configurations. +pub(crate) fn color_mode(mode: &str) -> Option { + match mode { + "rgb" => Some(ColorMode::Rgb), + "gray" | "grayscale" => Some(ColorMode::Grayscale), + "cmyk" => Some(ColorMode::Cmyk), + "lab" => Some(ColorMode::Lab), + _ => None, + } +} + +pub(crate) fn sample_type(depth: u64) -> Option { + match depth { + 8 => Some(SampleType::U8), + 16 => Some(SampleType::U16), + 32 => Some(SampleType::F32), + _ => None, + } +} + +/// A saved Background Color must not follow later changes to the toolbox colour. +pub(crate) fn background_color(p: &Value, fallback: [f32; 4], cmd: &str) -> Result<[f32; 4]> { + let c: [f32; 3] = match p.get("backgroundColor") { + Some(v) => serde_json::from_value(v.clone()).map_err(|_| bad(cmd, "`backgroundColor` must be [r,g,b] in 0..1"))?, + None => [fallback[0], fallback[1], fallback[2]], + }; + if !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) { + return Err(bad(cmd, "`backgroundColor` must be [r,g,b] in 0..1")); + } + Ok([c[0], c[1], c[2], 1.0]) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentSettings { + pub width: u32, + pub height: u32, + pub resolution: f32, + pub mode: String, + pub depth: u8, + pub background: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background_color: Option<[f32; 3]>, + /// Display units only; width/height are always pixels and resolution is always ppi. + pub unit: String, + pub resolution_unit: String, +} + +impl DocumentSettings { + fn validate(&self) -> std::result::Result<(), String> { + if !(1..=MAX_DIMENSION).contains(&self.width) || !(1..=MAX_DIMENSION).contains(&self.height) { + return Err(format!("Width and height must be within 1..={MAX_DIMENSION} pixels")); + } + if !self.resolution.is_finite() || !(1.0..=MAX_RESOLUTION as f32).contains(&self.resolution) { + return Err(format!("Resolution must be within 1..={MAX_RESOLUTION} ppi")); + } + if color_mode(&self.mode).is_none() || sample_type(self.depth.into()).is_none() { + return Err("Use RGB, Grayscale, CMYK or Lab at 8, 16 or 32 bits".into()); + } + if !matches!(self.background.as_str(), "white" | "black" | "transparent" | "backgroundColor") && crate::commands::parse_hex(&self.background).is_none() + { + return Err("Unknown background choice or invalid colour".into()); + } + if self.background == "backgroundColor" && self.background_color.is_none() { + return Err("A saved Background Color needs its colour value".into()); + } + if self.background_color.is_some_and(|c| !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))) { + return Err("Background colour components must be within 0..1".into()); + } + if !matches!(self.unit.as_str(), "px" | "in" | "cm" | "mm" | "pt" | "pica") || !matches!(self.resolution_unit.as_str(), "in" | "cm") { + return Err("Unknown dimension or resolution display unit".into()); + } + Ok(()) + } + + fn from_params(s: &Session, p: &Value, cmd: &str) -> Result { + let mut fields = p.as_object().cloned().ok_or_else(|| bad(cmd, "`settings` must be an object"))?; + let defaults = json!({"resolution":72.0,"mode":"rgb","depth":8,"background":"white","unit":"px","resolutionUnit":"in"}); + if let Some(defaults) = defaults.as_object() { + for (k, v) in defaults { + fields.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + // Like file.new, accept pixel counts sent as floats by a UI. Unlike file.new's fallback + // for legacy callers, refuse invalid input rather than saving a different configuration. + for key in ["width", "height"] { + let n = fields + .get(key) + .and_then(Value::as_f64) + .filter(|n| n.is_finite() && (1.0..=MAX_DIMENSION as f64).contains(n)) + .ok_or_else(|| bad(cmd, format!("`{key}` must be within 1..={MAX_DIMENSION} pixels")))?; + fields.insert(key.into(), json!(n.round() as u32)); + } + if fields.get("mode").and_then(Value::as_str) == Some("grayscale") { + fields.insert("mode".into(), json!("gray")); + } + if fields.get("background").and_then(Value::as_str) == Some("backgroundColor") { + let c = background_color(&Value::Object(fields.clone()), s.tools.background, cmd)?; + fields.insert("backgroundColor".into(), json!([c[0], c[1], c[2]])); + } + let settings: Self = serde_json::from_value(Value::Object(fields)).map_err(|e| bad(cmd, format!("invalid settings: {e}")))?; + settings.validate().map_err(|e| bad(cmd, e))?; + Ok(settings) + } + + /// Creation params deliberately exclude both the preset name and display-only units. + pub fn command_params(&self) -> Value { + let mut p = json!({"width":self.width,"height":self.height,"resolution":self.resolution, + "mode":self.mode,"depth":self.depth,"background":self.background}); + if let Some(c) = self.background_color { + p["backgroundColor"] = json!(c); + } + p + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DocumentPreset { + pub name: String, + pub settings: DocumentSettings, +} + +fn checked_name(name: &str) -> std::result::Result { + let name = name.trim(); + if name.is_empty() || name.chars().count() > crate::brush_preset_cmds::MAX_NAME { + return Err("Preset names must contain 1–255 characters after trimming".into()); + } + Ok(name.into()) +} + +/// Read entries independently so a damaged preset cannot discard other presets or preferences. +pub(crate) fn load(value: Value) -> Vec { + let Some(entries) = value.as_array() else { return Vec::new() }; + let mut presets: Vec = Vec::new(); + for entry in entries.iter().take(MAX_PRESETS) { + let Ok(mut preset) = serde_json::from_value::(entry.clone()) else { continue }; + let Ok(name) = checked_name(&preset.name) else { continue }; + if preset.settings.validate().is_err() || presets.iter().any(|p| p.name.eq_ignore_ascii_case(&name)) { + continue; + } + preset.name = name; + if preset.settings.mode == "grayscale" { + preset.settings.mode = "gray".into(); + } + presets.push(preset); + } + presets +} + +fn save(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "document.presets.save"; + let name = checked_name(req_str(p, "name", CMD)?).map_err(|e| bad(CMD, e))?; + if s.presets.documents.iter().any(|pr| pr.name.eq_ignore_ascii_case(&name)) { + return Err(bad(CMD, format!("A preset named \"{name}\" already exists; choose another name"))); + } + if s.presets.documents.len() >= MAX_PRESETS { + return Err(bad(CMD, format!("At most {MAX_PRESETS} document presets can be saved"))); + } + let settings = DocumentSettings::from_params(s, p.get("settings").unwrap_or(&Value::Null), CMD)?; + let preset = DocumentPreset { name, settings }; + let result = json!({"preset":preset}); + s.presets.documents.push(preset); + s.presets_changed(); + Ok(result) +} + +fn get(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "document.presets.get"; + let name = req_str(p, "name", CMD)?; + let preset = s.presets.documents.iter().find(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such document preset"))?; + Ok(json!({"preset":preset,"params":preset.settings.command_params()})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "document.presets.delete"; + let name = req_str(p, "name", CMD)?; + let index = s.presets.documents.iter().position(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such document preset"))?; + s.presets.documents.remove(index); + s.presets_changed(); + Ok(json!({"deleted":name})) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "document.presets.list", + label: "List Document Presets", + menu: &[], + shortcut: None, + params: "{}", + enabled: always, + run: |s, _| Ok(json!({"presets":s.presets.documents})), + journal: false, + }, + CommandSpec { + id: "document.presets.get", + label: "Get Document Preset", + menu: &[], + shortcut: None, + params: r#"{"name":str} -> {preset,params} (params for file.new; no document is created)"#, + enabled: always, + run: get, + journal: false, + }, + CommandSpec { + id: "document.presets.save", + label: "Save Document Preset", + menu: &[], + shortcut: None, + params: r##"{"name":str (trimmed, ASCII case-insensitive unique, 1..255 characters),"settings":{"width":px,"height":px,"resolution":ppi=72,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|transparent|backgroundColor|#rrggbb"="white","backgroundColor":[r,g,b]?,"unit":"px|in|cm|mm|pt|pica"="px","resolutionUnit":"in|cm"="in"}}"##, + enabled: always, + run: save, + journal: true, + }, + CommandSpec { + id: "document.presets.delete", + label: "Delete Document Preset", + menu: &[], + shortcut: None, + params: r#"{"name":str}"#, + enabled: always, + run: delete, + journal: true, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings() -> Value { + json!({"width":16,"height":24,"resolution":254.5,"mode":"lab","depth":16, + "background":"backgroundColor","backgroundColor":[0.125,0.375,0.625],"unit":"mm","resolutionUnit":"cm"}) + } + + fn saved(s: &mut Session, name: &str, settings: Value) -> Value { + s.execute("document.presets.save", json!({"name":name,"settings":settings})).unwrap()["preset"].clone() + } + + fn reload(s: &Session) -> Session { + let mut next = Session::new(); + next.load_prefs_json(&s.prefs_to_json()).unwrap(); + next + } + + #[test] + fn every_field_survives_save_reload_and_file_new() { + for mode in ["rgb", "gray", "cmyk", "lab"] { + for depth in [8, 16, 32] { + let mut s = Session::new(); + let mut config = settings(); + config["mode"] = json!(mode); + config["depth"] = json!(depth); + let preset = saved(&mut s, " Product square ", config.clone()); + assert_eq!(preset["name"], "Product square"); + assert_eq!(preset["settings"], config); + let mut next = reload(&s); + assert_eq!(next.presets.documents, s.presets.documents); + let rev = next.prefs.rev(); + let selected = next.execute("document.presets.get", json!({"name":"product SQUARE"})).unwrap(); + assert_eq!(selected["preset"], preset); + assert!(next.documents().is_empty(), "save/get do not create a document"); + assert_eq!(next.prefs.rev(), rev, "queries do not dirty preferences"); + let mut params = selected["params"].clone(); + assert!(params.get("name").is_none()); + params["name"] = json!("Actual document"); + next.execute("file.new", params).unwrap(); + let doc = &next.active().unwrap().doc; + assert_eq!((doc.size.width, doc.size.height, doc.resolution_dpi), (16, 24, 254.5)); + assert_eq!(doc.name, "Actual document"); + assert_eq!(doc.mode, color_mode(mode).unwrap()); + assert_eq!(doc.depth, sample_type(depth).unwrap()); + } + } + } + + #[test] + fn background_choice_and_captured_colour_are_independent_of_the_toolbox() { + for (background, expected) in [ + ("white", [1.0, 1.0, 1.0, 1.0]), + ("black", [0.0, 0.0, 0.0, 1.0]), + ("transparent", [0.0; 4]), + ("#ff0000", [1.0, 0.0, 0.0, 1.0]), + ("backgroundColor", [0.125, 0.375, 0.625, 1.0]), + ] { + let mut s = Session::new(); + s.tools.background = [0.125, 0.375, 0.625, 1.0]; + saved(&mut s, "Background", json!({"width":4,"height":4,"background":background,"depth":32})); + let mut s = reload(&s); + s.tools.background = [1.0, 0.0, 1.0, 1.0]; + let p = s.execute("document.presets.get", json!({"name":"Background"})).unwrap(); + assert_eq!(p["preset"]["settings"]["background"], background); + s.execute("file.new", p["params"].clone()).unwrap(); + let pixel = s.execute("document.pixel", json!({"x":0,"y":0})).unwrap(); + let rgba = pixel.as_array().unwrap(); + for (actual, expected) in rgba.iter().zip(expected) { + assert!((actual.as_f64().unwrap() - expected).abs() < 0.001, "{background}: {pixel}"); + } + } + } + + #[test] + fn deletion_marks_preferences_dirty_and_survives_reload() { + let mut s = Session::new(); + saved(&mut s, "Delete me", settings()); + saved(&mut s, "Keep me", settings()); + let mut s = reload(&s); + let rev = s.prefs.rev(); + s.execute("document.presets.delete", json!({"name":" delete ME "})).unwrap(); + assert!(s.prefs.rev() > rev); + let mut s = reload(&s); + assert_eq!(s.execute("document.presets.list", json!({})).unwrap()["presets"].as_array().unwrap().len(), 1); + assert_eq!(s.presets.documents[0].name, "Keep me"); + assert!(s.execute("document.presets.get", json!({"name":"Delete me"})).is_err()); + assert!(s.execute("document.presets.delete", json!({"name":"Delete me"})).is_err()); + } + + #[test] + fn old_preferences_and_malformed_presets_keep_unrelated_settings() { + let mut original = Session::new(); + original.edit_prefs(|p| p.general.beep_when_done = true); + original.presets.tool_presets[0].name = "Unrelated tool preset".into(); + let valid = saved(&mut original, "Keep me", settings()); + for invalid in [json!("bad collection"), json!([false, {}, {"name":"broken","settings":{}}])] { + let mut value = original.prefs_value(); + value["presets"]["documents"] = invalid; + let mut s = Session::new(); + s.load_prefs_json(&value.to_string()).unwrap(); + assert!(s.prefs().general.beep_when_done); + assert_eq!(s.presets.tool_presets[0].name, "Unrelated tool preset"); + assert!(s.presets.documents.is_empty()); + } + let mut value = original.prefs_value(); + let mut bad = valid.clone(); + bad["settings"]["width"] = json!(0); + value["presets"]["documents"] = json!([bad, valid, valid]); + let mut s = Session::new(); + s.load_prefs_json(&value.to_string()).unwrap(); + assert_eq!(s.presets.documents.len(), 1, "keep valid entries, discard bad/duplicate entries"); + assert_eq!(s.presets.documents[0].name, "Keep me"); + for without_presets in [false, true] { + let mut value = original.prefs_value(); + if without_presets { + value.as_object_mut().unwrap().remove("presets"); + } else { + value["presets"].as_object_mut().unwrap().remove("documents"); + } + let mut old = Session::new(); + old.load_prefs_json(&value.to_string()).unwrap(); + assert!(old.presets.documents.is_empty()); + assert!(old.prefs().general.beep_when_done); + } + } + + #[test] + fn bad_input_and_duplicate_names_never_replace_or_mutate_a_snapshot() { + let mut s = Session::new(); + let mut config = settings(); + saved(&mut s, "Keep me", config.clone()); + config["width"] = json!(99); + assert_eq!(s.presets.documents[0].settings.width, 16); + let before = s.prefs_value(); + let rev = s.prefs.rev(); + for name in ["", " \t\n ", "Keep me", " keep ME ", &"x".repeat(256)] { + assert!(s.execute("document.presets.save", json!({"name":name,"settings":config})).is_err()); + } + for (key, bad) in [ + ("width", json!(0)), + ("height", json!(-1)), + ("width", json!(300_001)), + ("width", json!("wrong")), + ("resolution", json!(0)), + ("resolution", json!(30_001)), + ("resolution", Value::Null), + ("mode", json!("indexed")), + ("depth", json!(64)), + ("depth", json!("16")), + ("background", json!("invalid")), + ("backgroundColor", json!([1, 0])), + ("backgroundColor", json!([2, 0, 0])), + ("unit", json!("yards")), + ("resolutionUnit", json!("px")), + ] { + let mut config = settings(); + config[key] = bad; + assert!(s.execute("document.presets.save", json!({"name":"Bad","settings":config})).is_err(), "{key}"); + } + for p in [json!({}), Value::Null, json!({"name":12}), json!({"name":"Bad","settings":[]})] { + assert!(s.execute("document.presets.save", p).is_err()); + } + assert_eq!(s.prefs_value(), before); + assert_eq!(s.prefs.rev(), rev); + } + + #[test] + fn invalid_explicit_background_colour_does_not_create_a_document() { + let mut s = Session::new(); + for color in [Value::Null, json!([1, 2]), json!([1, -1, 0]), json!("red")] { + assert!(s.execute("file.new", json!({"background":"backgroundColor","backgroundColor":color})).is_err()); + assert!(s.documents().is_empty()); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7b209455..b1a411a2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -26,6 +26,7 @@ pub mod comps_cmds; pub mod cutout_cmds; pub mod display_color; pub mod distort_cmds; +pub mod document_preset_cmds; pub mod edit_cmds; pub mod edit_menu_cmds; pub mod eraser_cmds; diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index c78646e8..57d739f1 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -79,6 +79,8 @@ pub struct PresetState { /// The pattern the Patterns panel selected (Fill, Pattern fill layers default to it). pub pattern: Option, pub tool_presets: Vec, + /// File › New: named snapshots, separate from the name of the document being created. + pub documents: Vec, pub clone: clone_source::CloneSources, /// The user defaults "Make Default" in the Layer Style dialog saves /// (effect kind → param set); "Reset to Default" restores them. @@ -99,6 +101,7 @@ impl Default for PresetState { pattern_groups: patterns::builtin_groups(), pattern: None, tool_presets: tools::builtin(), + documents: Vec::new(), clone: Default::default(), layer_defaults: BTreeMap::new(), swatches: swatches::builtin(), @@ -122,6 +125,9 @@ struct Persisted { pattern_groups: Option>>, #[serde(default)] tool_presets: Option>, + // Read this set independently: a bad entry must not discard the other preset kinds. + #[serde(default)] + documents: Option, #[serde(default)] custom_shapes: Option>, #[serde(default)] @@ -139,6 +145,7 @@ impl PresetState { shapes: Some(self.shapes.clone()), pattern_groups: Some(self.pattern_groups.clone()), tool_presets: Some(self.tool_presets.clone()), + documents: Some(json!(self.documents)), custom_shapes: Some(s.edit_state.custom_shapes.clone()), layer_defaults: Some(self.layer_defaults.clone()), swatches: Some(self.swatches.clone()), @@ -171,6 +178,9 @@ impl Session { if let Some(t) = p.tool_presets { st.tool_presets = t; } + if let Some(d) = p.documents { + st.documents = crate::document_preset_cmds::load(d); + } if let Some(c) = p.custom_shapes { self.edit_state.custom_shapes = c; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 13a8054a..fe1958ae 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -133,7 +133,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::widgets::hairline(ui); ui.add_space(8.0); match d.kind { - DialogKind::NewDocument => crate::new_doc_ui::body(ui, &mut fields), + DialogKind::NewDocument => crate::new_doc_ui::body(app, ui, &mut fields), DialogKind::About if fields.get("systemInfo").and_then(Value::as_bool) == Some(true) => { let lines = crate::gpu_status::system_info(app); for l in &lines { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 0a73df96..52cb21ce 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1484,6 +1484,10 @@ ↑↓ navigate {key} run esc close ↑↓ pohyb {key} spustit esc zavřít Version {version} Verze {version} New Document Nový dokument + Saved Uložené + Save Preset… Uložit předvolbu… + Preset Name Název předvolby + No saved presets yet. Zatím nejsou uloženy žádné předvolby. Error Chyba Command Příkaz diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9c85d211..15aacf45 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1468,6 +1468,9 @@ ↑↓ navigate {key} run esc close ↑↓ Navigieren {key} Ausführen Esc Schließen Version {version} Version {version} New Document Neues Dokument + Save Preset… Vorgabe speichern… + Preset Name Name der Vorgabe + No saved presets yet. Noch keine gespeicherten Vorgaben. Error Fehler Command Befehl Adjustment layers have nothing to transform Anpassungsebenen haben nichts zu transformieren diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 22c29053..2661b96f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1469,6 +1469,9 @@ ↑↓ navigate {key} run esc close ↑↓ navegar {key} ejecutar esc cerrar Version {version} Versión {version} New Document Documento nuevo + Save Preset… Guardar ajuste preestablecido… + Preset Name Nombre del ajuste preestablecido + No saved presets yet. Todavía no hay ajustes preestablecidos guardados. Error Error Command Comando diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 90c5252f..e0dcbca6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1454,6 +1454,8 @@ ↑↓ navigate {key} run esc close ↑↓ naviguer {key} exécuter échap fermer Version {version} Version {version} New Document Nouveau document + Preset Name Nom du préréglage + No saved presets yet. Aucun préréglage enregistré pour le moment. Error Erreur Command Commande Adjustment layers have nothing to transform Les calques d'ajustement n'ont rien à transformer diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8a701040..f415e69a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1448,6 +1448,8 @@ ↑↓ navigate {key} run esc close ↑↓ navigasi {key} jalankan esc tutup Version {version} Versi {version} New Document Dokumen Baru + Preset Name Nama Preset + No saved presets yet. Belum ada preset tersimpan. Error Error Command Perintah Adjustment layers have nothing to transform Layer penyesuaian tidak ada yang bisa ditransformasi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 83575a2f..7257394c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1468,6 +1468,9 @@ ↑↓ navigate {key} run esc close ↑↓ naviga {key} esegui esc chiudi Version {version} Versione {version} New Document Nuovo documento + Save Preset… Salva predefinito… + Preset Name Nome del predefinito + No saved presets yet. Nessun predefinito salvato. Error Errore Command Comando diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e083260e..dd73d83c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1474,6 +1474,10 @@ ↑↓ navigate {key} run esc close ↑↓ 移動 {key} 実行 esc 閉じる Version {version} バージョン {version} New Document 新規ドキュメント + Saved 保存済み + Save Preset… プリセットを保存… + Preset Name プリセット名 + No saved presets yet. 保存済みのプリセットはありません。 Error エラー Command コマンド diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bd35f2fa..b56cbe74 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1446,6 +1446,10 @@ ↑↓ navigate {key} run esc close ↑↓ 이동 {key} 실행 esc 닫기 Version {version} 버전 {version} New Document 새 문서 + Saved 저장됨 + Save Preset… 사전 설정 저장… + Preset Name 사전 설정 이름 + No saved presets yet. 저장된 사전 설정이 없습니다. Error 오류 Command 명령 Adjustment layers have nothing to transform 조정 레이어에는 변형할 내용이 없습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5d8372d1..cfd51513 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1469,6 +1469,8 @@ ↑↓ navigate {key} run esc close ↑↓ nawigacja {key} uruchom esc zamknij Version {version} Wersja {version} New Document Nowy dokument + Preset Name Nazwa ustawienia wstępnego + No saved presets yet. Nie ma jeszcze zapisanych ustawień wstępnych. Error Błąd Command Polecenie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f6cbdeb4..00bb7b3a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1453,6 +1453,8 @@ ↑↓ navigate {key} run esc close ↑↓ navegar {key} executar esc fechar Version {version} Versão {version} New Document Novo documento + Preset Name Nome da predefinição + No saved presets yet. Nenhuma predefinição salva ainda. Error Erro Command Comando Adjustment layers have nothing to transform Camadas de ajuste não têm nada a transformar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f93153e4..425f14da 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1666,6 +1666,8 @@ Divide Разделить Result Результат New Document Новый документ + Preset Name Имя набора + No saved presets yet. Сохранённых наборов пока нет. New Channel Новый канал Selection Выделение Save Selection Сохранить выделение diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d7a90b14..e3c50927 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1480,6 +1480,10 @@ ↑↓ navigate ↵ run esc close ↑↓ 导航 ↵ 运行 esc 关闭 Version {version} 版本 {version} New Document 新建文档 + Saved 已保存 + Save Preset… 保存预设… + Preset Name 预设名称 + No saved presets yet. 尚无已保存的预设。 Error 错误 Command 命令 Adds grain to the soft edges of the brush tip. 在笔尖的柔软边缘添加颗粒。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 11d7a684..4b7741ad 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1475,6 +1475,10 @@ ↑↓ navigate {key} run esc close ↑↓ 瀏覽 {key} 執行 esc 關閉 Version {version} 版本 {version} New Document 新增文件 + Saved 已儲存 + Save Preset… 儲存預設… + Preset Name 預設集名稱 + No saved presets yet. 尚無已儲存的預設集。 Error 錯誤 Command 指令 Adjustment layers have nothing to transform 調整圖層沒有可變形的內容 diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index 75158797..8fd5b2e5 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -127,6 +127,7 @@ pub fn set_size(f: &mut Map, key: &str, v: f32, unit: &str, ppi: f.insert(key.into(), px_value(from_unit(v, unit, ppi))); f.insert(typed_key(key), json!({"value": v, "unit": unit})); f.remove("__preset"); + f.remove("__savedPreset"); } /// What Width or Height (`key`, `d` pixels when unset) shows in `unit`: the value the user typed @@ -149,6 +150,7 @@ fn typed_key(key: &str) -> String { /// Swap Width and Height (the Orientation buttons), with the values typed for them, so a typed /// 841 x 1189 mm turns into 1189 x 841 mm, not 1188.9 x 841. pub fn swap_size(f: &mut Map) { + f.remove("__savedPreset"); let (w, h) = (get_f(f, "width", 1920.0), get_f(f, "height", 1080.0)); f.insert("width".into(), px_value(h)); f.insert("height".into(), px_value(w)); @@ -163,6 +165,7 @@ pub fn swap_size(f: &mut Map) { /// Apply a preset to the dialog fields. pub fn apply_preset(f: &mut Map, p: &Preset) { + f.remove("__savedPreset"); f.insert("width".into(), json!(p.1)); f.insert("height".into(), json!(p.2)); f.insert("resolution".into(), json!(p.3)); @@ -182,6 +185,9 @@ pub fn command_params(f: &Map) -> Value { pub fn set_resolution(f: &mut Map, new_ppi: f32) { let old_ppi = get_f(f, "resolution", 72.0); f.insert("resolution".into(), json!(new_ppi)); + if old_ppi != new_ppi { + f.remove("__savedPreset"); + } if get_s(f, "__unit", "px") == "px" || !(old_ppi > 0.0 && new_ppi > 0.0) || old_ppi == new_ppi { return; } @@ -190,6 +196,7 @@ pub fn set_resolution(f: &mut Map, new_ppi: f32) { f.insert("width".into(), px_value(w * scale)); f.insert("height".into(), px_value(h * scale)); f.remove("__preset"); + f.remove("__savedPreset"); } fn get_f(f: &Map, k: &str, d: f32) -> f32 { @@ -221,15 +228,114 @@ fn page_icon(ui: &egui::Ui, r: Rect, w: u32, h: u32, t: &Tokens) { ui.painter().rect_stroke(page, 1.0, Stroke::new(1.2, t.text_dim), StrokeKind::Inside); } -pub fn body(ui: &mut egui::Ui, f: &mut Map) { +/// Restore a snapshot without replacing the new document's name or its clipboard offer. +pub fn apply_saved_preset(f: &mut Map, preset: &photocraft_engine::document_preset_cmds::DocumentPreset) { + f.remove("backgroundColor"); + if let Some(params) = preset.settings.command_params().as_object() { + f.extend(params.clone()); + } + f.insert("__unit".into(), json!(preset.settings.unit)); + f.insert("__resUnit".into(), json!(preset.settings.resolution_unit)); + f.insert("__savedPreset".into(), json!(preset.name)); + f.remove("__preset"); + // Typed Width/Height belong to the previous values (`shown_size`). + f.remove(&typed_key("width")); + f.remove(&typed_key("height")); +} + +fn saved_presets(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { + let presets = app.session.presets.documents.clone(); + if presets.is_empty() { + ui.label(tl!("No saved presets yet.")); + return; + } + egui::ScrollArea::vertical().id_salt("saved-document-presets").max_height(320.0).show(ui, |ui| { + for preset in &presets { + ui.push_id(&preset.name, |ui| { + ui.horizontal(|ui| { + let selected = get_s(f, "__savedPreset", "") == preset.name; + let label = egui::Button::new(&preset.name).selected(selected).truncate(); + if ui.add_sized(vec2(422.0, 32.0), label).on_hover_text(&preset.name).clicked() { + apply_saved_preset(f, preset); + } + if widgets::secondary_button(ui, tl!("Delete"), 76.0).clicked() { + match app.run("document.presets.delete", json!({"name":preset.name})) { + Ok(_) => { + f.remove("__presetError"); + if selected { + f.remove("__savedPreset"); + } + } + Err(e) => { + f.insert("__presetError".into(), json!(e.to_string())); + } + } + } + }); + let s = &preset.settings; + small_label(ui, &format!("{} × {} px @ {} ppi · {}/{}", s.width, s.height, s.resolution, s.mode.to_uppercase(), s.depth)); + ui.add_space(8.0); + }); + } + }); +} + +fn save_preset(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { + ui.add_space(12.0); + if f.get("__savingPreset").and_then(Value::as_bool) != Some(true) { + if widgets::secondary_button(ui, tl!("Save Preset…"), 240.0).clicked() { + f.insert("__savingPreset".into(), json!(true)); + f.remove("__presetError"); + } + } else { + // Consume before TextEdit sees Enter, so naming can never confirm the outer dialog. + let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + let label = ui.label(tl!("Preset Name")); + let mut name = get_s(f, "__presetName", ""); + ui.add(egui::TextEdit::singleline(&mut name).id_salt("document-preset-name").desired_width(240.0).char_limit(255)).labelled_by(label.id); + f.insert("__presetName".into(), json!(name)); + ui.horizontal(|ui| { + if widgets::secondary_button(ui, tl!("Save"), 110.0).clicked() || enter { + let mut settings = command_params(f); + settings["unit"] = json!(get_s(f, "__unit", "px")); + settings["resolutionUnit"] = json!(get_s(f, "__resUnit", "in")); + match app.run("document.presets.save", json!({"name":name,"settings":settings})) { + Ok(_) => { + if let Some(preset) = app.session.presets.documents.last() { + apply_saved_preset(f, preset); + } + f.insert("__category".into(), json!("Saved")); + f.remove("__savingPreset"); + f.remove("__presetName"); + f.remove("__presetError"); + ui.ctx().request_repaint(); + } + Err(e) => { + f.insert("__presetError".into(), json!(e.to_string())); + } + } + } + if widgets::secondary_button(ui, tl!("Cancel"), 110.0).clicked() { + f.remove("__savingPreset"); + f.remove("__presetError"); + } + }); + } + if let Some(error) = f.get("__presetError").and_then(Value::as_str) { + ui.add(egui::Label::new(error).wrap()); + } +} + +pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); let cat = get_s(f, "__category", "Recent"); // Category tabs. ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 18.0; - for (name, _) in CATEGORIES { - let on = cat == *name; - let r = ui.add(egui::Label::new(RichText::new(tl!(name)).size(13.0).color(if on { t.text } else { t.text_dim })).sense(Sense::click())); + for name in CATEGORIES.iter().map(|c| c.0).chain(["Saved"]) { + let on = cat == name; + let label = if name == "Saved" { tl!("Saved") } else { tl!(name) }; + let r = ui.add(egui::Label::new(RichText::new(label).size(13.0).color(if on { t.text } else { t.text_dim })).sense(Sense::click())); if on { ui.painter().line_segment([r.rect.left_bottom() + vec2(0.0, 3.0), r.rect.right_bottom() + vec2(0.0, 3.0)], Stroke::new(2.0, t.text)); } @@ -249,6 +355,10 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { // Left: preset grid. ui.vertical(|ui| { ui.set_width(520.0); + if cat == "Saved" { + saved_presets(app, ui, f); + return; + } ui.label(RichText::new(crate::i18n::fmt(tl!("BLANK DOCUMENT PRESETS ({n})"), &[("n", &presets.len().to_string())])).size(11.0).color(t.text_faint)); ui.add_space(6.0); let card = vec2(164.0, 112.0); @@ -319,6 +429,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { let opts: Vec<(String, &str)> = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect(); if widgets::dropdown(ui, "nd-unit", &mut unit, &opts, 120.0) { f.insert("__unit".into(), json!(unit)); + f.remove("__savedPreset"); } }); small_label(ui, tl!("Height")); @@ -348,6 +459,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { if widgets::dropdown(ui, "nd-resunit", &mut ru, &[("in".to_string(), tl!("Pixels/Inch")), ("cm".to_string(), tl!("Pixels/Centimeter"))], 120.0) { f.insert("__resUnit".into(), json!(ru)); + f.remove("__savedPreset"); } }); ui.add_space(4.0); @@ -367,11 +479,13 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { 110.0, ) { f.insert("mode".into(), json!(mode)); + f.remove("__savedPreset"); } let mut depth = f.get("depth").and_then(Value::as_u64).unwrap_or(8); let depth_options: Vec<(u64, &str, &str)> = DEPTH_OPTIONS.iter().map(|(bits, label, tooltip)| (*bits, *label, *tooltip)).collect(); if widgets::dropdown_with_tooltips(ui, "nd-depth", &mut depth, &depth_options, 120.0) { f.insert("depth".into(), json!(depth)); + f.remove("__savedPreset"); } }); ui.add_space(4.0); @@ -385,7 +499,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ]; if widgets::dropdown(ui, "nd-bg", &mut bg, &opts, 240.0) { f.insert("background".into(), json!(bg)); + f.remove("__savedPreset"); + // A fresh choice of Background Color uses today's toolbox colour. Selecting a + // saved preset instead restores the colour captured when it was saved. + f.remove("backgroundColor"); } + save_preset(app, ui, f); }); }); } @@ -512,6 +631,39 @@ mod tests { let d = &s.active().unwrap().doc; assert_eq!((d.size.width, d.size.height, d.resolution_dpi), (2480, 3508, 300.0)); } + + #[test] + fn saved_selection_restores_all_settings_but_keeps_the_document_name() { + let mut session = photocraft_engine::Session::new(); + session + .execute( + "document.presets.save", + json!({"name":"Print proof","settings":{ + "width":1600,"height":800,"resolution":254.0,"mode":"cmyk","depth":16, + "background":"backgroundColor","backgroundColor":[0.2,0.3,0.4],"unit":"mm","resolutionUnit":"cm" + }}), + ) + .unwrap(); + let preset = session.presets.documents[0].clone(); + let mut f = crate::state::UiState::new_document_fields(); + set_clipboard(&mut f, 20, 30); + f.insert("name".into(), json!("Catalog cover")); + apply_saved_preset(&mut f, &preset); + assert_eq!(f["name"], "Catalog cover"); + assert_eq!(f["__unit"], "mm"); + assert_eq!(f["__resUnit"], "cm"); + assert_eq!(f["__savedPreset"], "Print proof"); + assert!(f.get("__preset").is_none()); + assert_eq!(clipboard_preset(&f), Some((CLIPBOARD, 20, 30, 72.0))); + let mut expected = preset.settings.command_params(); + expected["name"] = json!("Catalog cover"); + assert_eq!(command_params(&f), expected); + f.insert("width".into(), json!(42)); + assert_eq!(session.presets.documents[0], preset, "editing the form does not edit the snapshot"); + apply_preset(&mut f, &(CLIPBOARD, 20, 30, 72.0)); + assert!(f.get("__savedPreset").is_none()); + assert_eq!((f["width"].as_u64(), f["height"].as_u64()), (Some(20), Some(30))); + } /// The real dialog (#254): a typed size must reach `file.new`, however it is confirmed. mod dialog { use super::super::{CATEGORIES, apply_preset}; @@ -519,6 +671,113 @@ mod tests { use crate::state::{DialogKind, UiState}; use egui::accesskit::Role; use egui_kittest::{Harness, kittest::Queryable}; + use std::sync::{Arc, Mutex}; + + /// The real preference services, backed by an in-memory store shared across app restarts. + fn harness_with_store(store: &Arc>>) -> Harness<'static, PhotocraftApp> { + let (read, write) = (store.clone(), store.clone()); + let services = crate::Services { + load_prefs: Some(Box::new(move || read.lock().unwrap().clone())), + save_prefs: Some(Box::new(move |text| { + *write.lock().unwrap() = Some(text.to_string()); + Ok(()) + })), + ..Default::default() + }; + let app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app| { + crate::prefs_ui::tick(app, ui.ctx()); + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.state_mut().ui.open_dialog(DialogKind::NewDocument, UiState::new_document_fields()); + h.run_steps(3); + h + } + + fn name_preset(h: &mut Harness<'static, PhotocraftApp>, name: &str) { + h.get_by_label("Save Preset…").click(); + h.run_steps(2); + h.get_by_role_and_label(Role::TextInput, "Preset Name").click(); + h.run_steps(1); + h.event(egui::Event::Text(name.into())); + h.run_steps(1); + } + + #[test] + fn save_restart_select_create_and_delete_through_the_dialog() { + let store = Arc::new(Mutex::new(None)); + let mut h = harness_with_store(&store); + type_into(&mut h, 0, "1600"); + type_into(&mut h, 1, "1600"); + let mut f = fields(&h); + f.insert("background".into(), serde_json::json!("transparent")); + set_fields(&mut h, f); + name_preset(&mut h, " Product square "); + h.get_by_label("Save").click(); + h.run_steps(3); + assert!(h.state().session.documents().is_empty(), "saving does not create a document"); + assert_eq!(h.state().session.presets.documents[0].name, "Product square"); + type_into(&mut h, 0, "32"); + assert_eq!(h.state().session.presets.documents[0].settings.width, 1600); + drop(h); + + let mut h = harness_with_store(&store); + h.get_by_label("Saved").click(); + h.run_steps(2); + h.get_by_label("Product square").click(); + h.run_steps(2); + assert!(h.state().session.documents().is_empty(), "selection only populates the form"); + assert_eq!(fields(&h)["name"], "Untitled-1"); + assert_eq!(fields(&h)["background"], "transparent"); + h.get_by_label("Create").click(); + h.run_steps(3); + assert_eq!(created(&h), (1600, 1600, 72.0)); + let pixel = h.state_mut().session.execute("document.pixel", serde_json::json!({"x":0,"y":0})).unwrap(); + assert_eq!(pixel[3], 0.0); + + h.state_mut().ui.open_dialog(DialogKind::NewDocument, UiState::new_document_fields()); + h.run_steps(2); + h.get_by_label("Saved").click(); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(3); + drop(h); + let mut h = harness_with_store(&store); + h.get_by_label("Saved").click(); + h.run_steps(2); + assert!(h.state().session.presets.documents.is_empty()); + assert!(h.query_by_label("No saved presets yet.").is_some()); + } + + #[test] + fn enter_saves_the_preset_and_bad_names_leave_the_form_open() { + let mut h = harness(); + name_preset(&mut h, "Enter test"); + enter(&mut h); + assert_eq!(h.state().session.presets.documents.len(), 1); + assert!(h.state().session.documents().is_empty(), "Enter must not also press Create"); + assert_eq!(h.state().ui.dialogs.len(), 1); + name_preset(&mut h, " ENTER TEST "); + h.get_by_label("Save").click(); + h.run_steps(2); + assert!(fields(&h).get("__presetError").is_some()); + assert_eq!(h.state().session.presets.documents.len(), 1); + assert!(h.state().session.documents().is_empty()); + h.get_by_label("Cancel").click(); + h.run_steps(2); + name_preset(&mut h, ""); + // Cancel keeps the entered name; clear it as an automation edit, then use the button. + h.state_mut().ui.dialogs[0].fields.insert("__presetName".into(), serde_json::json!(" ")); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(2); + assert!(fields(&h).get("__presetError").is_some()); + assert_eq!(h.state().session.presets.documents.len(), 1); + } fn harness() -> Harness<'static, PhotocraftApp> { let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 42df5434..12458e28 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -85,6 +85,10 @@ store expose and persist the same setting; scripts keep using canonical command | `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | | `document.inspect` | `{}`: layer tree, history, selection bounds | | `document.pixel` | `{"x":10,"y":10}`: composite RGBA | +| `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations | +| `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected | +| `document.presets.get` | `{"name":"Product square"}` → `{preset,params}`: fetch settings; pass `params` to `file.new` to create a document, optionally adding a document `name` | +| `document.presets.delete` | `{"name":"Product square"}`: delete the named saved configuration | | `type.hitTest` | `{"layer":id?,"x":px,"y":px}`: character under a document point. No `layer` picks the topmost visible type layer there. Result `{"layer","index","line","inside"}` | | `type.caret` | `{"layer":id?,"index":char}`: caret segment in document pixels, `{"index","line","segment":[[x,y],[x,y]]}` (rotated and vertical type included) | | `type.navigate` | `{"layer":id?,"index":char,"move":"wordPrev\|wordNext\|linePrev\|lineNext\|lineStart\|lineEnd\|start\|end","x":px?}`: neighbouring caret. `x` keeps the column across line moves. Result `{"index"}` | @@ -157,6 +161,16 @@ Headless CLI/MCP sessions and the web build keep brush presets and the action li only, unless a store is attached. Gradient presets (including imported `.grd` groups) persist with the preferences. +Named New Document presets also persist with preferences (`presets.documents`) on desktop and +web. File › New › Save Preset… saves the current form; the Saved tab selects a configuration or +deletes it. Selection only fills the form: Create still runs `file.new`, and the document name +is independent of the preset name. Settings include pixel width/height, resolution in ppi, +mode, depth, background, and display `unit` (`px`, `in`, `cm`, `mm`, `pt`, `pica`) and +`resolutionUnit` (`in`, `cm`). The defaults match File › New. Background Color captures the +toolbox colour as `backgroundColor: [r,g,b]` (0..1), so reusing it does not follow later toolbox +changes. Up to 256 presets are kept. Invalid saved entries are skipped independently; other +presets and preferences still load. Automatic Recent configurations are separate follow-up work. + ## Snapping With View › Snap on, tool gestures snap to the View › Snap To targets (guides and grid while they diff --git a/docs/scorecard.md b/docs/scorecard.md index 74ab0cbb..36d4cb67 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 10 | 14 | 8 | | -| [Workspace and UI](#workspace-and-ui) | 0 | 6 | 17 | settings that do nothing: 56 of 140 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 0 | 7 | 16 | settings that do nothing: 56 of 140 (target 0) | | [Type](#type) | 0 | 3 | 6 | | | [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) | | [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present | @@ -173,7 +173,7 @@ Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipbo | UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | missing | [#217](https://github.com/storytold/photocraft/issues/217) | prefs.rs:61 UiScale unused; input_tests.rs:71 asserts zoom_factor stays 1.0 | | UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No such prefs; start screen (canvas.rs:825) has no recents | | UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs:28 five themes | -| UI-217-12 | Save custom New Document presets; a real Recent category | missing | [#217](https://github.com/storytold/photocraft/issues/217) | new_doc_ui.rs:17 'Recent' is a static list | +| UI-217-12 | Save custom New Document presets; a real Recent category | partial | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard | | UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | partial | [#217](https://github.com/storytold/photocraft/issues/217) | widgets.rs:132 DragValue scrubs and takes + - * / arithmetic; no units in expressions, label scrub or reset | | UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1108 plain dropdown; trash is click-only (panels.rs:1213) | | UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | partial | [#217](https://github.com/storytold/photocraft/issues/217) | adjust_dialog.rs adds black/gray/white eyedroppers; smoothing and pencil modes remain missing | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 7e15fd5a..5b7d8425 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -85,9 +85,9 @@ note = "theme.rs:28 five themes" [[item]] id = "UI-217-12" target = "Save custom New Document presets; a real Recent category" -status = "missing" +status = "partial" issue = 217 -note = "new_doc_ui.rs:17 'Recent' is a static list" +note = "document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard" [[item]] id = "UI-217-13"