diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index d512d164..7141347c 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -119,7 +119,13 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) .collect() })), - pick_save: Some(Box::new(|pick: &FilePick| file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string()))), + pick_save: Some(Box::new(|pick: &FilePick| { + // The Templates folder may not exist yet. + if let Some(folder) = &pick.folder { + let _ = std::fs::create_dir_all(folder); + } + file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string()) + })), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ca9ed054..f4947642 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -151,11 +151,22 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { let opts = LoadOptions::from_params("document.open", p)?; - let Loaded { mut doc, format, warnings, restored } = load_with(name, bytes, &opts)?; + open_loaded(s, load_with(name, bytes, &opts)?, path, &opts, false) +} + +/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for +/// from there) as a new untitled document. +pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&str>) -> Result { + open_loaded(s, load(name, bytes)?, path.map(str::to_string), &LoadOptions::default(), true) +} + +/// Make a loaded file the new active document; `opts` are the options it was read with. +fn open_loaded(s: &mut Session, loaded: Loaded, path: Option, opts: &LoadOptions, as_template: bool) -> Result { + let Loaded { mut doc, format, warnings, restored } = loaded; let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically"); // A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled // document. - let template = doc.template || matches!(format.id, "ait" | "template"); + let template = as_template || doc.template || matches!(format.id, "ait" | "template"); if template { doc.template = false; doc.title = s.next_untitled(); @@ -200,6 +211,11 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result { open_bytes_with(s, src.name, &src.bytes, src.path.map(str::to_string), p) } +pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result { + let src = source(p, "file.newFromTemplate")?; + open_template(s, src.name, &src.bytes, src.path) +} + /// Decode an image's header (and, for formats stored as PNG, its pixels). pub fn raster_image(bytes: &[u8]) -> Result { let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 8f14dbe9..37d05433 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -5,8 +5,8 @@ //! - `encode`: one encoder per writable format, with typed options parsed from the params. //! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). //! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`. -//! - `save`: `document.save`, Save As / a Copy / as Template, Revert and the per-format options -//! (`file.formatOptions`); [`save_with`] is the one save path of every frontend. +//! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the +//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. //! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`. //! @@ -30,9 +30,9 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, enc pub(crate) use encode::{anti_alias, background, with_single_artboard}; use load::err; pub(crate) use load::source; -pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image}; +pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image}; pub use pdfimport::{LoadOptions, page_document}; -pub use save::{SAVE_FORMATS, SaveMode, SavePlan, save_filters, save_format, save_plan, save_with, stamp_save_dates}; +pub use save::{SAVE_FORMATS, SaveMode, SavePlan, save_filters, save_format, save_plan, save_with, stamp_save_dates, templates_folder}; pub use svg::options_map as svg_options; use super::*; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 60183051..57a9519f 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1,5 +1,5 @@ -//! Saving: `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `file.revert` -//! and `file.formatOptions`. +//! Saving: `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, +//! `file.newFromTemplate`, `file.revert` and `file.formatOptions`. //! //! Every frontend saves the same way: [`save_plan`] resolves where and how (path, format, options, //! a suggested name and folder when there is no path), then [`save_with`] encodes, writes through @@ -11,7 +11,7 @@ use vectorcraft_doc::Document; use super::super::*; use super::{Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, write_encoded, write_file}; -use crate::DocState; +use crate::{DocState, Prefs}; /// The formats Save As offers, in menu order (append-only). The other writable formats are exports: /// they never become the document's own file. @@ -68,9 +68,9 @@ pub struct SavePlan { pub format: &'static Format, /// The format's options (only those its encoder reads). pub options: Map, - /// Suggested file name: `.` or ` copy.`. + /// Suggested file name: `.`, ` copy.` or ` template.vctemplate`. pub name: String, - /// Suggested folder: the document's own. + /// Suggested folder: the document's own, or the Templates folder for a template. pub folder: Option, } @@ -91,6 +91,16 @@ pub fn stamp_save_dates(st: &mut DocState) { d.metadata.modified = Some(now); } +/// The Templates folder: the `templatesFolder` preference, else `Documents/VectorCraft Templates` +/// in the user's home (none where there is no home folder, as on the web). +pub fn templates_folder(prefs: &Prefs) -> Option { + if !prefs.templates_folder.is_empty() { + return Some(prefs.templates_folder.clone()); + } + let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok().filter(|h| !h.is_empty())?; + Some(std::path::Path::new(&home).join("Documents").join("VectorCraft Templates").to_string_lossy().to_string()) +} + /// The folder of a path (`None` for a bare file name). fn parent_folder(path: &str) -> Option { std::path::Path::new(path).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()) @@ -154,9 +164,13 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { let ext = format.extensions[0]; let name = match mode { SaveMode::Copy => format!("{stem} copy.{ext}"), - SaveMode::Save | SaveMode::SaveAs | SaveMode::Template => format!("{stem}.{ext}"), + SaveMode::Template => format!("{stem} template.{ext}"), + SaveMode::Save | SaveMode::SaveAs => format!("{stem}.{ext}"), + }; + let folder = match mode { + SaveMode::Template => templates_folder(&s.prefs), + _ => st.path.as_deref().and_then(parent_folder), }; - let folder = st.path.as_deref().and_then(parent_folder); Ok(SavePlan { mode, path, format, options, name, folder }) } @@ -338,10 +352,19 @@ pub(super) fn specs() -> Vec { "Save as Template…", ["File"], None, - "{path?} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \".vctemplate\", folder?, warnings}", + "{path?} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \" template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}", has_doc, |s, p| save(s, SaveMode::Template, p) ), + cmd!( + "file.newFromTemplate", + "New from Template…", + ["File"], + Some("Cmd+Shift+N"), + "{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document → {index, title, format, warnings}", + always, + super::load::new_from_template + ), cmd!( "file.revert", "Revert", diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 9ee95dde..aa5242b0 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -315,6 +315,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("japaneseCropMarks", "General", "Options", "Use Japanese Crop Marks", bool), // Appearance panel menu p!("newArtBasic", "General", "Appearance Panel", "New Art Has Basic Appearance", bool), + // File Handling (continued) + p!("templatesFolder", "File Handling", "Files", "Templates Folder", text), ]; pub fn spec(key: &str) -> Option<&'static PrefSpec> { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 90071e35..50599b78 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -372,6 +372,9 @@ pub struct Prefs { /// not a Preferences dialog field: resetting the preferences keeps it; `pdf.preset.*` edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub pdf_presets: Vec, + // File Handling (continued) + /// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`). + pub templates_folder: String, } impl Default for Prefs { @@ -498,6 +501,7 @@ impl Default for Prefs { new_doc_presets: vec![], recent_new_docs: vec![], pdf_presets: vec![], + templates_folder: String::new(), } } } diff --git a/crates/engine/src/tests_save.rs b/crates/engine/src/tests_save.rs index 7bb8e688..af9445e7 100644 --- a/crates/engine/src/tests_save.rs +++ b/crates/engine/src/tests_save.rs @@ -1,5 +1,5 @@ -//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template and -//! Revert. +//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template, Revert +//! and templates. use std::path::PathBuf; @@ -161,6 +161,40 @@ fn revert_reloads_the_saved_file_in_the_same_tab() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn templates_suggest_a_name_in_the_templates_folder_and_open_untitled() { + let d = dir("tpl"); + let mut s = session(); + let source = path(&d, "flyer.vectorcraft"); + s.execute("document.save", &json!({"path": source})).unwrap(); + s.prefs.templates_folder = path(&d, "Templates"); + let r = s.execute("file.saveAsTemplate", &json!({})).unwrap(); + assert_eq!(r["name"], "flyer template.vctemplate"); + assert_eq!(r["folder"].as_str(), Some(s.prefs.templates_folder.as_str())); + assert_ne!(r["name"].as_str(), std::path::Path::new(&source).file_name().and_then(|n| n.to_str()), "never the source file"); + // Writing it leaves the document alone; opening it gives an untitled document. + rect(&mut s); + let tpl = path(&d, "flyer template.vctemplate"); + s.execute("file.saveAsTemplate", &json!({"path": tpl})).unwrap(); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(source.as_str())); + assert!(s.doc().unwrap().is_dirty()); + let r = s.execute("document.open", &json!({"path": tpl})).unwrap(); + assert_eq!(r["format"], "template"); + assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}"); + assert_eq!(s.doc().unwrap().path, None); + assert!(!s.doc().unwrap().doc.template); + // New from Template opens any file untitled. + let r = s.execute("file.newFromTemplate", &json!({"path": source})).unwrap(); + assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}"); + assert_eq!(s.doc().unwrap().path, None); + // With no preference the folder is under the user's home (where there is one). + s.prefs.templates_folder.clear(); + if let Some(f) = crate::cmd::fileio::templates_folder(&s.prefs) { + assert!(f.ends_with("VectorCraft Templates"), "{f}"); + } + let _ = std::fs::remove_dir_all(d); +} + #[test] fn save_plans_suggest_names_and_folders() { let mut s = session(); diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 5307ed73..e8f4b053 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -269,6 +269,7 @@ mod tests { #[test] fn save_a_copy_and_template_suggest_their_names() { let (mut app, written, picks) = desktop("copy.vectorcraft"); + app.session.prefs.templates_folder = "/templates".into(); app.run("file.saveCopy", json!({})).unwrap(); assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft"); assert!(app.ui.dialog.is_none(), "the native format has no options"); @@ -276,7 +277,7 @@ mod tests { assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty()); app.run("file.saveAsTemplate", json!({})).unwrap(); let pick = &picks.borrow()[1]; - assert_eq!((pick.name.as_str(), pick.folder.as_deref()), ("Untitled-1.vctemplate", None)); + assert_eq!((pick.name.as_str(), pick.folder.as_deref()), ("Untitled-1 template.vctemplate", Some("/templates"))); assert_eq!(pick.filters, [("VectorCraft Template", &["vctemplate"][..])]); assert_eq!(written.borrow()[1].0, "copy.vectorcraft", "the picked name is kept"); } diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index bf000a53..e0c032b2 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -9,6 +9,9 @@ use crate::dialogs::svg_options; use crate::state::Dialog; use crate::{FilePick, Services, VectorcraftApp, dialogs}; +/// Template extensions New from Template's open dialog lists first. +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. @@ -77,6 +80,25 @@ pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { Ok(()) } +/// File → New from Template…: a template (or any readable file) as a new untitled document. Without +/// a path the open dialog starts in the Templates folder. On the web the browser's file picker +/// opens it (`.vctemplate`, `.ait` and template files open untitled there too). +pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Result { + let path = match path { + Some(p) => p, + None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), + None => { + let filters = std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters()).collect(); + pick_open(app, &FilePick { folder: fileio::templates_folder(&app.session.prefs), filters, ..Default::default() })? + } + }; + let bytes = read(app, &path)?; + let r = fileio::open_template(&mut app.session, &path, &bytes, Some(&path)).map_err(|e| e.to_string())?; + app.sync_views(); + crate::dialogs::missing_links::after_open(app, &r); + Ok(r) +} + /// Web: download `bytes` under `path`'s file name; desktop: write them to `path`. fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> { if let Some(dl) = services.download.as_mut() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0d72f78a..b0069464 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -56,7 +56,6 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+S", "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved) the Save As panel asks first", ), - ("file.newFromTemplate", "New from Template…", "Cmd+Shift+N", "{path?} open a template as a new untitled document"), ( "file.place", "Place…", @@ -438,10 +437,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) } - "file.newFromTemplate" => match s("path") { - Some(path) => io::open_path(app, &path).map(|_| Value::Null), - None => io::open_dialog(app).map(|_| Value::Null), - }, + // Bytes sent by an agent go straight to the engine; a path or nothing opens it here. + "file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")), "file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app), id if id.starts_with("file.openRecent") => { let n: usize = id["file.openRecent".len()..].parse().unwrap_or(0); diff --git a/crates/ui-egui/src/tests_save.rs b/crates/ui-egui/src/tests_save.rs index dc92aa3a..976df367 100644 --- a/crates/ui-egui/src/tests_save.rs +++ b/crates/ui-egui/src/tests_save.rs @@ -74,3 +74,14 @@ fn revert_asks_then_reloads_in_the_same_tab_keeping_every_zoom() { assert!(app.run("file.revert", json!({})).is_err() && app.ui.dialog.is_none()); let _ = std::fs::remove_dir_all(d); } + +#[test] +fn new_from_template_takes_bytes_from_agents() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + let bytes = app.session.execute("document.serialize", &json!({"format": "template"})).unwrap(); + let r = app.run("file.newFromTemplate", json!({"name": "flyer.vctemplate", "dataBase64": bytes["dataBase64"]})).unwrap(); + assert!(r["title"].as_str().is_some_and(|t| t.starts_with("Untitled-")), "{r}"); + assert_eq!((app.session.documents().len(), app.views.len()), (2, 2)); + assert_eq!(app.session.active().unwrap().path, None); +} diff --git a/docs/mcp.md b/docs/mcp.md index 34bfd898..bdfcd87a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -141,7 +141,8 @@ markup around it: if another app changed the SVG since, `document.open` reads it Saving beyond `save_file` goes through `run_command`: `file.saveAs {path?, format?, options?, svg?}` (the document takes on the new file and format), `file.saveCopy` (the document keeps its path, title and modified state), -`file.saveAsTemplate` (a `.vctemplate` that opens untitled), `file.revert` and `file.formatOptions {format?}` (a format's options +`file.saveAsTemplate` (a `.vctemplate` that opens untitled; the suggested name and the Templates folder come back +without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOptions {format?}` (a format's options with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF remembers its options, so the next save reuses them. A PDF opened with `pages`, `cropTo` or `password` is only part of the file, so Save asks for a new name instead of writing it back.