diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 5cda41e5..af0f5fe8 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -613,13 +613,14 @@ fn format_filters() -> impl Iterator impl Iterator { std::iter::once(("All readable files", OPEN_EXTS)) .chain(format_filters()) .chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS))) .chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS))) .chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS))) + .chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS))) } /// File → Place dialog filters: "All placeable files", then one per readable format, then text. diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index c04120d1..560e8d28 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -146,7 +146,7 @@ fn open_exts_cover_every_readable_format() { } let filters: Vec<_> = open_filters().collect(); assert_eq!(filters[0], ("All readable files", OPEN_EXTS)); - assert_eq!(filters.len(), 4 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener presets and PDF presets"); + assert_eq!(filters.len(), 5 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets"); } #[test] diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3f2259ff..979b19ba 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -47,6 +47,7 @@ pub mod pdfcmds; mod place; pub mod prefscmds; pub mod print; +pub mod printpresets; pub mod rasterfx; mod recolor; pub mod recovery; @@ -239,6 +240,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(webexport::specs()); v.extend(assets::specs()); v.extend(css::specs()); + v.extend(printpresets::specs()); v }) } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 95982d45..3fd4ba06 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -51,7 +51,11 @@ fn saved(doc: &Document) -> Value { /// The settings of `doc` with `p.settings` over them, checked. fn settings(cmd: &str, doc: &Document, p: &Value) -> Result { - let mut v = saved(doc); + settings_over(cmd, saved(doc), p) +} + +/// The settings `v` (JSON) with `p.settings` over them, checked. +pub(crate) fn settings_over(cmd: &str, mut v: Value, p: &Value) -> Result { match p.get("settings") { None | Some(Value::Null) => {} Some(over @ Value::Object(_)) => merge(&mut v, over), diff --git a/crates/engine/src/cmd/printpresets.rs b/crates/engine/src/cmd/printpresets.rs new file mode 100644 index 00000000..c209d86b --- /dev/null +++ b/crates/engine/src/cmd/printpresets.rs @@ -0,0 +1,246 @@ +//! Edit → Print Presets: `print.presets.list`, `save`, `delete`, `export` and `import`. The +//! built-in [Default] (the default print settings) is generated in code and protected; the user's +//! presets live in [`crate::Prefs::print_presets`]. The Print dialog's preset list loads them. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_pdf::PrintSettings; + +use super::fileio::pdf::changed; +use super::print::settings_over; +use super::*; + +/// The built-in preset's name. +pub const DEFAULT_PRESET: &str = "[Default]"; + +/// The `format` of a print presets file, also its extension. +pub const PRESET_FORMAT: &str = "vcprintpresets"; + +/// The extensions of print presets files (opening one imports it). +pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT]; + +/// A named set of print settings: [Default] or a saved one ([`crate::Prefs::print_presets`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrintPreset { + pub name: String, + #[serde(default)] + pub settings: PrintSettings, +} + +/// What `print.presets.export` writes and `print.presets.import` reads. +#[derive(Serialize, Deserialize)] +struct PresetFile { + format: String, + #[serde(default)] + presets: Vec, +} + +pub fn specs() -> Vec { + vec![ + cmd!( + query "print.presets.list", + "Print Presets", + [], + None, + "{} → {presets: [{name, builtIn, settings (as print.setup's), changed: [{option, value}] (how it differs from [Default])}]} [Default] (the default print settings, protected) first, then the saved presets", + always, + list + ), + cmd!( + query "print.presets.save", + "Save Print Preset", + [], + None, + "{name?: (default: a new \"Print Preset N\"), newName?: rename it, preset?: the preset to start from (default: the saved preset `name`, else [Default]), settings?: {…print.setup settings} (over it)} create or change a saved preset ([Default] is protected) → {name, created, settings}", + always, + save + ), + cmd!(query "print.presets.delete", "Delete Print Preset", [], None, "{name} delete a saved preset ([Default] stays) → {deleted: name}", always, delete), + cmd!( + query "print.presets.export", + "Export Print Presets", + [], + None, + "{names?: [preset names, [Default] too] (default: every saved preset), path?} write the presets as a .vcprintpresets file (JSON) → {path, count}; without path → {data: the file's text, count}", + always, + export + ), + cmd!( + query "print.presets.import", + "Import Print Presets", + [], + None, + "{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else imported ones whose name is taken get a number)} add the presets of a .vcprintpresets file (as print.presets.export writes) to the saved ones → {imported: [names]}", + always, + import + ), + ] +} + +/// Is `name` the built-in [Default] (also as "Default")? +pub fn is_default(name: &str) -> bool { + let n = name.trim(); + n.eq_ignore_ascii_case(DEFAULT_PRESET) || n.eq_ignore_ascii_case("Default") +} + +/// `[{option, value}]` for every setting of `set` that differs from `base` (`marks.trim`). +pub fn changes(set: &PrintSettings, base: &PrintSettings) -> Vec { + let (Ok(v), Ok(base)) = (serde_json::to_value(set), serde_json::to_value(base)) else { return vec![] }; + let mut diff = vec![]; + changed("", &v, &base, &mut diff); + diff +} + +impl Session { + /// Every print preset: [Default], then the saved ones. + pub fn print_presets(&self) -> Vec { + let mut v = vec![PrintPreset { name: DEFAULT_PRESET.into(), settings: PrintSettings::default() }]; + v.extend(self.prefs.print_presets.iter().cloned()); + v + } + + /// The settings of the print preset `name` ([Default] or a saved one, any case). + pub fn print_preset_settings(&self, name: &str) -> Option { + if is_default(name) { + return Some(PrintSettings::default()); + } + self.print_saved_index(name).and_then(|i| self.prefs.print_presets.get(i)).map(|p| p.settings.clone()) + } + + /// The first free "Print Preset N": the name a new preset gets. + pub fn new_print_preset_name(&self) -> String { + (1..).map(|i| format!("Print Preset {i}")).find(|n| !self.print_preset_taken(n, None)).unwrap_or_default() + } + + /// The index of the saved print preset `name` (any case). + fn print_saved_index(&self, name: &str) -> Option { + self.prefs.print_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim())) + } + + /// Whether `name` is [Default]'s or a saved preset's other than the one at `except`. + fn print_preset_taken(&self, name: &str, except: Option) -> bool { + is_default(name) || self.print_saved_index(name).is_some_and(|i| Some(i) != except) + } +} + +fn row(p: PrintPreset, built_in: bool) -> Value { + let changed = changes(&p.settings, &PrintSettings::default()); + json!({ "name": p.name, "builtIn": built_in, "settings": p.settings, "changed": changed }) +} + +fn list(s: &mut Session, _: &Value) -> Result { + let rows: Vec = s.print_presets().into_iter().enumerate().map(|(i, p)| row(p, i == 0)).collect(); + Ok(json!({ "presets": rows })) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "print.presets.save"; + let name = match str_param(p, "name").map(str::trim) { + Some("") => return Err(bad(C, "`name` is empty")), + Some(n) => n.to_string(), + None => s.new_print_preset_name(), + }; + if is_default(&name) { + return Err(bad(C, format!("{DEFAULT_PRESET} is protected: save the settings under another name"))); + } + let at = s.print_saved_index(&name); + let saved = at.and_then(|i| s.prefs.print_presets.get(i)).cloned(); + // A saved preset changes from its own settings unless `preset` says where to start. + let base = match str_param(p, "preset") { + Some(b) => s.print_preset_settings(b).ok_or_else(|| bad(C, format!("no print preset named `{b}` (see print.presets.list)")))?, + None => saved.as_ref().map(|old| old.settings.clone()).unwrap_or_default(), + }; + let base = serde_json::to_value(base).map_err(|e| EngineError::Other(e.to_string()))?; + let settings = settings_over(C, base, p)?; + let name = match str_param(p, "newName").map(str::trim) { + Some("") => return Err(bad(C, "`newName` is empty")), + Some(n) if s.print_preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))), + Some(n) => n.to_string(), + None => saved.map_or(name, |old| old.name), + }; + let preset = PrintPreset { name: name.clone(), settings: settings.clone() }; + match at.and_then(|i| s.prefs.print_presets.get_mut(i)) { + Some(slot) => *slot = preset, + None => s.prefs.print_presets.push(preset), + } + Ok(json!({ "name": name, "created": at.is_none(), "settings": settings })) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "print.presets.delete"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + if is_default(name) { + return Err(bad(C, format!("{DEFAULT_PRESET} is protected and stays"))); + } + let i = s.print_saved_index(name).ok_or_else(|| bad(C, format!("no saved print preset named `{name}`")))?; + Ok(json!({ "deleted": s.prefs.print_presets.remove(i).name })) +} + +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "print.presets.export"; + let presets = match p.get("names").and_then(Value::as_array) { + Some(names) => { + let find = |n: &Value| { + let n = n.as_str().unwrap_or_default(); + let settings = s.print_preset_settings(n).ok_or_else(|| bad(C, format!("no print preset named `{n}`")))?; + let name = s.print_saved_index(n).and_then(|i| s.prefs.print_presets.get(i)).map_or(DEFAULT_PRESET.to_string(), |q| q.name.clone()); + Ok(PrintPreset { name, settings }) + }; + names.iter().map(find).collect::>>()? + } + None => s.prefs.print_presets.clone(), + }; + if presets.is_empty() { + return Err(bad(C, "no saved presets to export (name [Default] in `names` to export it)")); + } + let count = presets.len(); + let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?; + match str_param(p, "path") { + Some(path) => { + super::fileio::write_file(path, text.as_bytes())?; + Ok(json!({"path": path, "count": count})) + } + None => Ok(json!({"data": text, "count": count})), + } +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "print.presets.import"; + let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) { + (Some(path), ..) => super::fileio::read_file(path)?, + (None, Some(text), _) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?, + _ => return Err(bad(C, "give `path`, `data` or `dataBase64`")), + }; + let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a print presets file: {e}")))?; + if file.format != PRESET_FORMAT { + return Err(bad(C, format!("not a print presets file (format `{}`)", file.format))); + } + // Values a hand-edited file may carry are refused as the print commands refuse them. + for q in &file.presets { + q.settings.check().map_err(|e| bad(C, format!("`{}`: {e}", q.name)))?; + } + let replace = bool_or(p, "replace", false); + let mut imported = vec![]; + for mut preset in file.presets { + // [Default] comes in as a saved copy ("Default 2"…): the built-in one stays. + let name = preset.name.trim().trim_start_matches('[').trim_end_matches(']').trim(); + let base = Some(name).filter(|n| !n.is_empty()).unwrap_or("Print Preset").to_string(); + match s.print_saved_index(&base).filter(|_| replace) { + Some(i) => { + if let Some(slot) = s.prefs.print_presets.get_mut(i) { + preset.name = slot.name.clone(); + imported.push(preset.name.clone()); + *slot = preset; + } + } + None => { + preset.name = unique_name(&base, |n| s.print_preset_taken(n, None)); + imported.push(preset.name.clone()); + s.prefs.print_presets.push(preset); + } + } + } + Ok(json!({ "imported": imported })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4ce3bd7c..8eaebb10 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -465,6 +465,10 @@ pub struct Prefs { /// Save for Web: the settings the dialog opens on (`webExport.settings`); none until set. #[serde(skip_serializing_if = "Option::is_none")] pub web_export_settings: Option, + /// Edit → Print Presets: the user's presets ([Default] isn't stored). A local library, not a + /// Preferences dialog field: resetting the preferences keeps it; `print.presets.*` edit it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub print_presets: Vec, } impl Default for Prefs { @@ -596,6 +600,7 @@ impl Default for Prefs { use_compression: false, web_export_presets: vec![], web_export_settings: None, + print_presets: vec![], } } } @@ -1210,6 +1215,8 @@ mod tests_previewbounds; #[cfg(test)] mod tests_print; #[cfg(test)] +mod tests_printpresets; +#[cfg(test)] mod tests_printpreview; #[cfg(test)] mod tests_proxyitems; diff --git a/crates/engine/src/tests_printpresets.rs b/crates/engine/src/tests_printpresets.rs new file mode 100644 index 00000000..fafa25cb --- /dev/null +++ b/crates/engine/src/tests_printpresets.rs @@ -0,0 +1,108 @@ +//! Edit → Print Presets: [Default] is protected, saved presets round-trip through files and +//! persist with the preferences. + +use serde_json::json; + +use super::*; +use crate::cmd::printpresets::DEFAULT_PRESET; + +fn is_bad_params(r: Result) -> bool { + matches!(r, Err(EngineError::BadParams { .. })) +} + +#[test] +fn default_is_listed_first_and_protected() { + let mut s = Session::new(); + let v = s.execute("print.presets.list", &json!({})).unwrap(); + assert_eq!((v["presets"][0]["name"].clone(), v["presets"][0]["builtIn"].clone()), (json!(DEFAULT_PRESET), json!(true))); + assert_eq!(v["presets"][0]["settings"]["copies"], 1); + assert_eq!(v["presets"][0]["changed"], json!([])); + for name in [DEFAULT_PRESET, "default", " [default] "] { + assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": name, "settings": {"copies": 2}}))), "{name}"); + assert!(is_bad_params(s.execute("print.presets.delete", &json!({"name": name}))), "{name}"); + } + // Nor can a saved preset take its name. + s.execute("print.presets.save", &json!({"name": "Proofs"})).unwrap(); + assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": "Proofs", "newName": "Default"})))); + assert_eq!(s.prefs.print_presets.len(), 1); + assert!(is_bad_params(s.execute("print.presets.delete", &json!({"name": "Nope"})))); +} + +#[test] +fn presets_save_change_rename_and_delete() { + let mut s = Session::new(); + let v = s.execute("print.presets.save", &json!({"settings": {"media": "a4", "marks": {"trim": true}}})).unwrap(); + assert_eq!((v["name"].clone(), v["created"].clone()), (json!("Print Preset 1"), json!(true))); + // A saved preset changes from its own settings; `preset` starts from another one. + let v = s.execute("print.presets.save", &json!({"name": "print preset 1", "settings": {"copies": 3}})).unwrap(); + assert_eq!((v["created"].clone(), v["settings"]["media"].clone(), v["settings"]["copies"].clone()), (json!(false), json!("a4"), json!(3))); + let v = s.execute("print.presets.save", &json!({"name": "Plain", "preset": "Print Preset 1", "settings": {"marks": {"trim": false}}})).unwrap(); + assert_eq!((v["settings"]["copies"].clone(), v["settings"]["marks"]["trim"].clone()), (json!(3), json!(false))); + let v = s.execute("print.presets.list", &json!({})).unwrap(); + let changed: Vec<&str> = v["presets"][1]["changed"].as_array().unwrap().iter().map(|c| c["option"].as_str().unwrap()).collect(); + assert_eq!(changed, ["copies", "marks.trim", "media"]); + // Renames keep the settings; bad settings and unknown bases are refused. + s.execute("print.presets.save", &json!({"name": "Plain", "newName": "Draft"})).unwrap(); + assert_eq!(s.prefs.print_presets[1].name, "Draft"); + assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": "Draft", "newName": "print preset 1"})))); + assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": "X", "settings": {"copies": 0}})))); + assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": "X", "preset": "Nope"})))); + assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": " "})))); + assert_eq!(s.execute("print.presets.delete", &json!({"name": "draft"})).unwrap()["deleted"], "Draft"); + assert_eq!(s.prefs.print_presets.len(), 1); +} + +#[test] +fn presets_export_and_import_round_trip() { + let mut s = Session::new(); + s.execute("print.presets.save", &json!({"name": "Tiles", "settings": {"scaling": "tileFull", "overlap": 18}})).unwrap(); + let saved = s.prefs.print_presets.clone(); + let data = s.execute("print.presets.export", &json!({"names": ["Tiles", DEFAULT_PRESET]})).unwrap(); + assert_eq!(data["count"], 2); + let text = data["data"].as_str().unwrap().to_string(); + s.execute("print.presets.delete", &json!({"name": "Tiles"})).unwrap(); + let r = s.execute("print.presets.import", &json!({"data": text})).unwrap(); + assert_eq!(r["imported"], json!(["Tiles", "Default 2"]), "[Default] comes in as a saved copy"); + assert_eq!(s.prefs.print_presets[0], saved[0], "the same preset comes back"); + assert_eq!(s.prefs.print_presets[1].settings, vectorcraft_pdf::PrintSettings::default()); + // replace: saved presets of the same names are overwritten. + let r = s.execute("print.presets.import", &json!({"data": text, "replace": true})).unwrap(); + assert_eq!(r["imported"], json!(["Tiles", "Default 3"])); + assert_eq!(s.prefs.print_presets.len(), 3); + // Through a file, and every saved preset by default. + let path = std::env::temp_dir().join(format!("vc-printpresets-{}.vcprintpresets", std::process::id())).to_string_lossy().to_string(); + assert_eq!(s.execute("print.presets.export", &json!({"path": path})).unwrap()["count"], 3); + let mut t = Session::new(); + assert_eq!(t.execute("print.presets.import", &json!({"path": path})).unwrap()["imported"].as_array().unwrap().len(), 3); + assert_eq!(t.prefs.print_presets, s.prefs.print_presets); + let _ = std::fs::remove_file(&path); + // Not a presets file, or values out of range: refused, nothing added. + for bad in [ + json!({"data": "{}"}), + json!({"data": "{\"format\": \"vcpdfpresets\", \"presets\": []}"}), + json!({"data": "{\"format\": \"vcprintpresets\", \"presets\": [{\"name\": \"X\", \"settings\": {\"copies\": 5000}}]}"}), + json!({"data": "{\"format\": \"vcprintpresets\", \"presets\": [{\"name\": \"X\", \"settings\": {\"media\": \"quarto\"}}]}"}), + json!({"dataBase64": "!!"}), + json!({}), + ] { + assert!(is_bad_params(t.execute("print.presets.import", &bad)), "{bad}"); + } + assert_eq!(t.prefs.print_presets.len(), 3); + assert!(is_bad_params(Session::new().execute("print.presets.export", &json!({}))), "nothing saved to export"); + assert!(is_bad_params(Session::new().execute("print.presets.export", &json!({"names": ["Nope"]})))); +} + +#[test] +fn saved_presets_persist_with_the_preferences() { + let mut s = Session::new(); + s.execute("print.presets.save", &json!({"name": "Kept", "settings": {"copies": 2}})).unwrap(); + let back: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back.print_presets, s.prefs.print_presets); + // Resetting the preferences keeps them (a library, not a preference). + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.prefs.print_presets.len(), 1); + // Preferences saved before print presets existed still load, and none are written while empty. + let old: crate::Prefs = serde_json::from_value(json!({"keyboardIncrement": 2.0})).unwrap(); + assert!(old.print_presets.is_empty()); + assert!(crate::Prefs::default().to_json().get("printPresets").is_none()); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 80482de5..f395437a 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1105,3 +1105,40 @@ proptest! { paste_from_elsewhere("clipboard.importEmf", json!({"dataBase64": mutated_b64(metafile_samples()[which].clone(), cut, &edits)}))?; } } + +// ---------- print presets files ---------- + +/// Import print presets from `data`, then lay out (and print, when short) the rich document with +/// each one imported. +fn print_presets(what: &str, data: &str) -> Result<(), TestCaseError> { + survive_library(what, "print.presets.import", data, |s, r| { + let list = s.execute("print.presets.list", &json!({})).unwrap_or_default(); + for name in r["imported"].as_array().into_iter().flatten() { + let Some(p) = list["presets"].as_array().into_iter().flatten().find(|p| &p["name"] == name) else { continue }; + let settings = json!({ "settings": p["settings"] }); + let pages = s.execute("print.preview", &settings).ok().and_then(|v| v["pages"].as_u64()); + let _ = s.execute("print.setup", &settings); + if pages.is_some_and(|n| n <= 8) { + let _ = s.execute("file.print", &settings); + } + } + }) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn print_presets_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "{\"format\": \"vcprintpresets\", ", "{\"format\": \"vcprintpresets\", \"presets\": [{\"name\": \"x\", \"settings\": "])) { + print_presets("print presets garbage", &format!("{head}{s}"))?; + } + + #[test] + fn mutated_print_presets_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) { + let mut s = rich_session(); + s.execute("print.presets.save", &json!({"name": "Tiles", "settings": {"scaling": "tileFull", "scale": {"width": 300, "height": 300}, "marks": {"trim": true}}})).unwrap(); + s.execute("print.presets.save", &json!({"name": "Seps", "settings": {"output": {"mode": "separations", "inks": [{"name": "Cyan", "print": false}]}}})).unwrap(); + let text = s.execute("print.presets.export", &json!({"names": ["Tiles", "Seps", "[Default]"]})).unwrap()["data"].as_str().unwrap().to_string(); + print_presets("mutated print presets", &mutate_text(&text, cut, &edits))?; + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 06102517..dca6c0e4 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -42,6 +42,7 @@ pub mod place; pub mod placement_options; mod png_options; pub mod print; +pub mod print_presets; mod psd_options; pub mod raster_effects; pub mod recolor; @@ -238,6 +239,8 @@ registry! { SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC, PsdOptions: ["psdOptions"] => png_options::SPEC, Print: [print::KIND] => print::SPEC, + PrintPreset: [print::PRESET_KIND] => print::PRESET_SPEC, + PrintPresets: [print_presets::KIND] => print_presets::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -372,3 +375,6 @@ mod tests_psd; #[cfg(test)] mod tests_print; + +#[cfg(test)] +mod tests_print_presets; diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 127dd962..1d189736 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -1,4 +1,5 @@ -//! File → Print: the Print dialog. The printer (and Setup…) on top, then the sections General, +//! File → Print: the Print dialog. The print preset (and Save Preset…) and the printer (and +//! Setup…) on top, then the sections General, //! Marks and Bleed, Output, Graphics, Color Management, Advanced and Summary, with a preview of the //! printed page under the section list: the arrows step through the pages, dragging the art moves //! its placement on the paper. @@ -9,6 +10,11 @@ //! keeps the settings with the document (`print.setup`) and prints them (`file.print`); Done //! (`discard: true`, then confirm) only keeps them. The preview, the inks and the Summary come //! from `print.preview`: the dialog lays nothing out itself. +//! +//! `preset` names the print preset whose settings were loaded last: setting it (the list, or +//! `ui.dialog.set`) loads that preset's settings. Save Preset… names the settings as a preset +//! (`print.presets.save`). The same dialog edits a preset (kind `printPreset`, from Edit → Print +//! Presets): a `name` instead of the preset and printer, and OK saves the preset. use std::sync::{Arc, LazyLock}; @@ -24,12 +30,31 @@ use super::{DialogSpec, form}; use crate::state::Dialog; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, widgets}; +use vectorcraft_engine::cmd::printpresets::DEFAULT_PRESET; pub(super) const KIND: &str = "print"; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Print".into(), body, confirm, ok: Some("Print"), discard: Some("Done"), ..DialogSpec::FORM }; +/// The dialog kind of the preset editor (New / Edit in Edit → Print Presets). +pub(super) const PRESET_KIND: &str = "printPreset"; + +pub(super) const PRESET_SPEC: DialogSpec = DialogSpec { + heading: |d| if d.str(EDITING).is_empty() { "New Print Preset" } else { "Edit Print Preset" }.into(), + body, + confirm: confirm_preset, + ok: Some("Save Preset"), + ..DialogSpec::FORM +}; + +/// The preset editor's field holding the name of the saved preset it edits (empty: a new one). +const EDITING: &str = "__editing"; +/// The field holding the name typed for Save Preset… (present while that row shows). +const SAVE_AS: &str = "__savePresetAs"; +/// The preset whose settings were loaded last (`preset` loads its settings when it differs). +const LOADED: &str = "__loaded"; + pub(super) const SECTIONS: [&str; 7] = ["General", "Marks and Bleed", "Output", "Graphics", "Color Management", "Advanced", "Summary"]; /// The default print settings as JSON: what a field an agent left out reads as. @@ -37,11 +62,11 @@ static DEFAULTS: LazyLock = LazyLock::new(|| serde_json::to_value(PrintSe /// The defaults of the settings a dialog of `kind` edits, when it is a Print dialog. pub(super) fn defaults(kind: &str) -> Option<&'static Value> { - (kind == KIND).then(|| &*DEFAULTS) + (kind == KIND || kind == PRESET_KIND).then(|| &*DEFAULTS) } /// The fields that aren't print settings. -const NOT_SETTINGS: [&str; 3] = ["printer", "toFile", "discard"]; +const NOT_SETTINGS: [&str; 5] = ["printer", "toFile", "discard", "preset", "name"]; /// The Printer list's entries besides the system's printers. const DEFAULT_PRINTER: &str = "Default Printer"; @@ -55,9 +80,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { let st = app.session.active().ok_or("no document")?; // The settings saved with the document, every field filled in (defaults if never set up). let saved: PrintSettings = st.doc.print_setup.clone().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); - let Value::Object(mut fields) = serde_json::to_value(saved).map_err(|e| e.to_string())? else { - return Err("print settings are not an object".into()); - }; + let mut fields = fields_of(&saved)?; let printers = crate::print::printers(app); let list = printers["printers"].as_array().map(Vec::as_slice).unwrap_or_default(); let default = list.iter().find(|p| p["default"] == true).or(list.first()).and_then(|p| p["name"].as_str()).unwrap_or_default(); @@ -66,10 +89,82 @@ pub fn open(app: &mut VectorcraftApp) -> Result { fields.insert("__printers".into(), printers); fields.insert("__section".into(), json!(SECTIONS[0])); fields.insert("__sheet".into(), json!(0)); + // The saved preset these settings are, if any (else [Default], shown as [Custom] if they differ). + let preset = app.session.prefs.print_presets.iter().find(|p| p.settings == saved).map_or(DEFAULT_PRESET, |p| p.name.as_str()); + fields.insert("preset".into(), json!(preset)); + fields.insert(LOADED.into(), json!(preset)); app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); Ok(Value::Null) } +/// The settings as dialog fields. +fn fields_of(set: &PrintSettings) -> Result, String> { + match serde_json::to_value(set).map_err(|e| e.to_string())? { + Value::Object(fields) => Ok(fields), + _ => Err("print settings are not an object".into()), + } +} + +/// Load the settings of the preset `preset` names once it changed (the list, or `ui.dialog.set`). +fn sync_preset(app: &VectorcraftApp, d: &mut Dialog) -> Result<(), String> { + let name = d.str("preset"); + if d.kind != KIND || name == d.str(LOADED) { + return Ok(()); + } + d.fields.insert(LOADED.into(), json!(name)); + let set = app.session.print_preset_settings(&name).ok_or_else(|| format!("no print preset named `{name}`"))?; + d.fields.extend(fields_of(&set)?); + Ok(()) +} + +/// Save Preset…: save the settings under the typed name and pick that preset. +fn save_preset(app: &mut VectorcraftApp, d: &mut Dialog) -> Result { + let r = app.run("print.presets.save", json!({ "name": d.str(SAVE_AS), "preset": DEFAULT_PRESET, "settings": settings(d) }))?; + d.fields.remove(SAVE_AS); + d.fields.insert("preset".into(), r["name"].clone()); + d.fields.insert(LOADED.into(), r["name"].clone()); + Ok(r) +} + +/// Open the preset editor on the saved preset `name`, or on a new preset starting from `preset` +/// (default: [Default]). +pub fn open_preset(app: &mut VectorcraftApp, params: &Value) -> Result { + let s = |k: &str| params.get(k).and_then(Value::as_str); + let (base, name, editing) = match s("name") { + Some(name) => { + let p = app.session.prefs.print_presets.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim())); + let p = p.ok_or_else(|| format!("no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it)"))?; + (p.name.clone(), p.name.clone(), p.name.clone()) + } + None => (s("preset").unwrap_or(DEFAULT_PRESET).to_string(), app.session.new_print_preset_name(), String::new()), + }; + let set = app.session.print_preset_settings(&base).ok_or_else(|| format!("no print preset named `{base}`"))?; + let mut fields = fields_of(&set)?; + fields.insert("name".into(), json!(name)); + fields.insert(EDITING.into(), json!(editing)); + fields.insert("__section".into(), json!(SECTIONS[0])); + fields.insert("__sheet".into(), json!(0)); + app.ui.dialog = Some(Dialog { kind: PRESET_KIND.into(), fields }); + Ok(Value::Null) +} + +/// Save Preset (the preset editor's OK): `print.presets.save`, then back to Edit → Print Presets on +/// it. A new preset may not take the name of another one. +fn confirm_preset(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (name, editing) = (d.str("name"), d.str(EDITING)); + let p = if editing.is_empty() { + if app.session.print_preset_settings(&name).is_some() { + return Err(format!("a preset named `{}` exists", name.trim())); + } + json!({ "name": name, "preset": DEFAULT_PRESET, "settings": settings(d) }) + } else { + json!({ "name": editing, "newName": name, "settings": settings(d) }) + }; + let r = app.run("print.presets.save", p)?; + super::print_presets::open(app, r["name"].as_str()); + Ok(r) +} + /// The print settings the dialog stands for (`print.setup`'s `settings`). pub(super) fn settings(d: &Dialog) -> Value { let mut p = form::params(d); @@ -82,18 +177,27 @@ pub(super) fn settings(d: &Dialog) -> Value { } /// Print, or Done (`discard`): keep the settings with the document (one undo step when they -/// changed), then print them; settings that can't print keep the dialog open, unchanged. +/// changed), then print them; settings that can't print keep the dialog open, unchanged. While +/// Save Preset… asks for a name, it saves the preset instead. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut d = d.clone(); // Done's flag holds for this press only: the dialog may stay open. - if let Some(open) = app.ui.dialog.as_mut() { - open.fields.remove("discard"); + let done = d.fields.remove("discard").is_some_and(|v| v == true); + let synced = sync_preset(app, &mut d); + app.ui.dialog = Some(d.clone()); + synced?; + // While Save Preset… asks for a name, OK (and Enter) saves the preset instead. + if d.fields.contains_key(SAVE_AS) { + let r = save_preset(app, &mut d); + app.ui.dialog = Some(d); + return r; } - let settings = settings(d); + let settings = settings(&d); let p = json!({ "settings": settings }); // Laid out first, so a bad range or overlap is refused before anything is kept. app.session.execute("print.preview", &p).map_err(|e| e.to_string())?; app.run("print.setup", p)?; - if d.bool("discard") { + if done { app.ui.dialog = None; return Ok(json!({ "settings": settings })); } @@ -106,7 +210,17 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); - printer_row(app, ui, d); + if let Err(e) = sync_preset(app, d) { + app.status(e); + } + if d.kind == PRESET_KIND { + row_with(ui, "Preset name:", TOP_LABEL_WIDTH, |ui| { + form::text_edit(ui, d, "name", 288.0); + }); + } else { + preset_rows(app, ui, d); + printer_row(app, ui, d); + } ui.add_space(10.0); let section = current_section(d, &SECTIONS); let preview = preview_of(app, ui.ctx(), &settings(d)); @@ -126,7 +240,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { "Graphics" => graphics(ui, d), "Color Management" => color(ui, d), "Advanced" => advanced(app, ui, d), - "Summary" => summary(ui, d, &preview), + "Summary" => summary(ui, d, &preview, app.session.active().is_some()), _ => general(app, ui, d), }); }); @@ -134,6 +248,49 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The Print Preset row ([Default], the saved presets; [Custom] once the settings differ from the +/// preset's) with Save Preset…, and while Save Preset… asks for a name, the row taking it. +fn preset_rows(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + const CUSTOM: &str = "[Custom]"; + let name = d.str("preset"); + let current = serde_json::from_value::(settings(d)).ok(); + let same = current.is_some() && current == app.session.print_preset_settings(&name); + let names: Vec<&str> = std::iter::once(DEFAULT_PRESET).chain(app.session.prefs.print_presets.iter().map(|p| p.name.as_str())).collect(); + let mut picked = None; + let asking = d.fields.contains_key(SAVE_AS); + let mut ask = false; + row_with(ui, "Print Preset:", TOP_LABEL_WIDTH, |ui| { + picked = + widgets::dropdown(ui, "print-preset", if same { &name } else { CUSTOM }, &names, 300.0).and_then(|i| names.get(i)).map(|n| n.to_string()); + let save = ui.add_enabled_ui(!asking, |ui| widgets::flat_button(ui, "Save Preset…", 96.0)).inner; + ask = save.on_hover_text("Save these settings as a print preset").clicked(); + }); + if let Some(n) = picked { + // Picking the preset shown again brings its settings back. + d.fields.insert(LOADED.into(), json!("")); + d.fields.insert("preset".into(), json!(n)); + if let Err(e) = sync_preset(app, d) { + app.status(e); + } + } + if ask { + d.fields.insert(SAVE_AS.into(), json!(app.session.new_print_preset_name())); + } + if d.fields.contains_key(SAVE_AS) { + row_with(ui, "Save as preset:", TOP_LABEL_WIDTH, |ui| { + form::text_edit(ui, d, SAVE_AS, 200.0); + if widgets::flat_button(ui, "Save", 52.0).clicked() + && let Err(e) = save_preset(app, d) + { + app.status(e); + } + if widgets::flat_button(ui, "Cancel", 60.0).clicked() { + d.fields.remove(SAVE_AS); + } + }); + } +} + /// What the Printer list offers. enum Destination { Printer(String), @@ -233,7 +390,11 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv: ui.painter().rect_filled(rect, 3.0, t.pasteboard); let sheet = pv.as_ref().ok().and_then(|v| current_sheet(d, v)); let Some((index, count, sheet)) = sheet else { - let text = pv.as_ref().err().map_or("Nothing to print", |_| "Can't print these settings: see the Summary"); + let text = match pv { + _ if app.session.active().is_none() => "Open a document to preview its pages", + Ok(_) => "Nothing to print", + Err(_) => "Can't print these settings: see the Summary", + }; let font = egui::FontId::proportional(11.0); let galley = ui.painter().layout(text.into(), font, t.text_dim, rect.width() - 16.0); ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text_dim); @@ -568,7 +729,7 @@ fn advanced(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { /// The section an option is set in, and whether its first key is the section's own object /// (`output.mode` → Output › Mode). -fn section_of(option: &str) -> (usize, bool) { +pub(super) fn section_of(option: &str) -> (usize, bool) { match option.split('.').next().unwrap_or_default() { "marks" | "bleed" => (1, false), "output" => (2, true), @@ -580,7 +741,7 @@ fn section_of(option: &str) -> (usize, bool) { } /// `output.mode` → "Output › Mode", `placement.x` → "General › Placement › X". -fn option_label(option: &str) -> String { +pub(super) fn option_label(option: &str) -> String { let (section, own) = section_of(option); let keys = option.split('.').skip(usize::from(own)).map(|k| form::humanize(k).trim_end_matches(':').to_string()); std::iter::once(SECTIONS.get(section).copied().unwrap_or_default().to_string()).chain(keys).collect::>().join(" › ") @@ -615,19 +776,21 @@ pub(super) fn labelled(changed: &mut [Value]) { } } -fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { +fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result, has_doc: bool) { let t = Tokens::get(ui.ctx()); - let v = match pv { - Ok(v) => v, + match pv { + Ok(v) => { + heading(ui, "Pages"); + let per_copy = v["sheets"].as_array().map_or(0, Vec::len); + ui.label(egui::RichText::new(format!("{} in all, {per_copy} per copy", v["pages"])).color(t.text)); + } + // A preset edited without a document has no pages to count. + Err(_) if !has_doc => {} Err(e) => { heading(ui, "Error"); ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text)); - return; } - }; - heading(ui, "Pages"); - let per_copy = v["sheets"].as_array().map_or(0, Vec::len); - ui.label(egui::RichText::new(format!("{} in all, {per_copy} per copy", v["pages"])).color(t.text)); + } heading(ui, "Options"); let mut changed = vec![]; vectorcraft_engine::cmd::fileio::pdf::changed("", &settings(d), &DEFAULTS, &mut changed); @@ -637,6 +800,8 @@ fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { note(ui, "Every option is at its default."); } super::save_pdf::option_rows(ui, &changed, option_label); - heading(ui, "Warnings"); - super::save_pdf::warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default()); + if let Ok(v) = pv { + heading(ui, "Warnings"); + super::save_pdf::warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default()); + } } diff --git a/crates/ui-egui/src/dialogs/print_presets.rs b/crates/ui-egui/src/dialogs/print_presets.rs new file mode 100644 index 00000000..763a071a --- /dev/null +++ b/crates/ui-egui/src/dialogs/print_presets.rs @@ -0,0 +1,163 @@ +//! Edit → Print Presets: every preset in a list ([Default], protected, then the saved ones) with +//! how the selected one differs from [Default] beside it; New… (a preset starting from the +//! selected one), Edit… (saved presets), Delete, Import… and Export… (the selected preset) under +//! the list. New… and Edit… open the preset editor (the Print dialog, kind `printPreset`), whose OK +//! comes back here. +//! +//! Field: `selected` (a preset's name). + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::printpresets::{DEFAULT_PRESET, PRESET_EXTS, PRESET_FORMAT, changes}; + +use super::DialogSpec; +use super::print::{open_preset, option_label, section_of}; +use super::save_pdf::option_rows; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Print Presets. +pub const KIND: &str = "printPresets"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "Print Presets".into(), + body, + confirm: |app, _| { + app.ui.dialog = None; + Ok(Value::Null) + }, + ok: None, + min_width: 620.0, + ..DialogSpec::FORM +}; + +/// Open the presets manager on `selected` (default: [Default]). +pub fn open(app: &mut VectorcraftApp, selected: Option<&str>) { + app.ui.dialog = Some(Dialog::new(KIND, json!({ "selected": selected.unwrap_or(DEFAULT_PRESET) }))); +} + +/// What a button asked for, done once the list and the details are drawn. +pub(super) enum Action { + Select(String), + New, + Edit, + Delete, + Import, + Export, +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let saved = &app.session.prefs.print_presets; + let selected = d.str("selected"); + // 0 is [Default]; saved presets follow. + let i = saved.iter().position(|p| p.name.eq_ignore_ascii_case(selected.trim())).map_or(0, |i| i + 1); + let name = i.checked_sub(1).and_then(|k| saved.get(k)).map_or(DEFAULT_PRESET, |p| p.name.as_str()).to_string(); + let builtin = i == 0; + let mut act = None; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(280.0); + widgets::dim_label(ui, "Presets:"); + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().id_salt("print-presets").min_scrolled_height(240.0).max_height(240.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_height(240.0); + for (k, n) in std::iter::once(DEFAULT_PRESET).chain(saved.iter().map(|p| p.name.as_str())).enumerate() { + if ui.selectable_label(k == i, n).clicked() { + act = Some(Action::Select(n.to_string())); + } + } + }); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if widgets::flat_button(ui, "New…", 48.0).on_hover_text("A new preset starting from the selected one").clicked() { + act = Some(Action::New); + } + if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Edit…", 48.0)).inner.clicked() { + act = Some(Action::Edit); + } + if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() { + act = Some(Action::Delete); + } + if widgets::flat_button(ui, "Import…", 60.0).clicked() { + act = Some(Action::Import); + } + if widgets::flat_button(ui, "Export…", 60.0).on_hover_text("Save the selected preset to a file").clicked() { + act = Some(Action::Export); + } + }); + }); + ui.add_space(18.0); + ui.vertical(|ui| { + ui.set_width(300.0); + ui.label(egui::RichText::new(&name).color(t.text_strong).strong()); + ui.add_space(8.0); + widgets::dim_label(ui, &format!("Differences from {DEFAULT_PRESET}:")); + let settings = i.checked_sub(1).and_then(|k| saved.get(k)).map(|p| &p.settings); + let mut changed = settings.map(|s| changes(s, &Default::default())).unwrap_or_default(); + changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default()).0); + egui::ScrollArea::vertical().id_salt("print-preset-details").max_height(200.0).show(ui, |ui| { + if changed.is_empty() { + ui.label(egui::RichText::new("None.").color(t.text_dim).size(11.5)); + } + option_rows(ui, &changed, option_label); + }); + if builtin { + ui.add_space(6.0); + ui.label(egui::RichText::new("[Default] is protected: New… starts an editable copy.").color(t.text_dim).size(11.5)); + } + }); + }); + if let Some(a) = act + && let Err(e) = run(app, d, a, &name) + { + app.status(e); + } + false +} + +fn select(d: &mut Dialog, name: &str) { + d.fields.insert("selected".into(), json!(name)); +} + +pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> Result<(), String> { + match act { + Action::Select(name) => select(d, &name), + // The editor replaces this dialog; its OK comes back here. + Action::New => { + open_preset(app, &json!({ "preset": current }))?; + *d = app.ui.dialog.take().ok_or("no preset editor")?; + } + Action::Edit => { + open_preset(app, &json!({ "name": current }))?; + *d = app.ui.dialog.take().ok_or("no preset editor")?; + } + Action::Delete => { + let i = app.session.prefs.print_presets.iter().position(|p| p.name == current); + app.run("print.presets.delete", json!({ "name": current }))?; + // The one above it takes its place ([Default] above the first saved one). + let next = i.and_then(|i| i.checked_sub(1)).and_then(|i| app.session.prefs.print_presets.get(i)).map(|p| p.name.clone()); + select(d, next.as_deref().unwrap_or(DEFAULT_PRESET)); + } + Action::Import => { + // On the web the picked file arrives later and imports through `io::open_bytes`. + if let Some(f) = app.services.open_async.as_mut() { + f(); + return Ok(()); + } + let pick = crate::FilePick { filters: vec![("Print presets", PRESET_EXTS)], ..Default::default() }; + let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?; + let r = app.run("print.presets.import", json!({ "path": path }))?; + if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { + select(d, first); + } + } + Action::Export => { + crate::io::save_command_output(app, "print.presets.export", PRESET_FORMAT, json!({ "names": [current] }))?; + } + } + Ok(()) +} diff --git a/crates/ui-egui/src/dialogs/tests_print_presets.rs b/crates/ui-egui/src/dialogs/tests_print_presets.rs new file mode 100644 index 00000000..ae4848fb --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_print_presets.rs @@ -0,0 +1,145 @@ +//! Print presets in the UI: the Print dialog's preset list and Save Preset…, and Edit → Print +//! Presets (New…, Edit…, Delete, Import…, Export…) with its preset editor. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::printpresets::DEFAULT_PRESET; + +use super::print::{KIND, PRESET_KIND, SECTIONS}; +use super::print_presets::{self, Action}; +use crate::{VectorcraftApp, theme}; + +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +/// Press a manager button on the preset `current`, as the body does. +fn press(app: &mut VectorcraftApp, act: Action, current: &str) { + let mut d = app.ui.dialog.take().unwrap(); + print_presets::run(app, &mut d, act, current).unwrap(); + app.ui.dialog = Some(d); + frame(app); +} + +fn kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) +} + +fn field(app: &VectorcraftApp, k: &str) -> Value { + app.ui.dialog.as_ref().and_then(|d| d.fields.get(k).cloned()).unwrap_or_default() +} + +fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(k.into(), v); +} + +#[test] +fn the_print_dialog_saves_and_loads_presets() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("file.print", json!({})).unwrap(); + assert_eq!(field(&app, "preset"), DEFAULT_PRESET, "never set up: [Default]"); + // Save Preset… asks for a name; OK (and Enter) then saves the preset, not printing. + set(&mut app, "copies", json!(4)); + set(&mut app, "media", json!("a4")); + set(&mut app, "__savePresetAs", json!("Proofs")); + frame(&mut app); + let r = super::confirm(&mut app).unwrap(); + assert_eq!(r["name"], "Proofs"); + assert_eq!(kind(&app), Some(KIND), "the Print dialog stays open"); + assert_eq!(field(&app, "preset"), "Proofs"); + assert!(field(&app, "__savePresetAs").is_null()); + let saved = &app.session.prefs.print_presets[0].settings; + assert_eq!((saved.copies, saved.media), (4, vectorcraft_pdf::Media::A4)); + // Setting `preset` (the list, or an agent) loads that preset's settings. + set(&mut app, "preset", json!(DEFAULT_PRESET)); + frame(&mut app); + assert_eq!((field(&app, "copies"), field(&app, "media")), (json!(1), json!("letter"))); + set(&mut app, "preset", json!("proofs")); + set(&mut app, "discard", json!(true)); + super::confirm(&mut app).unwrap(); + let setup = app.session.active().unwrap().doc.print_setup.clone().unwrap(); + assert_eq!((setup["copies"].clone(), setup["media"].clone()), (json!(4), json!("a4")), "Done kept the preset's settings"); + // The next Print knows the preset its settings are. + app.run("file.print", json!({})).unwrap(); + assert_eq!(field(&app, "preset"), "Proofs"); + frame(&mut app); + // An unknown preset is refused; [Default]'s name can't be taken. + set(&mut app, "preset", json!("Nope")); + set(&mut app, "discard", json!(true)); + assert!(super::confirm(&mut app).is_err()); + assert!(field(&app, "discard").is_null(), "Done's press doesn't stick to the dialog"); + set(&mut app, "__savePresetAs", json!("[Default]")); + assert!(super::confirm(&mut app).is_err()); + assert_eq!(app.session.prefs.print_presets.len(), 1); +} + +#[test] +fn the_manager_adds_edits_and_deletes_presets_through_the_editor() { + // Edit → Print Presets works without a document too. + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + assert!(crate::menus::menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("ui.printPresetsDialog") && e.enabled)); + app.run("ui.printPresetsDialog", json!({})).unwrap(); + frame(&mut app); + assert_eq!((kind(&app), field(&app, "selected")), (Some(print_presets::KIND), json!(DEFAULT_PRESET))); + // [Default] can't be edited: New… starts a copy. + assert!(app.run("ui.printPresetDialog", json!({"name": DEFAULT_PRESET})).is_err()); + app.run("ui.printPresetDialog", json!({"preset": DEFAULT_PRESET})).unwrap(); + assert_eq!((kind(&app), field(&app, "name")), (Some(PRESET_KIND), json!("Print Preset 1"))); + for s in SECTIONS { + set(&mut app, "__section", json!(s)); + frame(&mut app); + assert_eq!(kind(&app), Some(PRESET_KIND), "{s}"); + } + set(&mut app, "name", json!("Tiled")); + set(&mut app, "scaling", json!("tileFull")); + super::confirm(&mut app).unwrap(); + assert_eq!((kind(&app), field(&app, "selected")), (Some(print_presets::KIND), json!("Tiled")), "back to the manager"); + frame(&mut app); + // A new preset can't take a name in use; Edit… renames. + app.run("ui.printPresetDialog", json!({"preset": "Tiled"})).unwrap(); + assert_eq!(field(&app, "scaling"), "tileFull", "New… starts from the selected preset"); + set(&mut app, "name", json!("tiled")); + assert!(super::confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some(PRESET_KIND), "stays open on a taken name"); + app.run("ui.printPresetDialog", json!({"name": "Tiled"})).unwrap(); + set(&mut app, "name", json!("Posters")); + set(&mut app, "overlap", json!(9)); + super::confirm(&mut app).unwrap(); + let p = &app.session.prefs.print_presets; + assert_eq!((p.len(), p[0].name.as_str(), p[0].settings.overlap), (1, "Posters", 9.0)); + // Delete selects the one above ([Default]). + press(&mut app, Action::Delete, "Posters"); + assert!(app.session.prefs.print_presets.is_empty()); + assert_eq!(field(&app, "selected"), DEFAULT_PRESET); + super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "Close"); +} + +#[test] +fn presets_files_import_and_export() { + let dir = std::env::temp_dir().join(format!("vc-printpresets-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("mine.vcprintpresets").to_string_lossy().to_string(); + let picked = file.clone(); + let services = crate::Services { + pick_save: Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("print.presets.save", json!({"name": "Mine", "settings": {"copies": 2}})).unwrap(); + app.run("ui.printPresetsDialog", json!({"selected": "Mine"})).unwrap(); + press(&mut app, Action::Export, "Mine"); + // Opening the file imports it (also how the web's picked file arrives). + let bytes = std::fs::read(&file).unwrap(); + crate::io::open_bytes(&mut app, &file, &bytes, Some(file.clone())).unwrap(); + let names: Vec<&str> = app.session.prefs.print_presets.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Mine", "Mine 2"]); + assert_eq!(app.session.prefs.print_presets[1].settings.copies, 2); + assert!(app.ui.status.contains("print presets"), "{}", app.ui.status); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index eab8fd8b..73c5da31 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -15,13 +15,14 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs}; const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"]; /// Open bytes of any readable format as a new document (templates open untitled); swatch and -/// graphic style library files open in the library panel and flattener and PDF presets files are -/// imported. +/// graphic style library files open in the library panel and flattener, PDF and print presets +/// files are imported. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { let ext = fileio::extension(name); let presets = [ (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), + (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), ]; if let Some((_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) { let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2ec05854..7d4f958f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -507,7 +507,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.print", "Print…", "Cmd+P", - "{} open the Print dialog (dialog `print`: the print.setup settings, printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}", + "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}", ), ( "print.printers", @@ -516,6 +516,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}", ), ("print.printerSetup", "Printer Setup…", "", "{printer?} open the system's settings of the printer (the Print dialog's Setup…; desktop)"), + ( + "ui.printPresetsDialog", + "Print Presets…", + "", + "{selected?: preset name} open Edit → Print Presets (dialog `printPresets`, field `selected`): [Default] (protected) and the saved presets, with how the selected one differs from [Default]. New… and Edit… open the preset editor (ui.printPresetDialog); Delete, Import… and Export… run print.presets.delete / import / export", + ), + ( + "ui.printPresetDialog", + "Print Preset", + "", + "{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -922,6 +934,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("printing isn't available here".into()), } } + "ui.printPresetsDialog" => { + crate::dialogs::print_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.printPresetDialog" => crate::dialogs::print::open_preset(app, p), _ => return None, }; Some(r) @@ -1293,7 +1310,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Edit Original", "links.editOriginal"), Sep, c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"), - todo("Print Presets…"), + c("Print Presets…", "ui.printPresetsDialog"), c("PDF Presets…", "ui.pdfPresetsDialog"), cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})), Sep, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c0569bce..8ccbb7d0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -405,3 +405,14 @@ PDF. With `discard: true` it is Done: it only keeps the settings. Settings that range of no tiles) answer with an error and keep the dialog open. `file.print {settings?, printer?, toFile?, path?}` prints without the dialog; `print.printers` lists the printers and `print.printerSetup {printer?}` opens their system settings (Setup…). + +Print presets: in the `print` dialog, `preset` names the print preset whose settings were loaded last; setting it +(`ui.dialog.set {field: "preset", value: "Posters"}`) loads that preset's settings, and the list reads `[Custom]` once +they differ. Save Preset… shows a name field (`__savePresetAs`): while it shows, `ui.dialog.confirm` saves the dialog's +settings as that preset (`print.presets.save`) and selects it instead of printing. `ui.printPresetsDialog {selected?}` +opens Edit → Print Presets (dialog `printPresets`, field `selected`); `[Default]` is protected. New… and Edit… open the +preset editor, which `ui.printPresetDialog {name? (a saved preset to edit) | preset? (the preset a new one starts +from)}` opens too: dialog `printPreset`, the Print dialog's settings fields plus `name`; `ui.dialog.confirm` runs +`print.presets.save` and returns to Print Presets (a new preset can't take a name in use). Delete, Import… and +Export… are `print.presets.delete`, `print.presets.import` and `print.presets.export`; `ui.dialog.confirm` closes. +Opening a `.vcprintpresets` file with `app.open` imports its presets. diff --git a/docs/mcp.md b/docs/mcp.md index 7bf0a233..dbb65b3f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1506,3 +1506,18 @@ default printer) or, with `toFile`, a `path` or no printing available, saves the {"name":"run_command","arguments":{"command":"print.preview","params":{"settings":{"copies":2,"output":{"mode":"separations"}}}}} {"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.pdf"}}} ``` + +## Print presets + +`print.presets.list` lists `[Default]` (the default print settings, `builtIn: true`, protected) and the saved presets, +each with its `settings` and how they differ from `[Default]` (`changed`). `print.presets.save {name?, newName?, +preset?, settings?}` creates or changes a saved preset (starting from its own settings, or from `preset`, with +`settings` over them) and `print.presets.delete {name}` deletes one; `[Default]` can be neither changed nor deleted. +`print.presets.export {names?, path?}` writes them as a `.vcprintpresets` JSON file (without `path` it returns `data`), +and `print.presets.import {path? | data? | dataBase64?, replace?}` adds a file's presets (a name in use gets a number +unless `replace`). Saved presets live with the preferences. To print with a preset, pass its `settings` to +`print.setup`, `print.preview` or `file.print`. + +```json +{"name":"run_command","arguments":{"command":"print.presets.save","params":{"name":"Posters","settings":{"scaling":"tileImageable","overlap":18,"marks":{"trim":true}}}}} +```