From 515ce2ac94782b45ac1ee1edf96cee94a23d049a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:18:06 +0700 Subject: [PATCH 1/4] M4.14: Save As format chooser, Save writes back its own format, Save a Copy Save As offers VectorCraft, VectorCraft Template, PDF, SVG and SVGZ and asks for the chosen format's options before writing: SVG Options for SVG/SVGZ, the Save PDF dialog for PDF (its OK finishes the save); on the web a Save As dialog first names the file and picks the format. A document opened from or saved as SVG/PDF remembers its format and options in one place (DocState.format, save_options: the SVG options, or every Save PDF setting), so Save writes it again; the result's warnings name what the format loses, then the encoder's notes. Save a Copy writes " copy." and leaves the path, title and modified state alone. Save is off for a clean saved document, and Save as PDF... is in the File menu. Engine: document.save, file.saveAs and file.saveCopy take {path?, format?, options?, svg?} (SVG options also flat) and write linked images beside an SVG; file.formatOptions reads a format's options. save_plan/save_with are the one save path of every frontend, on the shared SVG encoder (one SVGZ path). The template format is writable. Save panels get per-format filters, a folder and a suggested name. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/main.rs | 28 +- crates/engine/src/cmd/fileio/encode.rs | 6 + crates/engine/src/cmd/fileio/export.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 35 +- crates/engine/src/cmd/fileio/mod.rs | 49 +-- crates/engine/src/cmd/fileio/pdf.rs | 6 + crates/engine/src/cmd/fileio/pdfimport.rs | 6 + crates/engine/src/cmd/fileio/save.rs | 392 ++++++++++++++---- crates/engine/src/cmd/fileio/tests.rs | 2 +- crates/engine/src/cmd/fileio/tests_pdfedit.rs | 5 +- crates/engine/src/cmd/fileio/tests_svg.rs | 2 +- crates/engine/src/cmd/place/mod.rs | 5 +- crates/engine/src/lib.rs | 14 +- crates/engine/src/tests_save.rs | 169 ++++++++ crates/mcp/src/headless.rs | 8 +- crates/mcp/src/tests_fileio.rs | 2 +- .../ui-egui/src/dialogs/flattener_presets.rs | 7 +- crates/ui-egui/src/dialogs/form.rs | 7 +- crates/ui-egui/src/dialogs/missing_links.rs | 5 +- crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/pdf_presets.rs | 5 +- crates/ui-egui/src/dialogs/save_options.rs | 334 +++++++++++++++ crates/ui-egui/src/dialogs/save_pdf.rs | 16 +- crates/ui-egui/src/dialogs/svg_options.rs | 9 +- crates/ui-egui/src/dialogs/tests_export.rs | 6 +- crates/ui-egui/src/io.rs | 273 ++++++++---- crates/ui-egui/src/lib.rs | 24 +- crates/ui-egui/src/menus.rs | 35 +- crates/ui-egui/src/panels/library_panel.rs | 9 +- crates/ui-egui/src/panels/separations.rs | 6 +- .../src/panels/tests_style_libraries.rs | 2 +- crates/ui-egui/src/place.rs | 2 +- crates/ui-egui/src/shortcut_editor.rs | 4 +- crates/ui-egui/src/tests_place.rs | 2 +- crates/ui-egui/src/tests_svg.rs | 2 +- crates/ui-egui/src/unsaved.rs | 6 +- docs/control-protocol.md | 1 + docs/mcp.md | 9 +- 38 files changed, 1202 insertions(+), 295 deletions(-) create mode 100644 crates/engine/src/tests_save.rs create mode 100644 crates/ui-egui/src/dialogs/save_options.rs diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 928885f2..d512d164 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -13,7 +13,7 @@ mod native_menu; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; -use vectorcraft_ui_egui::{Services, VectorcraftApp}; +use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp}; struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option); @@ -97,14 +97,19 @@ fn save_prefs(app: &VectorcraftApp) { } } +/// A native file dialog showing `pick`'s file types, folder and suggested name. +fn file_dialog(pick: &FilePick) -> rfd::FileDialog { + let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts)); + let d = match &pick.folder { + Some(folder) => d.set_directory(folder), + None => d, + }; + if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) } +} + fn services() -> Services { Services { - pick_open: Some(Box::new(|| { - fileio::open_filters() - .fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(name, exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })), + pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))), pick_open_multi: Some(Box::new(|| { fileio::place_filters() .fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)) @@ -114,14 +119,7 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) .collect() })), - // Saving a document offers every format Save writes (.vectorcraft, .ai, .svg, .svgz). - pick_save: Some(Box::new(|name: &str| { - fileio::save_filters(name) - .into_iter() - .fold(rfd::FileDialog::new().set_file_name(name), |d, (label, exts)| d.add_filter(label, exts)) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })), + pick_save: Some(Box::new(|pick: &FilePick| 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/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 98645c9f..74b32375 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -248,6 +248,12 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { } let bytes = match f.id { "vectorcraft" => vectorcraft_format::save_file(doc), + // A native file flagged so opening it starts a new untitled document. + "template" => { + let mut d = doc.clone(); + d.template = true; + vectorcraft_format::save_file(&d) + } "svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)), "pdf" => { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index dae09701..e16e9771 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -100,7 +100,7 @@ fn screen_format(row: &Value) -> Result { return Err(bad(C, "each format is an object {format, scale?, suffix?}")); } let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; - if format.id == "vectorcraft" { + if matches!(format.id, "vectorcraft" | "template") { return Err(bad(C, "Export for Screens writes png, jpg, webp, svg, svgz or pdf")); } // Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 612aacf3..ca9ed054 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -8,7 +8,7 @@ use vectorcraft_geom::Affine; use super::super::*; use super::pdfimport::LoadOptions; -use super::{Format, format, format_for_name, read_file}; +use super::{Format, SAVE_FORMATS, format, format_for_name, read_file}; use crate::EngineError; /// A file read into a document, with its format and non-fatal import notes. @@ -46,7 +46,8 @@ pub fn file_name(name: &str) -> String { pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { let by_name = format_for_name(name).filter(|f| f.read); if vectorcraft_format::sniff(bytes) { - return format("vectorcraft"); + // A template keeps its meaning from the extension, like .ait. + return by_name.filter(|f| f.id == "template").or_else(|| format("vectorcraft")); } if vectorcraft_svg::is_svgz(bytes) { return format("svgz"); @@ -113,7 +114,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?; let title = file_name(name); let (mut doc, warnings, restored) = match format.id { - "vectorcraft" => (vectorcraft_format::load(bytes).map_err(err)?, vec![], false), + "vectorcraft" | "template" => (vectorcraft_format::load(bytes).map_err(err)?, vec![], false), "svg" | "svgz" => { let text = vectorcraft_svg::text_of(bytes).map_err(err)?; let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); @@ -132,17 +133,17 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } // Imports are named after the file; a native document keeps its own title (the tab shows the // file name once it has a path). - if format.id != "vectorcraft" || doc.title.is_empty() { + if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() { doc.title = title; } Ok(Loaded { doc, format, warnings, restored }) } /// Open a file's bytes as the new active document (what `document.open` does) → -/// `{index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks}`. `path` -/// is kept for Save only for a non-template native file or a `.ai` file whose native document came -/// back (Save writes both again); the document's linked files are looked for from it -/// ([`crate::cmd::links::resolve`]). +/// `{index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks}`. The +/// document keeps `path` (and its format, for Save) when Save can write that format back: not a +/// template, a partly read PDF, or a `.ai` file whose native document didn't come back. The +/// document's linked files are looked for from `path` ([`crate::cmd::links::resolve`]). pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option) -> Result { open_bytes_with(s, name, bytes, path, &Value::Null) } @@ -152,14 +153,24 @@ pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option Vec { always, load::open ), - cmd!( - "document.save", - "Save Document", - [], - None, - "{path?, format?: vectorcraft|ai|svg|svgz (default: from the path's extension, else vectorcraft), svg?: {…SVG options, see document.formats}, …document.exportPdf options (.ai)} (default path: the document's) → {path, linked?, warnings?}; the document takes the path. An SVG save uses the given SVG options, else the ones this document was last saved with, and keeps hidden layers (display:none) unless hiddenLayers is false. A .ai save is a PDF-compatible file: every artboard as a PDF page with the native document embedded (preserveEditing is always on), so document.open restores it exactly. A never-saved document without path → {dataBase64} (stays modified)", - has_doc, - save::save - ), cmd!( query "document.serialize", "Serialize Document", [], None, - "{format?: vectorcraft (default)|svg|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", has_doc, export::serialize ), @@ -71,7 +63,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft|template (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -84,15 +76,6 @@ pub fn specs() -> Vec { has_selection, export::export_selection ), - cmd!( - "file.saveAsTemplate", - "Save as Template…", - ["File"], - None, - "{path?} a native copy that opens as a new untitled document (no path → {dataBase64})", - has_doc, - save::save_template - ), cmd!( "document.exportForScreens", "Export for Screens", @@ -122,6 +105,9 @@ pub fn specs() -> Vec { pdfimport::pdf_info ), ] + .into_iter() + .chain(save::specs()) + .collect() } /// One option a format's encoder reads from the export params. @@ -287,18 +273,28 @@ pub const FORMATS: &[Format] = &[ }, reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true), reader("bmp", "BMP", &["bmp"], "image/bmp", true), + Format { + id: "template", + label: "VectorCraft Template", + extensions: &["vctemplate"], + mime: "application/json", + read: true, + write: true, + raster: false, + options: &[], + }, ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). pub const OPEN_EXTS: &[&str] = - &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"]; + &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate"]; /// Text files: File → Place sets them as area type (Text Import Options). pub const TEXT_EXTS: &[&str] = &["txt"]; /// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`]. pub const PLACE_EXTS: &[&str] = - &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "txt"]; + &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate", "txt"]; /// One dialog filter per readable format. fn format_filters() -> impl Iterator { @@ -331,6 +327,11 @@ pub fn extension(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() } +/// A file name or path without its folder and extension (`/a/Poster.svg` → `Poster`). +pub fn file_stem(name: &str) -> String { + std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()) +} + /// The format a file name's extension names. pub fn format_for_name(name: &str) -> Option<&'static Format> { Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e)) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index a13779b0..ea8f6a5a 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -241,3 +241,9 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result { let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?; Ok(json!({ "settings": v, "presets": presets(s), "changed": diff, "warnings": warnings })) } + +/// Is `key` a top-level PDF setting (a [`PdfSettings`] field as `document.exportPdf` names it)? +pub fn is_setting(key: &str) -> bool { + static DEFAULTS: std::sync::LazyLock = std::sync::LazyLock::new(|| serde_json::to_value(PdfSettings::default()).unwrap_or_default()); + DEFAULTS.get(key).is_some() +} diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 3023bd87..5c600ef9 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -54,6 +54,12 @@ impl LoadOptions { Ok(Self { pages, crop, password, color_mode }) } + /// Does the document read only part of the file, or read it differently (a page range, another + /// box, a password)? Writing it back would lose the rest, so Save doesn't (it asks for a name). + pub fn is_partial(&self) -> bool { + self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() + } + /// The PDF import options: `pages` resolved against the file's page count. fn import_options(&self, bytes: &[u8]) -> Result { let pages = match &self.pages { diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 6b6d3c70..d3288501 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1,68 +1,85 @@ -//! Saves: `document.save` (native, SVG or PDF-compatible .ai) and `file.saveAsTemplate`. +//! Saving: `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate` 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 +//! the caller's writer (the file system here; a save panel or a browser download in the apps) and +//! makes the file the document's own (path, format, options, saved state) for Save and Save As. -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; +use vectorcraft_doc::Document; use super::super::*; -use super::{Encoded, Format, default_name, encode_all, format_for_name, writable_format, write_encoded, write_or_return}; +use super::{Encoded, Format, encode_all, file_stem, format, format_for_name, write_encoded, write_file}; use crate::DocState; -/// The formats Save writes, in the order save dialogs offer them; other formats are exports. -pub const SAVE_FORMATS: [&str; 4] = ["vectorcraft", "ai", "svg", "svgz"]; +/// The formats Save As offers, in menu order (append-only). The other writable formats are exports: +/// they never become the document's own file. +pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]; -/// The format Save writes: `format` (an id or extension), else the format the path's extension -/// names, else native (see [`SAVE_FORMATS`]). A `.ai` file is a PDF carrying the native document. -pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> { - let f = match (format, path.and_then(format_for_name)) { - (Some(id), _) => super::format(id).filter(|f| SAVE_FORMATS.contains(&f.id)).map_or_else(|| writable_format(format, None), Ok)?, - (None, Some(f)) => f, - (None, None) => super::format("vectorcraft").ok_or("no native format")?, - }; - if SAVE_FORMATS.contains(&f.id) { - Ok(f) - } else { - Err(format!("Save writes .{}, .ai, .svg or .svgz files: write {} with document.export", vectorcraft_format::EXTENSION, f.label)) - } +/// Save-panel filters `(label, [extension])` for `first` (a format id, or a file name whose +/// extension names one): when Save writes that format, one per [`SAVE_FORMATS`] entry with it +/// leading (the panel's default type), else none (an export picks its own). Only the extension a +/// save writes (never the former native name). +pub fn save_filters(first: &str) -> Vec<(&'static str, &'static [&'static str])> { + let Some(first) = format_for_name(first).or_else(|| format(first)).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] }; + let mut v: Vec<&'static Format> = SAVE_FORMATS.iter().filter_map(|id| format(id)).collect(); + v.sort_by_key(|f| f.id != first.id); + v.into_iter().map(|f| (f.label, &f.extensions[..1])).collect() } -/// Save dialog filters for a file named `name`: when Save writes its format, every format Save -/// writes (that one first), else none. -pub fn save_filters(name: &str) -> Vec<(&'static str, &'static [&'static str])> { - let Some(first) = format_for_name(name).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] }; - let others = SAVE_FORMATS.iter().filter_map(|id| super::format(id)).filter(|f| f.id != first.id); - std::iter::once(first).chain(others).map(|f| (f.label, f.extensions)).collect() +/// How a save treats the document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SaveMode { + /// File → Save: the document's own path and format. + Save, + /// File → Save As: a new path or format, which the document takes on. + SaveAs, + /// File → Save a Copy: the document keeps its path, title and modified state. + Copy, + /// File → Save as Template: a template copy; the document keeps its path and state too. + Template, } -/// What Save writes for `st` in format `f` (see [`save_format`]) to `path` → the encoded file and -/// the options to remember for the next Save. An SVG save takes the SVG options in `p`, else the -/// ones the document was last saved with, and keeps hidden layers (hidden) unless they say -/// otherwise. A native file (and the document a `.ai` file carries) records its links' paths -/// relative to `path`. -pub fn save_encoding(st: &DocState, f: &Format, p: &Value, path: Option<&str>) -> Result<(Encoded, Value)> { - const C: &str = "document.save"; - let relative = path.filter(|_| matches!(f.id, "vectorcraft" | "ai")).and_then(|path| crate::cmd::links::with_relative_paths(&st.doc, path)); - let doc = relative.as_ref().unwrap_or(&st.doc); - match f.id { - "vectorcraft" => return Ok((encode_all(doc, f.id, p)?, Value::Null)), - // A PDF of every artboard with the PDF options in `p`, always carrying the native document. - "ai" => { - let mut q = if p.is_object() { p.clone() } else { json!({}) }; - if let Some(o) = q.as_object_mut() { - o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]); - } - let (bytes, warnings) = super::pdf::encode(C, &doc.without_edit_modes(), &q)?; - return Ok((Encoded { warnings, ..Encoded::one(bytes) }, Value::Null)); +impl SaveMode { + /// The engine command of this mode. + pub fn command(self) -> &'static str { + match self { + Self::Save => "document.save", + Self::SaveAs => "file.saveAs", + Self::Copy => "file.saveCopy", + Self::Template => "file.saveAsTemplate", } - _ => {} } - let given = super::svg_options(p).map_err(|e| bad(C, e))?; - let opts = if given.is_empty() { st.save_options.clone() } else { Value::Object(given) }; - let mut svg = opts.as_object().cloned().unwrap_or_default(); - svg.entry("hiddenLayers").or_insert(Value::Bool(true)); - let enc = encode_all(&st.doc, f.id, &json!({ "svg": svg }))?; - if enc.files.len() != 1 { - return Err(bad(C, "Save writes one artboard: name one, or export several with document.export")); + + /// The mode an engine command id runs. + pub fn of(command: &str) -> Option { + [Self::Save, Self::SaveAs, Self::Copy, Self::Template].into_iter().find(|m| m.command() == command) + } +} + +/// A save resolved against the active document, before anything is encoded or written. +#[derive(Clone, Debug)] +pub struct SavePlan { + pub mode: SaveMode, + /// Where to write. `None` when no path is known (never saved, or a format other than the + /// document's own): the caller asks for one or takes the bytes. + pub path: Option, + pub format: &'static Format, + /// The format's options (only those its encoder reads). + pub options: Map, + /// Suggested file name: `.` or ` copy.`. + pub name: String, + /// Suggested folder: the document's own. + pub folder: Option, +} + +impl SavePlan { + /// Does writing this make the file the document's own (Save and Save As, except to a + /// template)? + pub fn retargets(&self) -> bool { + matches!(self.mode, SaveMode::Save | SaveMode::SaveAs) && self.format.id != "template" } - Ok((enc, opts)) } /// File Info's dates for a save to a file: the modified date (and the created date, when it has @@ -74,38 +91,245 @@ pub fn stamp_save_dates(st: &mut DocState) { d.metadata.modified = Some(now); } -pub(super) fn save(s: &mut Session, p: &Value) -> Result { - const C: &str = "document.save"; - let path = str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone())); - let f = save_format(str_param(p, "format"), path.as_deref()).map_err(|e| bad(C, e))?; - if path.is_some() { - stamp_save_dates(s.doc_mut()?); - } - let expanded = super::pdf::expand_preset(s, C, p)?; - let p = &*expanded; - let st = s.doc()?; - let (enc, opts) = save_encoding(st, f, p, path.as_deref())?; - // The encoder's notes (a .ai file's PDF options not applied yet…). - let notes = if enc.warnings.is_empty() { json!({}) } else { json!({ "warnings": enc.warnings }) }; - let Some(path) = path else { - // Nowhere to save to (web, agents): hand the bytes back; the document stays modified. - return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, notes); - }; - let out = write_encoded(Some(&path), &path, &st.doc, &enc, json!({}))?; - let st = s.doc_mut()?; - st.path = Some(path.clone()); - st.save_options = opts; - st.mark_saved(); - let mut r = super::merge(json!({ "path": path }), notes); - if let Some(linked) = out.get("linked") { - r["linked"] = linked.clone(); - } - Ok(r) +/// 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()) } -/// File → Save as Template: a native copy flagged so opening it starts a new untitled document. -pub(super) fn save_template(s: &mut Session, p: &Value) -> Result { - let mut d = (*s.doc()?.doc).clone(); - d.template = true; - write_or_return(str_param(p, "path"), &vectorcraft_format::save_file(&d), json!({})) +/// A save format by id or extension, or why it isn't one. +fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> { + match format(f) { + Some(f) if SAVE_FORMATS.contains(&f.id) => Ok(f), + Some(f) => Err(format!("Save writes {}, not {}: use document.export for other formats", SAVE_FORMATS.join(", "), f.label)), + None => Err(format!("unknown format `{f}` (Save writes {})", SAVE_FORMATS.join(", "))), + } +} + +/// The format Save writes for `format` (an id or extension), else the format `path`'s extension +/// names, else native (see [`SAVE_FORMATS`]); other formats are exports. +pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> { + save_format_of(format.or_else(|| path.and_then(format_for_name).map(|f| f.id)).unwrap_or("vectorcraft")) +} + +/// [`save_format_of`] for command `cmd`. +fn checked(cmd: &str, f: &str) -> Result<&'static Format> { + save_format_of(f).map_err(|e| bad(cmd, e)) +} + +/// Resolve a save of the active document from `{path?, format?, options?}` (see the commands' +/// params docs). +pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { + let cmd = mode.command(); + let st = s.doc()?; + let path = str_param(p, "path").map(str::to_string); + let format = match (mode, str_param(p, "format"), path.as_deref().and_then(format_for_name)) { + (SaveMode::Template, ..) => checked(cmd, "template")?, + (_, Some(f), _) => checked(cmd, f)?, + (_, None, Some(f)) => checked(cmd, f.id)?, + (_, None, None) => checked(cmd, st.format)?, + }; + let mut given: Map = match p.get("options") { + Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(), + None | Some(Value::Null) => Map::new(), + Some(_) => return Err(bad(cmd, "options must be an object (see file.formatOptions)")), + }; + if is_svg(format) { + // SVG options also come flat or as `svg: {…}` beside the path; kept flat. + let mut svg = super::svg_options(&Value::Object(given)).map_err(|e| bad(cmd, e))?; + svg.extend(super::svg_options(p).map_err(|e| bad(cmd, e))?); + given = svg; + } else if is_pdf(format) { + // So do PDF options (as the Save PDF dialog and document.exportPdf name them). + if let Some(o) = p.as_object() { + given.extend( + o.iter().filter(|(k, v)| !v.is_null() && k.as_str() != "path" && reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())), + ); + } + } + // None given: the ones the document was last saved with in this format. + let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given }; + // Save writes the document's own file; the other modes only where they are told to. + let path = path.or_else(|| (mode == SaveMode::Save && format.id == st.format).then(|| st.path.clone()).flatten()); + let stem = file_stem(st.path.as_deref().unwrap_or(&st.doc.title)); + let ext = format.extensions[0]; + let name = match mode { + SaveMode::Copy => format!("{stem} copy.{ext}"), + SaveMode::Save | SaveMode::SaveAs | SaveMode::Template => format!("{stem}.{ext}"), + }; + let folder = st.path.as_deref().and_then(parent_folder); + Ok(SavePlan { mode, path, format, options, name, folder }) +} + +/// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General +/// settings (`createLayers`…) and presets. +fn reads_option(f: &Format, key: &str) -> bool { + match is_pdf(f) { + true => super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key), + false => f.options.iter().any(|o| o.name == key), + } +} + +/// Formats that carry the whole native document: they lose nothing, and record links relative to +/// where they are written. A `.ai` file is a PDF carrying it. +fn is_native(f: &Format) -> bool { + matches!(f.id, "vectorcraft" | "template" | "ai") +} + +fn is_pdf(f: &Format) -> bool { + matches!(f.id, "pdf" | "ai") +} + +fn is_svg(f: &Format) -> bool { + matches!(f.id, "svg" | "svgz") +} + +/// What a format loses against a native file (reported whenever a save writes it). +fn fidelity_warning(f: &Format) -> Option { + (!is_native(f)).then(|| { + format!( + "{} keeps the artwork but not everything a VectorCraft document holds (editable effects, symbols, swatches, styles): save as VectorCraft to keep it all editable", + f.label + ) + }) +} + +/// Encode `plan` and write it with `write` (the file, then the images an SVG links to, beside it). +/// Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?: [{name, +/// dataBase64}]}` and the document is unchanged; with one → `{path, format, bytes, warnings, +/// linked?: [path…]}`, and Save or Save As (except to a template) make the file the document's +/// own: path, title, format, options, saved state. +pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { + let cmd = plan.mode.command(); + let retargets = plan.retargets(); + if plan.path.is_some() && retargets { + stamp_save_dates(s.doc_mut()?); + } + let own: &Document = &s.doc()?.doc; + // A native file records its links' paths relative to where it is written. + let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(own, p)); + let doc = relative.as_ref().unwrap_or(own); + let mut params = plan.options.clone(); + if is_svg(plan.format) { + // A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out. + params.entry("hiddenLayers").or_insert(Value::Bool(true)); + } + let params = Value::Object(params); + let params = super::pdf::expand_preset(s, cmd, ¶ms)?; + let mut enc = if plan.format.id == "ai" { + // A PDF of every artboard with the PDF options, always carrying the native document. + let mut q = params.into_owned(); + if let Some(o) = q.as_object_mut() { + o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]); + } + let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &q)?; + Encoded { warnings, ..Encoded::one(bytes) } + } else { + encode_all(doc, plan.format.id, ¶ms)? + }; + if enc.files.len() != 1 { + return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); + } + // What the format loses first, then the encoder's own notes (PDF options not applied yet…). + let mut warnings = std::mem::take(&mut enc.warnings); + warnings.splice(0..0, fidelity_warning(plan.format)); + let extra = json!({ "format": plan.format.id, "warnings": warnings }); + let Some(path) = plan.path else { + let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?; + out["name"] = json!(plan.name); + if let Some(folder) = plan.folder { + out["folder"] = json!(folder); + } + return Ok(out); + }; + let files = enc.named(doc, &path); + for (p, bytes) in &files { + write(p, bytes)?; + } + let bytes = files.first().map_or(0, |f| f.1.len()); + let mut out = super::merge(json!({ "path": path, "bytes": bytes }), extra); + if let Some(linked) = files.get(1..).filter(|l| !l.is_empty()) { + out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect(); + } + if retargets { + let st = s.doc_mut()?; + st.path = Some(path.clone()); + st.format = plan.format.id; + st.save_options = plan.options; + st.mark_saved(); + } + Ok(out) +} + +/// A save command: plan, then write with the file system. +fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result { + let plan = save_plan(s, mode, p)?; + save_with(s, plan, write_file) +} + +/// `file.formatOptions`: a writable format's options with the values Save would use. +fn format_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.formatOptions"; + let st = s.active(); + let id = str_param(p, "format").or(st.map(|d| d.format)).unwrap_or("vectorcraft"); + let f = format(id).filter(|f| f.write).ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?; + let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options); + let mut v = f.to_json(); + if let Some(options) = v["options"].as_object_mut() { + for (name, o) in options.iter_mut() { + o["value"] = saved.and_then(|m| m.get(name)).unwrap_or(&o["default"]).clone(); + } + } + v["saveFormats"] = + SAVE_FORMATS.iter().filter_map(|id| format(id)).map(|f| json!({"id": f.id, "label": f.label, "extensions": f.extensions})).collect(); + Ok(v) +} + +pub(super) fn specs() -> Vec { + vec![ + cmd!( + "document.save", + "Save Document", + [], + None, + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + has_doc, + |s, p| save(s, SaveMode::Save, p) + ), + cmd!( + "file.saveAs", + "Save As…", + ["File"], + Some("Cmd+Shift+S"), + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}", + has_doc, + |s, p| save(s, SaveMode::SaveAs, p) + ), + cmd!( + "file.saveCopy", + "Save a Copy…", + ["File"], + Some("Cmd+Alt+S"), + "{path?, format?, options?} (as file.saveAs) write a copy; the document keeps its path, title and modified state → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \" copy.\", folder?, warnings}", + has_doc, + |s, p| save(s, SaveMode::Copy, p) + ), + cmd!( + "file.saveAsTemplate", + "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}", + has_doc, + |s, p| save(s, SaveMode::Template, p) + ), + cmd!( + query "file.formatOptions", + "Format Options", + [], + None, + "{format?: a writable format id (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else the default", + always, + format_options + ), + ] } diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 07da7a6e..32cafb00 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -154,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() { let mut s = Session::new(); let r = s.execute("document.formats", &json!({})).unwrap(); let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); - assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp"]); + assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"]); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); assert_eq!(png["options"]["scale"]["default"], 1); diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 1d5fba70..c2d0d2f3 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -106,8 +106,9 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() { s.execute("document.save", &json!({})).unwrap(); let again = vectorcraft_pdf::editing(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(vectorcraft_format::load(&again.data).unwrap().layers, s.doc().unwrap().doc.layers); - // PDF stays an export. - assert!(s.execute("document.save", &json!({"path": tmp("x.pdf")})).is_err(), "PDF is written with document.exportPdf"); + // Save As also writes a plain PDF (the document then saves back as PDF); PNG stays an export. + assert_eq!(s.execute("document.save", &json!({"path": tmp("x.pdf")})).unwrap()["format"], "pdf"); + assert!(s.execute("document.save", &json!({"path": tmp("x.png")})).is_err(), "PNG is written with document.export"); // A plain PDF named .ai opens as artwork, without a path to save over. let plain = b64(&s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap()); let plain_path = tmp("plain.ai"); diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 890d8e91..d52ab20e 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -165,7 +165,7 @@ fn save_writes_svg_and_remembers_its_options() { let native = dir.join("doc.vectorcraft").to_string_lossy().to_string(); s.execute("document.save", &json!({"path": native})).unwrap(); assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap())); - assert_eq!(s.doc().unwrap().save_options, Value::Null); + assert!(s.doc().unwrap().save_options.is_empty(), "a native save remembers no options"); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 52afd11d..7878470d 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -171,12 +171,13 @@ fn load(p: &Value, cmd: &str) -> Result { let index = if pdf { 0 } else { page - 1 }; let n = doc.artboards.len(); let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| { - let what = if format.id == "vectorcraft" { "artboard" } else { "page" }; + let what = if matches!(format.id, "vectorcraft" | "template") { "artboard" } else { "page" }; bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)")) })?; // A PDF import has one layer per page; a native document's art is whatever lies on the // artboard. - let pages: Vec<&Arc> = if format.id == "vectorcraft" { layers.iter().collect() } else { layers.get(index).into_iter().collect() }; + let pages: Vec<&Arc> = + if matches!(format.id, "vectorcraft" | "template") { layers.iter().collect() } else { layers.get(index).into_iter().collect() }; let on_board = |c: &&Arc| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin())); let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board)); (nodes, crop.then_some(board)) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d8d61e17..73627d70 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -112,9 +112,12 @@ pub struct DocState { pub mask_view: Option, /// View → Show Transparency Grid, per document (view state: not saved, not undoable). pub transparency_grid: bool, - /// The SVG options this document was last saved with as SVG (Save reuses them; JSON as in - /// `document.save {svg}`, null when none). - pub save_options: Value, + /// The format Save writes ([`cmd::fileio::SAVE_FORMATS`]): the one the document was opened + /// from or last saved as. + pub format: &'static str, + /// That format's options as last saved (SVG options for SVG, the Save PDF settings for PDF; + /// empty for native files): Save reuses them and `file.formatOptions` reads them back. + pub save_options: serde_json::Map, } static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -138,7 +141,8 @@ impl DocState { uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), mask_view: None, transparency_grid: false, - save_options: Value::Null, + format: "vectorcraft", + save_options: Default::default(), } } /// Unsaved changes: the document differs from the saved one (selection changes don't count). @@ -1014,6 +1018,8 @@ mod tests_recolor; #[cfg(test)] mod tests_registration; #[cfg(test)] +mod tests_save; +#[cfg(test)] mod tests_scalestrokes; #[cfg(test)] mod tests_strokegeom; diff --git a/crates/engine/src/tests_save.rs b/crates/engine/src/tests_save.rs new file mode 100644 index 00000000..08236c66 --- /dev/null +++ b/crates/engine/src/tests_save.rs @@ -0,0 +1,169 @@ +//! The save pipeline: Save writes a document's own format, Save As, a Copy and as Template. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use super::*; +use crate::cmd::fileio::{SaveMode, save_plan}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s +} + +fn rect(s: &mut Session) { + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); +} + +/// A fresh folder for one test. +fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("vc-save-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn path(d: &std::path::Path, name: &str) -> String { + d.join(name).to_string_lossy().to_string() +} + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +#[test] +fn an_svg_path_writes_svg_and_save_writes_it_back() { + let d = dir("svg"); + let mut s = session(); + rect(&mut s); + let svg = path(&d, "art.svg"); + let r = s.execute("document.save", &json!({"path": svg})).unwrap(); + assert_eq!(r["format"], "svg"); + assert!(r["warnings"][0].as_str().unwrap().contains("SVG keeps the artwork"), "{r}"); + assert!(std::fs::read_to_string(&svg).unwrap().starts_with(" = o["saveFormats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]); + // SVGZ is gzip-compressed SVG and reopens as SVGZ. + let svgz = path(&d, "doc.svgz"); + s.execute("file.saveAs", &json!({"path": svgz})).unwrap(); + assert_eq!(&std::fs::read(&svgz).unwrap()[..2], [0x1f, 0x8b]); + s.execute("document.open", &json!({"path": svgz})).unwrap(); + assert_eq!(s.doc().unwrap().format, "svgz"); + // Export formats aren't save formats. + let e = s.execute("file.saveAs", &json!({"path": path(&d, "x.png")})).unwrap_err().to_string(); + assert!(e.contains("document.export"), "{e}"); + assert!(s.execute("document.save", &json!({"format": "webp"})).is_err()); + assert!(s.execute("file.saveAs", &json!({"path": svgz, "options": 3})).is_err()); + let _ = std::fs::remove_dir_all(d); +} + +#[test] +fn another_format_without_a_path_hands_the_bytes_back() { + let d = dir("otherfmt"); + let mut s = session(); + let native = path(&d, "a.vectorcraft"); + s.execute("document.save", &json!({"path": native})).unwrap(); + let r = s.execute("document.save", &json!({"format": "svg"})).unwrap(); + assert!(String::from_utf8(b64(&r)).unwrap().starts_with(" empty && !st.selection.is_empty(), "the template's art is placed and selected"); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index da5a02de..6de4b57c 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -29,9 +29,8 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[ ( "file.save", "Save", - "{path?, format?} = document.save: .vectorcraft, .ai (a PDF that reopens editable), .svg or .svgz (default: the document's path)", + "{path?, format?, options?, svg?: {…SVG options}} = document.save: .vectorcraft, .ai (a PDF that reopens editable), .pdf, .svg or .svgz (default: the document's own path and format)", ), - ("file.saveAs", "Save As…", "{path, format?, …the format's options} = document.save"), ("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"), ("file.exportForScreens", "Export for Screens…", "{folder?, artboards? | range?, formats?, prefix?} = document.exportForScreens"), ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), @@ -106,7 +105,7 @@ impl Headless { fn exec(&mut self, id: &str, params: &Value) -> Result { match id { "file.open" => self.open(params), - "file.save" | "file.saveAs" => self.save(params), + "file.save" => self.save(params), "file.export" => self.export(params), "file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()), "tool.select" => self.select_tool(params), @@ -221,7 +220,8 @@ impl Headless { self.session.execute("document.open", &json!({"path": path})).map_err(|e| e.to_string()) } - /// `app.save {path?}`: native format; remembers the path and clears the dirty flag. + /// `app.save {path?, format?, options?}`: `document.save` (by default the document's own path and + /// format); remembers the path and clears the dirty flag. pub fn save(&mut self, p: &Value) -> Result { let st = self.session.active().ok_or("no document")?; if s(p, "path").is_none() && st.path.is_none() { diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index 2fa6f4cf..a0ecfa7f 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -55,7 +55,7 @@ fn export_tool_lists_engine_formats_and_options() { let tools = tool_definitions(); let export = tools.iter().find(|t| t["name"] == "export").unwrap(); let props = &export["inputSchema"]["properties"]; - assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp"])); + assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"])); for k in ["artboard", "range", "options"] { assert!(props.get(k).is_some(), "export takes {k}"); } diff --git a/crates/ui-egui/src/dialogs/flattener_presets.rs b/crates/ui-egui/src/dialogs/flattener_presets.rs index e21bf2f7..7f26852b 100644 --- a/crates/ui-egui/src/dialogs/flattener_presets.rs +++ b/crates/ui-egui/src/dialogs/flattener_presets.rs @@ -9,7 +9,7 @@ use serde_json::{Value, json}; use vectorcraft_engine::cmd::FlattenOptions; -use vectorcraft_engine::cmd::flatten::PRESET_FORMAT; +use vectorcraft_engine::cmd::flatten::{PRESET_EXTS, PRESET_FORMAT}; use super::flatten::{options_editor, put_options}; use super::{DialogSpec, form}; @@ -180,7 +180,8 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> f(); return Ok(()); } - let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?; + let pick = crate::FilePick { filters: vec![("Flattener 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("flattener.presets.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { select(d, first); @@ -274,7 +275,7 @@ mod tests { let file = dir.join("mine.vcflattener").to_string_lossy().to_string(); let picked = file.clone(); let services = crate::Services { - pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), + 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() }; diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 1bbac292..8a20a3d4 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -12,9 +12,14 @@ const FIELD_W: f32 = 132.0; /// A labelled text field bound to `d.fields[key]` (one grid row). pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + field_w(ui, d, key, label, FIELD_W - 12.0); +} + +/// [`field`] `width` points wide. +pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, width: f32) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(label).color(t.text_dim)); - text(ui, d, key, FIELD_W - 12.0); + text(ui, d, key, width); ui.end_row(); } diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index 93c00d8c..c564ab9d 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -117,7 +117,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } else { let path = match d.str("path") { p if !p.is_empty() => p, - _ => app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?, + _ => { + let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() }; + app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")? + } }; out = app.run("links.relink", json!({ "ids": current["ids"], "path": path }))?; // Apply to All: the others by name in the same folder; any not there are asked about. diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index f8cb41b4..bcb16b88 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -39,6 +39,7 @@ pub mod raster_effects; pub mod recolor; pub mod saturate; mod save_changes; +pub(crate) mod save_options; mod save_pdf; pub mod save_style_library; pub mod save_swatch_library; @@ -201,6 +202,7 @@ registry! { TextImport: [text_import::KIND] => text_import::SPEC, PdfPresets: [pdf_presets::KIND] => pdf_presets::SPEC, PdfPreset: [save_pdf::PRESET_KIND] => save_pdf::PRESET_SPEC, + SaveOptions: [save_options::KIND] => save_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs index 89ae5552..1bb0b8ed 100644 --- a/crates/ui-egui/src/dialogs/pdf_presets.rs +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -175,7 +175,8 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> f(); return Ok(()); } - let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?; + let pick = crate::FilePick { filters: vec![("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], ..Default::default() }; + let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?; let r = app.run("pdf.preset.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { select(d, first); @@ -295,7 +296,7 @@ mod tests { let file = dir.join("mine.vcpdfpresets").to_string_lossy().to_string(); let picked = file.clone(); let services = crate::Services { - pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), + 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() }; diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs new file mode 100644 index 00000000..5307ed73 --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -0,0 +1,334 @@ +//! A save format's options before the file is written; on the web, where there is no save panel, +//! also the Save As dialog that names the file and picks the format (SVG and PDF then go on to +//! their own dialogs). The rows come from the format's options in the engine's format table, so a +//! format gains rows as its encoder gains options. +//! +//! Fields: `__action` (the save command it finishes), `__pick` (file name and format +//! editable), `path`, `format`, and one field per option (agents set them with `ui.dialog.set`). + +use std::sync::OnceLock; + +use serde_json::{Map, Value, json}; +use vectorcraft_engine::cmd::fileio::{self, Format, FormatOption, SAVE_FORMATS, SaveMode}; + +use super::{DialogResult, DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, io, widgets}; + +/// Dialog kind. +pub const KIND: &str = "saveOptions"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 380.0, ..DialogSpec::FORM }; + +fn heading(d: &Dialog) -> String { + if d.bool("__pick") { + return match SaveMode::of(&d.str("__action")) { + Some(SaveMode::Copy) => "Save a Copy", + Some(SaveMode::Template) => "Save as Template", + _ => "Save As", + } + .into(); + } + format!("{} Options", fileio::format(&d.str("format")).map_or("", |f| f.label)) +} + +/// `f`'s options with the values a save would use (`file.formatOptions`: as last saved in that +/// format, else the defaults). Text options start empty rather than null. +pub(crate) fn option_values(app: &mut VectorcraftApp, f: &Format) -> Map { + let v = app.session.execute("file.formatOptions", &json!({ "format": f.id })).unwrap_or_default(); + f.options + .iter() + .map(|o| { + let value = v["options"][o.name]["value"].clone(); + (o.name.to_string(), if o.ty == "string" && value.is_null() { json!("") } else { value }) + }) + .collect() +} + +/// Formats whose options have a dialog of their own (SVG Options, Save PDF), which follows this +/// one. +fn own_dialog(f: &Format) -> bool { + matches!(f.id, "svg" | "svgz" | "pdf") +} + +/// The options the dialog shows: arrays and objects are for agents, and a page range replaces the +/// single artboard choice; none for a format with its own dialog. +fn shown(f: &Format) -> impl Iterator { + let range = f.options.iter().any(|o| o.name == "range"); + let options = if own_dialog(f) { &[][..] } else { f.options }; + options.iter().filter(move |o| !(matches!(o.ty, "array" | "object") || (range && o.name == "artboard"))) +} + +/// The Save As format menu (labels in [`SAVE_FORMATS`] order). +fn save_labels() -> &'static [&'static str] { + static LABELS: OnceLock> = OnceLock::new(); + LABELS.get_or_init(|| SAVE_FORMATS.iter().filter_map(|id| fileio::format(id)).map(|f| f.label).collect()) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let Some(f) = fileio::format(&d.str("format")) else { return false }; + let mut switch = None; + egui::Grid::new("save-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + if d.bool("__pick") { + form::field_w(ui, d, "path", "File Name:", 240.0); + if SaveMode::of(&d.str("__action")) != Some(SaveMode::Template) { + ui.label(egui::RichText::new("Format:").color(t.text_dim)); + switch = widgets::dropdown(ui, "save-format", f.label, save_labels(), 254.0).and_then(|i| SAVE_FORMATS.get(i).copied()); + ui.end_row(); + } + } + for o in shown(f) { + option_row(app, ui, d, o); + } + }); + if let Some(id) = switch.filter(|id| *id != f.id) { + switch_format(app, d, id); + } + false +} + +/// One option: a checkbox, an artboard menu, a number or a text field. +fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &FormatOption) { + let t = Tokens::get(ui.ctx()); + let label = form::humanize(o.name); + let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null); + if o.ty == "boolean" { + ui.label(""); + let mut on = value.as_bool().unwrap_or(false); + if ui.checkbox(&mut on, label.trim_end_matches(':')).on_hover_text(o.description).changed() { + d.fields.insert(o.name.into(), json!(on)); + } + ui.end_row(); + return; + } + if o.ty == "string" { + form::field_w(ui, d, o.name, &label, 240.0); + return; + } + ui.label(egui::RichText::new(&label).color(t.text_dim)).on_hover_text(o.description); + let artboards = app.session.active().filter(|_| o.name == "artboard").map(|st| &st.doc.artboards); + if let Some(boards) = artboards { + let i = value.as_u64().unwrap_or(0) as usize; + let names: Vec<&str> = boards.iter().map(|a| a.name.as_str()).collect(); + if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), names.get(i).copied().unwrap_or(""), &names, 254.0) { + d.fields.insert(o.name.into(), json!(i)); + } + } else { + let integer = o.ty == "integer"; + let mut x = value.as_f64().unwrap_or(0.0); + let drag = + egui::DragValue::new(&mut x).range(0.0..=f64::MAX).speed(if integer { 1.0 } else { 0.1 }).max_decimals(if integer { 0 } else { 2 }); + if ui.add(drag).on_hover_text(o.description).changed() { + d.fields.insert(o.name.into(), if integer { json!(x.round() as i64) } else { json!(x) }); + } + } + ui.end_row(); +} + +/// Web Save As: another format brings its own options and file extension. +fn switch_format(app: &mut VectorcraftApp, d: &mut Dialog, id: &str) { + let (Some(old), Some(new)) = (fileio::format(&d.str("format")), fileio::format(id)) else { return }; + for o in old.options { + d.fields.remove(o.name); + } + d.fields.extend(option_values(app, new)); + d.fields.insert("format".into(), json!(new.id)); + d.fields.insert("path".into(), json!(with_extension(&d.str("path"), new))); +} + +/// `path` ending in `f`'s extension (kept when it already names `f`). +fn with_extension(path: &str, f: &Format) -> String { + if fileio::format_for_name(path).is_some_and(|g| g.id == f.id) { + return path.to_string(); + } + std::path::Path::new(path).with_extension(f.extensions[0]).to_string_lossy().to_string() +} + +/// Write the file with the chosen options (blank ones fall back to the format's defaults). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { + let f = fileio::format(&d.str("format")).ok_or("unknown format")?; + let path = d.str("path"); + if path.trim().is_empty() { + return Err("name the file".into()); + } + let path = if d.bool("__pick") { with_extension(path.trim(), f) } else { path }; + let options: Map = f + .options + .iter() + .filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone()))) + .collect(); + app.ui.dialog = None; + match SaveMode::of(&d.str("__action")) { + Some(mode) if own_dialog(f) => io::ask_format_options(app, mode, f, &path), + Some(mode) => io::save(app, mode, &json!({ "path": path, "format": f.id, "options": options }), false), + None => Err(format!("`{}` is no save command", d.str("__action"))), + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use vectorcraft_engine::Session; + + use super::*; + use crate::{FilePick, Services, theme}; + + type Written = Rc)>>>; + + /// A desktop app whose save panel answers `picked` and records what it was shown. + fn desktop(picked: &'static str) -> (VectorcraftApp, Written, Rc>>) { + let (written, picks) = (Written::default(), Rc::new(RefCell::new(vec![]))); + let (w, p) = (written.clone(), picks.clone()); + let services = Services { + pick_save: Some(Box::new(move |f: &FilePick| { + p.borrow_mut().push(f.clone()); + Some(picked.to_string()) + })), + write: Some(Box::new(move |path: &str, b: &[u8]| { + w.borrow_mut().push((path.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + (with_art(services), written, picks) + } + + /// A browser app: saves are downloads. + fn web() -> (VectorcraftApp, Written) { + let written = Written::default(); + let w = written.clone(); + let services = Services { + download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))), + ..Default::default() + }; + (with_art(services), written) + } + + fn with_art(services: Services) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + app + } + + /// One headless frame of the dialog layer; `enter` presses OK. + fn frame(app: &mut VectorcraftApp, enter: bool) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let events = if enter { + vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }] + } else { + vec![] + }; + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn dialog(app: &VectorcraftApp) -> &Dialog { + app.ui.dialog.as_ref().expect("a dialog is open") + } + + #[test] + fn save_as_svg_opens_svg_options_then_writes_svg() { + let (mut app, written, picks) = desktop("art.svg"); + let r = app.run("file.saveAs", json!({})).unwrap(); + assert_eq!((r["pending"].as_str(), r["path"].as_str()), (Some("svgOptions"), Some("art.svg"))); + // The save panel offers every save format, the document's own first. + let pick = &picks.borrow()[0]; + let labels: Vec<&str> = pick.filters.iter().map(|f| f.0).collect(); + assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai"]); + assert_eq!(pick.name, "Untitled-1.vectorcraft"); + let d = dialog(&app); + assert_eq!((d.kind.as_str(), d.str("path").as_str(), d.str("mode").as_str()), ("svgOptions", "art.svg", "save")); + assert!(written.borrow().is_empty(), "nothing is written before OK"); + frame(&mut app, false); + assert!(app.ui.dialog.is_some(), "drawing keeps it open"); + app.ui.dialog.as_mut().unwrap().fields.insert("outlineText".into(), json!(true)); + frame(&mut app, true); + assert!(app.ui.dialog.is_none()); + let (path, bytes) = written.borrow()[0].clone(); + assert_eq!(path, "art.svg"); + assert!(bytes.starts_with(b" Value { p } -/// Save PDF: write the file; the dialog stays open when that fails (bad range, cancelled save…). -/// While Save Preset… asks for a name, OK (and Enter) saves the preset instead. +/// Save PDF: write the file (a PDF copy, or the PDF a Save As / Save a Copy asked for, `__save`); +/// the dialog stays open when that fails (bad range, cancelled save…). While Save Preset… asks for +/// a name, OK (and Enter) saves the preset instead. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if d.fields.contains_key(SAVE_AS) { let mut d = d.clone(); @@ -109,7 +110,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { app.ui.dialog = Some(d); return r; } - let r = io::export_pdf(app, params(d)); + let r = match SaveMode::of(&d.str("__save")) { + Some(mode) => io::save_pdf(app, mode, params(d)), + None => io::export_pdf(app, params(d)), + }; if r.is_ok() { app.ui.dialog = None; } @@ -759,9 +763,9 @@ mod tests { // Without a path, bad options are refused before a save dialog asks for one. let asked = Rc::new(RefCell::new(0)); let a = asked.clone(); - app.services.pick_save = Some(Box::new(move |name: &str| { + app.services.pick_save = Some(Box::new(move |pick: &crate::FilePick| { *a.borrow_mut() += 1; - Some(format!("/tmp/picked-{name}")) + Some(format!("/tmp/picked-{}", pick.name)) })); app.run("ui.savePdfDialog", json!({"range": "7"})).unwrap(); assert!(super::super::confirm(&mut app).is_err()); diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 18e20321..4b207b64 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -78,7 +78,12 @@ pub fn open(app: &mut VectorcraftApp, mode: Mode, path: Option<&str>) { if app.session.active().is_some_and(|st| st.doc.setup.export_text == vectorcraft_doc::ExportText::Appearance) { fields.insert("outlineText".into(), json!(true)); } - let saved = app.session.active().map(|st| st.save_options.clone()).filter(|_| mode != Mode::Export); + // Only options saved with an SVG format are SVG options. + let saved = app + .session + .active() + .filter(|st| mode != Mode::Export && matches!(st.format, "svg" | "svgz")) + .map(|st| Value::Object(st.save_options.clone())); for last in [&app.ui.svg_options].into_iter().chain(saved.as_ref()) { if let Some(o) = last.as_object() { fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); @@ -289,7 +294,7 @@ mod tests { w.borrow_mut().push((p.to_string(), b.to_vec())); Ok(()) })), - pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), + pick_save: Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))), ..Default::default() }; let mut app = VectorcraftApp::new(Session::new(), services); diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index b654fbc1..42e84016 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -18,7 +18,7 @@ fn app(artboards: usize) -> (VectorcraftApp, Written) { let written = Written::default(); let w = written.clone(); let services = Services { - pick_save: Some(Box::new(|name: &str| Some(format!("/out/{name}")))), + pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name)))), write: Some(Box::new(move |p: &str, b: &[u8]| { w.borrow_mut().push((p.to_string(), b.to_vec())); Ok(()) @@ -211,7 +211,7 @@ fn old_ids_and_agents_export_directly_with_a_path() { #[test] fn nothing_to_export_keeps_export_as_open_before_the_save_dialog() { let (mut app, written) = app(1); - app.services.pick_save = Some(Box::new(|_: &str| panic!("no save dialog without art"))); + app.services.pick_save = Some(Box::new(|_: &crate::FilePick| panic!("no save dialog without art"))); app.run("select.all", json!({})).unwrap(); app.run("edit.clear", json!({})).unwrap(); app.run("file.exportAs", json!({})).unwrap(); @@ -219,7 +219,7 @@ fn nothing_to_export_keeps_export_as_open_before_the_save_dialog() { assert_eq!(kind(&app), Some("exportAs")); // With Use Artboards the empty artboard is exported. set(&mut app, "useArtboards", json!(true)); - app.services.pick_save = Some(Box::new(|name: &str| Some(format!("/out/{name}")))); + app.services.pick_save = Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name)))); confirm(&mut app).unwrap(); assert_eq!(kind(&app), Some("pngOptions")); assert!(written.borrow().is_empty()); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 56de6be3..524ffbdd 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -1,11 +1,13 @@ -//! File I/O through the injected services (pick, read, write, download); the engine's `fileio` -//! decodes and encodes every format. +//! File I/O through the injected services (pick, read, write, download); the engine's +//! `fileio` decodes, encodes and saves every format. use serde_json::{Value, json}; -use vectorcraft_engine::cmd::fileio; +use vectorcraft_engine::EngineError; +use vectorcraft_engine::cmd::fileio::{self, Format, SAVE_FORMATS, SaveMode, SavePlan}; -use crate::VectorcraftApp; use crate::dialogs::svg_options; +use crate::state::Dialog; +use crate::{FilePick, Services, VectorcraftApp, dialogs}; /// 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 @@ -49,48 +51,80 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O Ok(()) } +/// A path from the open dialog, or "cancelled". +fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result { + app.services.pick_open.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into()) +} + /// File → Open… pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { if let Some(f) = app.services.open_async.as_mut() { f(); return Ok(()); } - let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?; + let path = pick_open(app, &FilePick { filters: fileio::open_filters().collect(), ..Default::default() })?; open_path(app, &path) } +fn read(app: &VectorcraftApp, path: &str) -> Result, String> { + app.services.read.as_ref().ok_or("no file reader")?(path) +} + pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { - let read = app.services.read.as_ref().ok_or("no file reader")?; - let bytes = read(path)?; + let bytes = read(app, path)?; open_bytes(app, path, &bytes, Some(path.to_string()))?; note_recent(app, path); Ok(()) } -fn write_out(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> { - if let Some(dl) = app.services.download.as_mut() { +/// 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() { dl(&fileio::file_name(path), bytes); return Ok(()); } - let w = app.services.write.as_mut().ok_or("no file writer")?; - w(path, bytes) + services.write.as_mut().ok_or("no file writer")?(path, bytes) } -fn suggested(app: &VectorcraftApp, ext: &str) -> String { - let t = app.session.active().map(|d| d.title()).unwrap_or_else(|| "Untitled".into()); - let stem = std::path::Path::new(&t).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(t); - format!("{stem}.{ext}") +/// The web saves by downloading: no save panel, no folders. +fn is_web(app: &VectorcraftApp) -> bool { + app.services.download.is_some() } -/// `path`, else a suggested name (web download) or one picked in a save dialog. +/// Where a file goes when no path was given: the suggested name on the web (a download), else the +/// save panel's choice. +fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result { + if is_web(app) { + return Ok(pick.name.clone()); + } + app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into()) +} + +/// The active document's file name with extension `ext`, and its folder. +fn suggested(app: &VectorcraftApp, ext: &str) -> (String, Option) { + let st = app.session.active(); + let t = st.map(|d| d.path.clone().unwrap_or_else(|| d.doc.title.clone())).unwrap_or_else(|| "Untitled".into()); + let folder = st.and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(|p| p.to_string_lossy().to_string()); + (format!("{}.{ext}", fileio::file_stem(&t)), folder.filter(|f| !f.is_empty())) +} + +/// `path`, else one picked in a save panel (the web: the suggested name) for the active document +/// with extension `ext`, filtered to that format when it is one. pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: &str) -> Result { - match path { - Some(p) => Ok(p), - None if app.services.download.is_some() => Ok(suggested(app, ext)), - None => { - let s = suggested(app, ext); - app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or_else(|| "cancelled".into()) - } + if let Some(p) = path { + return Ok(p); + } + let (name, folder) = suggested(app, ext); + let filters = fileio::format(ext).map(|f| vec![(f.label, f.extensions)]).unwrap_or_default(); + pick_path(app, &FilePick { name, folder, filters }) +} + +/// A path typed in a save panel: kept when its extension names a save format (the panel's file +/// type), else `f`'s extension is added. +fn with_save_extension(path: &str, f: &Format) -> String { + match fileio::format_for_name(path) { + Some(g) if SAVE_FORMATS.contains(&g.id) => path.to_string(), + _ => format!("{path}.{}", f.extensions[0]), } } @@ -99,70 +133,137 @@ pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: & fn write_encoded(app: &mut VectorcraftApp, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result, String> { let named = enc.named(doc, path); for (p, bytes) in &named { - write_out(app, p, bytes)?; + write_to(&mut app.services, p, bytes)?; } Ok(named.into_iter().take(enc.files.len()).map(|(p, _)| p).collect()) } -fn format_param(p: &Value) -> Option<&str> { - p.get("format").and_then(Value::as_str) +fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result { + fileio::save_plan(&app.session, mode, p).map_err(|e| e.to_string()) } -/// File → Save / Save As: native, SVG for an .svg path (with the SVG options in `params`, else -/// the ones the document was last saved with), or a PDF-compatible .ai file (with the PDF options -/// in `params`) that reopens editable. -pub fn save(app: &mut VectorcraftApp, path: Option, save_as: bool, params: &Value) -> Result { - let st = app.session.active().ok_or("no document")?; - let existing = if save_as { None } else { st.path.clone() }; - let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?; - let f = fileio::save_format(format_param(params), Some(&path))?; - fileio::stamp_save_dates(app.session.active_mut().ok_or("no document")?); - let params = fileio::pdf::expand_preset(&app.session, "file.save", params).map_err(|e| e.to_string())?; - let params = &*params; - let st = app.session.active().ok_or("no document")?; - let (enc, opts) = fileio::save_encoding(st, f, params, Some(&path)).map_err(|e| e.to_string())?; - let doc = st.doc.clone(); - write_encoded(app, &doc, &path, &enc)?; - if let Some(st) = app.session.active_mut() { - st.path = Some(path.clone()); - st.save_options = opts; - st.mark_saved(); - } - app.status(format!("Saved {path}")); - note_recent(app, &path); - Ok(path) -} - -/// File → Save As / Save a Copy: the path (asked for when missing); an SVG path without SVG -/// options in `p` opens SVG Options, whose OK saves. A copy leaves the document's path alone. -pub fn save_as(app: &mut VectorcraftApp, copy: bool, p: &Value) -> Result { - let path = target_path(app, p.get("path").and_then(Value::as_str).map(str::to_string), vectorcraft_format::EXTENSION)?; - let f = fileio::save_format(format_param(p), Some(&path))?; - let given = p.get("svg").is_some_and(|v| !v.is_null()) || !fileio::svg_options(p)?.is_empty(); - if matches!(f.id, "svg" | "svgz") && !given { - let mode = if copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save }; - svg_options::open(app, mode, Some(&path)); - return Ok(json!({ "dialog": svg_options::KIND, "path": path })); - } - if !copy { - return save(app, Some(path), true, p).map(|p| json!({ "path": p })); - } - let st = app.session.active().ok_or("no document")?; - let doc = st.doc.clone(); - // A .ai copy is written as Save writes it (PDF plus the native document). - let enc = match f.id { - "ai" => fileio::pdf::expand_preset(&app.session, "file.saveCopy", p) - .and_then(|p| fileio::save_encoding(st, f, &p, Some(&path))) - .map(|(enc, _)| enc), - _ => { - let copy = vectorcraft_engine::cmd::links::with_relative_paths(&doc, &path); - fileio::encode_all(copy.as_ref().unwrap_or(&doc), f.id, p) +/// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the +/// engine command's `{path?, format?, options?, svg?}`. +/// +/// When no path is known, desktop asks with a save panel (one file type per save format) and the +/// web opens the Save As dialog (file name and format). With `ask_options` and no options in `p`, +/// a format picked that way that has options asks for them before anything is written (SVG +/// Options, the Save PDF dialog, or the save options dialog), and so does Save As or Save a Copy +/// to a given SVG path. → `{path, format, warnings…}` once written, or `{pending: , +/// path?}` while a dialog is open. +pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { + let first = plan(app, mode, p)?; + let ask = ask_options && !has_options(p); + if let Some(path) = first.path.clone() { + if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { + return ask_format_options(app, mode, first.format, &path); } + return write_plan(app, first); } - .map_err(|e| e.to_string())?; - write_encoded(app, &doc, &path, &enc)?; - app.status(format!("Saved a copy as {path}")); - Ok(json!({ "path": path })) + if is_web(app) && ask_options { + return Ok(open_options(app, mode.command(), first.format, &first.name, true)); + } + let filters = match mode { + SaveMode::Template => vec![(first.format.label, first.format.extensions)], + _ => fileio::save_filters(first.format.id), + }; + let picked = pick_path(app, &FilePick { name: first.name.clone(), folder: first.folder.clone(), filters })?; + // The picked file type wins over a `format` param. + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + if let Some(o) = q.as_object_mut() { + o.remove("format"); + o.insert("path".into(), json!(with_save_extension(&picked, first.format))); + } + let chosen = plan(app, mode, &q)?; + if ask && !chosen.format.options.is_empty() { + let path = chosen.path.clone().unwrap_or_default(); + return ask_format_options(app, mode, chosen.format, &path); + } + write_plan(app, chosen) +} + +/// Does `p` give format options (`options`, `svg: {…}` or SVG options at its top level)? +fn has_options(p: &Value) -> bool { + ["options", "svg"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) || fileio::svg_options(p).is_ok_and(|m| !m.is_empty()) +} + +/// Ask for format `f`'s options before a save (`mode`) to `path`: SVG Options for SVG, the Save +/// PDF dialog for PDF, else the save options dialog. OK finishes the save. +pub fn ask_format_options(app: &mut VectorcraftApp, mode: SaveMode, f: &Format, path: &str) -> Result { + match f.id { + "pdf" => ask_pdf_options(app, mode, path), + "svg" | "svgz" => { + let m = if mode == SaveMode::Copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save }; + svg_options::open(app, m, Some(path)); + Ok(json!({ "pending": svg_options::KIND, "dialog": svg_options::KIND, "path": path })) + } + _ => Ok(open_options(app, mode.command(), f, path, false)), + } +} + +/// A save as PDF asks with the Save PDF dialog, filled with the PDF options the document was last +/// saved with; its OK finishes the save ([`save_pdf`]). +pub fn ask_pdf_options(app: &mut VectorcraftApp, mode: SaveMode, path: &str) -> Result { + let mut p = Value::Object(plan(app, mode, &json!({ "path": path, "format": "pdf" }))?.options); + p["path"] = json!(path); + dialogs::open_save_pdf(app, &p)?; + let d = app.ui.dialog.as_mut().ok_or("the Save PDF dialog didn't open")?; + d.fields.insert("__save".into(), json!(mode.command())); + Ok(json!({ "pending": d.kind })) +} + +/// The Save PDF dialog's OK during a save (`mode`): `params` are its `document.exportPdf` options +/// and `path`. +pub fn save_pdf(app: &mut VectorcraftApp, mode: SaveMode, mut params: Value) -> Result { + let path = params.as_object_mut().and_then(|o| o.remove("path")); + let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); + let r = save(app, mode, &json!({ "path": path, "format": "pdf", "options": params }), false)?; + if view && let Some(path) = r["path"].as_str() { + view_file(app, path); + } + Ok(r) +} + +/// Open a written file in the system viewer (not on the web, which downloads it). +fn view_file(app: &mut VectorcraftApp, path: &str) { + if !is_web(app) { + app.open_url(&file_url(path)); + } +} + +/// "Saved ", with the first of the result's warnings. +fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { + let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + app.status(match warnings.first() { + Some(first) => format!("Saved {path} with {} note(s): {first}", warnings.len()), + None => format!("Saved {path}"), + }); +} + +/// Write a planned save through the services and report it in the status bar. +fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { + let retargets = plan.retargets(); + let services = &mut app.services; + let r = fileio::save_with(&mut app.session, plan, |path, bytes| write_to(services, path, bytes).map_err(EngineError::Other)) + .map_err(|e| e.to_string())?; + let path = r["path"].as_str().unwrap_or_default().to_string(); + if retargets { + note_recent(app, &path); + } + report_saved(app, &path, &r); + Ok(r) +} + +/// Open the save options dialog of format `f` before writing `path`: `action` is the save command +/// it finishes, and `pick` (the web's Save As) also names the file and chooses the format. +fn open_options(app: &mut VectorcraftApp, action: &str, f: &Format, path: &str, pick: bool) -> Value { + let mut fields = dialogs::save_options::option_values(app, f); + fields.insert("__action".into(), json!(action)); + fields.insert("__pick".into(), json!(pick)); + fields.insert("path".into(), json!(path)); + fields.insert("format".into(), json!(f.id)); + app.ui.dialog = Some(Dialog { kind: dialogs::save_options::KIND.into(), fields }); + json!({ "pending": dialogs::save_options::KIND }) } /// Put `path` at the top of File → Open Recent Files (capped by Preferences → Recent Files). @@ -239,7 +340,7 @@ fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) Some(p) => p, None => target_path(app, None, ext)?, }; - write_out(app, &path, &bytes)?; + write_to(&mut app.services, &path, &bytes)?; Ok((path, v)) } @@ -250,14 +351,10 @@ fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result { let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); let (path, mut v) = run_to_file(app, "document.exportPdf", "pdf", params)?; - if view && app.services.download.is_none() { - app.open_url(&file_url(&path)); + if view { + view_file(app, &path); } - let warnings: Vec<&str> = v["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); - app.status(match warnings.first() { - Some(first) => format!("Saved {path} with {} note(s): {first}", warnings.len()), - None => format!("Saved {path}"), - }); + report_saved(app, &path, &v); v["path"] = Value::String(path); Ok(v) } @@ -385,7 +482,7 @@ mod tests { let (mut app, written) = app(); app.session.execute("file.new", &json!({"width": 60, "height": 40})).unwrap(); app.session.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); - app.services.pick_save = Some(Box::new(|_: &str| Some("/tmp/sel.svg".into()))); + app.services.pick_save = Some(Box::new(|_: &FilePick| Some("/tmp/sel.svg".into()))); assert_eq!(app.run("document.exportSelection", json!({})).unwrap()["path"], "/tmp/sel.svg"); app.run("document.exportSelection", json!({"path": "/tmp/sel.png"})).unwrap(); let w = written.borrow(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bf693816..9402351a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -69,7 +69,25 @@ pub use control::{ControlRequest, ControlResponse}; pub use state::{UiState, View}; pub use sysclip::SystemClipboard; -pub type PickSave = Box Option>; +/// What a file dialog shows: a suggested file name (save dialogs), the folder to start in and the +/// file-type filters. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FilePick { + pub name: String, + pub folder: Option, + /// `(label, extensions without the dot)`, the default type first; empty = every file. + pub filters: Vec<(&'static str, &'static [&'static str])>, +} + +impl FilePick { + /// A save dialog suggesting `name`. + pub fn named(name: &str) -> Self { + Self { name: name.to_string(), ..Default::default() } + } +} + +pub type PickOpen = Box Option>; +pub type PickSave = Box Option>; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; pub type Inbox = Arc)>>>; @@ -82,8 +100,8 @@ pub type OpenUrlFn = Box; #[derive(Default)] pub struct Services { /// Show an open dialog; returns a path. - pub pick_open: Option Option>>, - /// Show a save dialog with a suggested file name; returns a path. + pub pick_open: Option, + /// Show a save dialog (suggested name, folder, file types); returns a path. pub pick_save: Option, pub read: Option, pub write: Option, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e9cf27b0..11beddfc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,6 +7,8 @@ use serde::Serialize; use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio::SaveMode; + use crate::VectorcraftApp; use crate::io; use crate::state::{DockTab, ICON_PANELS, next_zoom}; @@ -52,19 +54,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.save", "Save", "Cmd+S", - "{path?, svg?: {…SVG options}, …PDF options for .ai} (a document saved as .svg saves as SVG again, with the same options; one saved or opened as .ai saves as .ai again)", - ), - ( - "file.saveAs", - "Save As…", - "Cmd+Shift+S", - "{path?, svg?: {…SVG options}, …document.exportPdf options for .ai} .vectorcraft, .ai (PDF-compatible: a PDF carrying the native document, which reopens editable) or .svg/.svgz (such a path without svg options opens SVG Options)", - ), - ( - "file.saveCopy", - "Save a Copy…", - "Cmd+Alt+S", - "{path?, svg?: {…SVG options}, …PDF options for .ai} like Save As, but the document keeps its path", + "{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.revert", "Revert", "F12", "{}"), @@ -440,14 +430,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::open_path(app, &path).map(|_| Value::Null), None => io::open_dialog(app).map(|_| Value::Null), }, - "file.save" => io::save(app, s("path"), false, p).map(|p| json!({"path": p})), - "file.saveAs" | "file.saveCopy" => io::save_as(app, id == "file.saveCopy", p), + // Saves write through the app (save panel, download); with no path known the panel and the + // format's options dialog ask first. + "file.save" => io::save(app, SaveMode::Save, p, true), + "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), + "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), + "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), "document.exportSelection" => { io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) } - "file.saveAsTemplate" => { - io::save_command_output(app, id, "vectorcraft", 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), @@ -944,10 +935,9 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste"); } match id { - "file.save" - | "file.saveAs" - | "file.saveCopy" - | "file.place" + // Save is off for a clean document that already has its own file. + "file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.is_dirty()), + "file.place" | "file.export.svg" | "file.export.png" | "file.exportAs" @@ -1022,6 +1012,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Save As…", "file.saveAs"), c("Save a Copy…", "file.saveCopy"), c("Save as Template…", "file.saveAsTemplate"), + c("Save as PDF…", "file.export.pdf"), c("Revert", "file.revert"), Sep, c("Place…", "file.place"), diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 35d81835..f57e97b3 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -141,7 +141,12 @@ pub(crate) fn pick_library_file(app: &mut VectorcraftApp, path: Option) f(); return Ok(None); } - path.or_else(|| app.services.pick_open.as_mut().and_then(|f| f())).map(Some).ok_or_else(|| "cancelled".into()) + path.or_else(|| { + let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() }; + app.services.pick_open.as_mut().and_then(|f| f(&pick)) + }) + .map(Some) + .ok_or_else(|| "cancelled".into()) } /// The User Defined library a Window menu slot (`prefix` and a number from 1) stands for, of @@ -557,7 +562,7 @@ mod tests { let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); let w = written.clone(); let services = crate::Services { - pick_save: Some(Box::new(|name: &str| Some(format!("/tmp/{name}")))), + pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), write: Some(Box::new(move |p: &str, b: &[u8]| { w.borrow_mut().push((p.to_string(), b.to_vec())); Ok(()) diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 96f4eb1c..d7d484df 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -107,7 +107,11 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.horizontal(|ui| { if widgets::flat_button(ui, "Load Profile…", 110.0).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f()) + && let Some(path) = app + .services + .pick_open + .as_mut() + .and_then(|f| f(&crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() })) { run(app, "color.loadProfile", json!({ "path": path })); } diff --git a/crates/ui-egui/src/panels/tests_style_libraries.rs b/crates/ui-egui/src/panels/tests_style_libraries.rs index dbf0037f..31165c03 100644 --- a/crates/ui-egui/src/panels/tests_style_libraries.rs +++ b/crates/ui-egui/src/panels/tests_style_libraries.rs @@ -164,7 +164,7 @@ fn saving_to_a_file_and_opening_a_vcstyles_file() { let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); let w = written.clone(); let services = crate::Services { - pick_save: Some(Box::new(|name: &str| Some(format!("/tmp/{name}")))), + pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), write: Some(Box::new(move |p: &str, b: &[u8]| { w.borrow_mut().push((p.to_string(), b.to_vec())); Ok(()) diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 5cb75932..aaa571ff 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -106,7 +106,7 @@ fn pick(app: &mut VectorcraftApp) -> Result<(), String> { } let paths = match (app.services.pick_open_multi.as_mut(), app.services.pick_open.as_mut()) { (Some(f), _) => f(), - (None, Some(f)) => f().into_iter().collect(), + (None, Some(f)) => f(&crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect(), (None, None) => vec![], }; if paths.is_empty() { diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 1064d02c..303259a9 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -442,7 +442,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides); let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default(); - let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f("VectorCraft Shortcuts.json"))); + let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named("VectorCraft Shortcuts.json")))); match path { Some(path) => match app.services.write.as_mut() { Some(w) => w(&path, &bytes).map(|_| json!({"path": path})), @@ -461,7 +461,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())), (None, _) => Err("cancelled".into()), diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index d1fac8be..2a7e91cb 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -38,7 +38,7 @@ fn app() -> VectorcraftApp { /// File → Place… with `path` chosen in the file picker. fn place_picked(app: &mut VectorcraftApp, path: &str) { let path = path.to_string(); - app.services.pick_open = Some(Box::new(move || Some(path.clone()))); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(path.clone()))); app.run("file.place", json!({})).unwrap(); } diff --git a/crates/ui-egui/src/tests_svg.rs b/crates/ui-egui/src/tests_svg.rs index a22bb068..0708677b 100644 --- a/crates/ui-egui/src/tests_svg.rs +++ b/crates/ui-egui/src/tests_svg.rs @@ -20,7 +20,7 @@ pub(super) fn app(picked: &str) -> (VectorcraftApp, Written) { w.borrow_mut().push((p.to_string(), b.to_vec())); Ok(()) })), - pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), + pick_save: Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))), ..Default::default() }; let mut app = VectorcraftApp::new(Session::new(), services); diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs index 7d5909af..7e80a39b 100644 --- a/crates/ui-egui/src/unsaved.rs +++ b/crates/ui-egui/src/unsaved.rs @@ -71,7 +71,7 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result { let i = i.ok_or("no such document")?; if !d.bool("discard") { app.session.set_active(i); - io::save(app, None, false, &Value::Null)?; + io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?; } close_now(app, i)?; match d.str("then").as_str() { @@ -91,7 +91,7 @@ mod tests { let written = Arc::new(Mutex::new(vec![])); let w = written.clone(); let services = crate::Services { - pick_save: Some(Box::new(|_: &str| Some("out.vectorcraft".to_string()))), + pick_save: Some(Box::new(|_: &crate::FilePick| Some("out.vectorcraft".to_string()))), write: Some(Box::new(move |p: &str, _: &[u8]| { w.lock().unwrap().push(p.to_string()); Ok(()) @@ -154,7 +154,7 @@ mod tests { #[test] fn a_cancelled_save_as_keeps_the_document_open() { let (mut app, _) = app(); - app.services.pick_save = Some(Box::new(|_: &str| None)); + app.services.pick_save = Some(Box::new(|_: &crate::FilePick| None)); new_doc(&mut app, true); close(&mut app, 0).unwrap(); assert!(confirm(&mut app).is_err()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2f33e97a..7a94c8c3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -22,6 +22,7 @@ | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | diff --git a/docs/mcp.md b/docs/mcp.md index 33b007eb..6c1d8d1e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -63,7 +63,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `open_panel` | `{panel}` | Remote only. | | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. | -| `save_file` | `{path?}` | Saves in the native `.vectorcraft` format. | +| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | @@ -139,6 +139,13 @@ hidden layers. A `preserveEditing` SVG carries the native document (CDATA in ` Date: Sun, 4 Oct 2026 18:20:27 +0700 Subject: [PATCH 2/4] M4.15: Revert as an engine command with a confirmation file.revert reads and decodes the saved file first: if that fails the document is left as it is, still modified. Otherwise Session::replace_document swaps the content in the same tab (same place, path, format and uid; history and selection cleared; saved state), so every tab keeps its zoom. It runs only for saved, modified documents. In the app, Revert asks first with the shared confirm dialog, whose OK runs file.revert {confirmed: true} on the engine. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/mod.rs | 2 +- crates/engine/src/cmd/fileio/save.rs | 35 +++++++++++-- crates/engine/src/lib.rs | 33 ++++++++++-- crates/engine/src/tests_save.rs | 40 ++++++++++++++- crates/ui-egui/src/io.rs | 10 ++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 14 +---- crates/ui-egui/src/tests_save.rs | 76 ++++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- docs/mcp.md | 2 +- 10 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 crates/ui-egui/src/tests_save.rs diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 897088c3..8f14dbe9 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -5,7 +5,7 @@ //! - `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 and the per-format options +//! - `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. //! - `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`. diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index d3288501..60183051 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` and -//! `file.formatOptions`. +//! Saving: `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `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 @@ -10,7 +10,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::Document; use super::super::*; -use super::{Encoded, Format, encode_all, file_stem, format, format_for_name, write_encoded, write_file}; +use super::{Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, write_encoded, write_file}; use crate::DocState; /// The formats Save As offers, in menu order (append-only). The other writable formats are exports: @@ -266,6 +266,26 @@ fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result { save_with(s, plan, write_file) } +fn can_revert(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + match (&st.path, st.is_dirty()) { + (None, _) => Err("the document has never been saved".into()), + (_, false) => Err("no changes since the last save".into()), + _ => Ok(()), + } +} + +/// File → Revert: read and decode the saved file first (on any failure the document is left as it +/// is), then replace the document in its tab. +fn revert(s: &mut Session, _: &Value) -> Result { + let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; + let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; + let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?; + doc.template = false; + s.replace_document(index, doc); + Ok(json!({ "path": path })) +} + /// `file.formatOptions`: a writable format's options with the values Save would use. fn format_options(s: &mut Session, p: &Value) -> Result { const C: &str = "file.formatOptions"; @@ -322,6 +342,15 @@ pub(super) fn specs() -> Vec { has_doc, |s, p| save(s, SaveMode::Template, p) ), + cmd!( + "file.revert", + "Revert", + ["File"], + Some("F12"), + "{} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path}. The app asks first (a confirm dialog) unless confirmed: true", + can_revert, + revert + ), cmd!( query "file.formatOptions", "Format Options", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 73627d70..90071e35 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -643,10 +643,9 @@ impl Session { self.untitled_counter += 1; format!("Untitled-{}", self.untitled_counter) } - /// Add a document and make it active. - pub fn add_document(&mut self, mut doc: Document, path: Option) -> usize { - // Text layout bounds are a cache (not saved): compute them now, or selection boxes and - // hit testing would use the rough estimate until each text object is edited. + /// Text layout bounds are a cache (not saved): compute them for a document just read, or + /// selection boxes and hit testing would use the rough estimate until each text is edited. + fn refresh_text_bounds(doc: &mut Document) { let mut texts = vec![]; doc.walk(|n| { if matches!(n.kind, NodeKind::Text(_)) { @@ -660,6 +659,10 @@ impl Session { cmd::typecmd::refresh_bounds(t); } } + } + /// Add a document and make it active. + pub fn add_document(&mut self, mut doc: Document, path: Option) -> usize { + Self::refresh_text_bounds(&mut doc); self.reset_tool_for_doc_switch(); let mut st = DocState::new(doc, path); st.history.limit = self.prefs.history_states as usize; @@ -668,6 +671,28 @@ impl Session { self.active = Some(i); i } + /// Replace the document in tab `index` (File → Revert): new content, cleared history and + /// selection, saved state. The tab keeps its place, path and format. + pub fn replace_document(&mut self, index: usize, mut doc: Document) -> bool { + if index >= self.docs.len() { + return false; + } + Self::refresh_text_bounds(&mut doc); + if self.active == Some(index) { + // Pending tool work (typing, a drag) belongs to the content being thrown away. + self.reset_tool_for_doc_switch(); + } + let old = &self.docs[index]; + let mut st = DocState::new(doc, old.path.clone()); + st.history.limit = old.history.limit; + // Same open document (caches keyed by uid stay valid); a new revision redraws it. + st.uid = old.uid; + st.revision = old.revision + 1; + st.format = old.format; + st.save_options = old.save_options.clone(); + self.docs[index] = st; + true + } pub fn close_document(&mut self, index: usize) -> bool { if index >= self.docs.len() { return false; diff --git a/crates/engine/src/tests_save.rs b/crates/engine/src/tests_save.rs index 08236c66..7bb8e688 100644 --- a/crates/engine/src/tests_save.rs +++ b/crates/engine/src/tests_save.rs @@ -1,4 +1,5 @@ -//! The save pipeline: Save writes a document's own format, Save As, a Copy and as Template. +//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template and +//! Revert. use std::path::PathBuf; @@ -33,6 +34,10 @@ fn b64(v: &Value) -> Vec { vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() } +fn objects(s: &Session) -> usize { + s.doc().unwrap().doc.node_count() +} + #[test] fn an_svg_path_writes_svg_and_save_writes_it_back() { let d = dir("svg"); @@ -123,6 +128,39 @@ fn save_a_copy_keeps_the_path_title_and_modified_state() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn revert_reloads_the_saved_file_in_the_same_tab() { + let d = dir("revert"); + let mut s = session(); + rect(&mut s); + let native = path(&d, "a.vectorcraft"); + s.execute("document.save", &json!({"path": native})).unwrap(); + assert!(s.execute("file.revert", &json!({})).is_err(), "nothing to revert"); + s.execute("file.new", &json!({})).unwrap(); + s.set_active(0); + let uid = s.doc().unwrap().uid; + rect(&mut s); + rect(&mut s); + assert_eq!(objects(&s), 4); + s.execute("file.revert", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!((s.active_index(), s.documents().len(), objects(&s)), (Some(0), 2, 2)); + assert!(!st.is_dirty() && st.history.undo.is_empty() && st.selection.is_empty()); + assert_eq!((st.uid, st.path.as_deref()), (uid, Some(native.as_str()))); + // With the file gone the document is left as it is, still modified. + rect(&mut s); + std::fs::remove_file(&native).unwrap(); + assert!(s.execute("file.revert", &json!({})).is_err()); + let st = s.doc().unwrap(); + assert!(st.is_dirty()); + assert_eq!((objects(&s), st.history.undo.len()), (3, 1)); + // Never saved: nothing to revert to. + s.set_active(1); + rect(&mut s); + assert!(s.execute("file.revert", &json!({})).is_err()); + 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/io.rs b/crates/ui-egui/src/io.rs index 524ffbdd..bf000a53 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -375,6 +375,16 @@ fn file_url(path: &str) -> String { url } +/// File → Revert: ask first; OK runs `file.revert` with `confirmed`, which goes to the engine. +pub fn ask_revert(app: &mut VectorcraftApp) -> Result { + let c = vectorcraft_engine::find_command("file.revert").ok_or("no revert command")?; + (c.enabled)(&app.session)?; + let name = app.session.active().map(|d| d.title()).unwrap_or_default(); + let message = format!("Revert to the saved version of “{name}”?"); + dialogs::confirm::ask(app, &message, "Changes made since it was last saved will be lost.", "file.revert", json!({ "confirmed": true })); + Ok(json!({ "pending": dialogs::confirm::KIND })) +} + /// Place a file's bytes (no path, so embedded) centred in the view: `file.place`. pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9402351a..bb1e65b0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,8 @@ mod tests_place; #[cfg(test)] mod tests_recolor; #[cfg(test)] +mod tests_save; +#[cfg(test)] mod tests_svg; #[cfg(test)] mod tests_svgsave; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 11beddfc..0d72f78a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -57,7 +57,6 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{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.revert", "Revert", "F12", "{}"), ( "file.place", "Place…", @@ -443,17 +442,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::open_path(app, &path).map(|_| Value::Null), None => io::open_dialog(app).map(|_| Value::Null), }, - "file.revert" => { - let path = app.session.active().and_then(|d| d.path.clone()); - match path { - Some(path) => { - let i = app.session.active_index().unwrap_or(0); - app.session.close_document(i); - io::open_path(app, &path).map(|_| Value::Null) - } - None => Err("document has never been saved".into()), - } - } + "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); match n.checked_sub(1).and_then(|i| app.ui.recent_files.get(i)).cloned() { @@ -948,7 +937,6 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { | "view.fitArtboard" | "view.fitAll" | "view.actualSize" => app.session.active().is_some(), - "file.revert" => app.session.active().is_some_and(|d| d.path.is_some() && d.is_dirty()), id if id.starts_with("file.openRecent") => { id["file.openRecent".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.ui.recent_files.len()) } diff --git a/crates/ui-egui/src/tests_save.rs b/crates/ui-egui/src/tests_save.rs new file mode 100644 index 00000000..dc92aa3a --- /dev/null +++ b/crates/ui-egui/src/tests_save.rs @@ -0,0 +1,76 @@ +//! The File menu around saving, in the app: Revert's confirmation. + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{Services, VectorcraftApp, dialogs, menus, theme}; + +/// An app that reads and writes real files. +fn app() -> VectorcraftApp { + let services = Services { + 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()))), + ..Default::default() + }; + VectorcraftApp::new(Session::new(), services) +} + +/// A fresh folder for one test. +fn dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-ui-save-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn rect(app: &mut VectorcraftApp) { + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.node_count() +} + +/// One headless frame of the dialog layer with Enter pressed (OK). +fn press_ok(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let key = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let mut out = ctx.run_ui(egui::RawInput { events: vec![key], ..Default::default() }, |ui| dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +#[test] +fn revert_asks_then_reloads_in_the_same_tab_keeping_every_zoom() { + let d = dir("revert"); + let path = d.join("a.vectorcraft").to_string_lossy().to_string(); + let mut app = app(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + rect(&mut app); + app.run("file.saveAs", json!({"path": path})).unwrap(); + assert!(!menus::enabled(&app, "file.revert"), "nothing changed since the save"); + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + app.views[0].zoom = 2.0; + app.views[1].zoom = 4.0; + app.session.set_active(0); + rect(&mut app); + assert!(menus::enabled(&app, "file.revert")); + let r = app.run("file.revert", json!({})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + assert_eq!(objects(&app), 3, "nothing happens before OK"); + press_ok(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!((objects(&app), app.session.active_index(), app.session.documents().len()), (2, Some(0), 2)); + assert!(!app.session.active().unwrap().is_dirty()); + assert_eq!((app.views[0].zoom, app.views[1].zoom), (2.0, 4.0)); + // Cancel leaves the changes. + rect(&mut app); + app.run("file.revert", json!({})).unwrap(); + app.ui.dialog = None; + assert_eq!(objects(&app), 3); + // An untitled document can't revert. + app.session.set_active(1); + rect(&mut app); + assert!(app.run("file.revert", json!({})).is_err() && app.ui.dialog.is_none()); + let _ = std::fs::remove_dir_all(d); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7a94c8c3..b159ed26 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -22,7 +22,7 @@ | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | -| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | diff --git a/docs/mcp.md b/docs/mcp.md index 6c1d8d1e..34bfd898 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -141,7 +141,7 @@ 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) and `file.formatOptions {format?}` (a format's options +`file.saveAsTemplate` (a `.vctemplate` that opens untitled), `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. From 70e2726321040f01b03e414e6629fc2f645ff111 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:23:56 +0700 Subject: [PATCH 3/4] M4.16: Template safety: Templates folder, " template", New from Template Save as Template suggests " template.vctemplate" in the Templates folder (the new templatesFolder preference; default Documents/VectorCraft Templates, created by the desktop save panel) and never the source path. New from Template is an engine command (file.newFromTemplate {path} or {name, dataBase64}) that opens any readable file as a new Untitled-N document; in the app, bytes from an agent go straight to the engine, otherwise the open dialog starts in the Templates folder with template types first. .ait and template files keep opening untitled. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/main.rs | 8 ++++- crates/engine/src/cmd/fileio/load.rs | 20 +++++++++-- crates/engine/src/cmd/fileio/mod.rs | 8 ++--- crates/engine/src/cmd/fileio/save.rs | 39 +++++++++++++++++----- crates/engine/src/cmd/prefscmds.rs | 2 ++ crates/engine/src/lib.rs | 4 +++ crates/engine/src/tests_save.rs | 38 +++++++++++++++++++-- crates/ui-egui/src/dialogs/save_options.rs | 3 +- crates/ui-egui/src/io.rs | 22 ++++++++++++ crates/ui-egui/src/menus.rs | 7 ++-- crates/ui-egui/src/tests_save.rs | 11 ++++++ docs/mcp.md | 3 +- 12 files changed, 141 insertions(+), 24 deletions(-) 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. From 489bb847554f799aa980fb9fce3f316564528d4c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:26:24 +0700 Subject: [PATCH 4/4] M4.17: File menu details: converted files, 30 recent files, one colour-mode command, Show in Folder, saved view Older native files (.drawcraft or an older format version) open as " [Converted]" and Save asks for a .vectorcraft name instead of overwriting them (preference appendConverted). Open Recent Files honours the 0-30 preference; empty slots are hidden (the native menu rebuilds when they, saved views or User Defined libraries change). File > Document Color Mode is one command, file.documentColorMode, converting through the colour settings; choosing the current mode is not an edit, and object.convertDocumentColorMode stays as an alias the command palette leaves out. New File > Show in Folder (file.reveal; paths with spaces work on Windows). Native files store the view they were saved with (Document.last_view, written at save time so it never dirties the document) and reopen there. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/main.rs | 31 +++++ apps/vectorcraft/src/native_menu.rs | 16 ++- crates/doc/src/lib.rs | 5 + crates/engine/src/cmd/colormgmt.rs | 97 +++++++++------ crates/engine/src/cmd/docmenu.rs | 47 +------ crates/engine/src/cmd/fileio/load.rs | 21 +++- crates/engine/src/cmd/fileio/save.rs | 28 +++-- .../engine/src/cmd/fileio/tests_pdfcolor.rs | 4 +- crates/engine/src/cmd/mod.rs | 9 ++ crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/lib.rs | 25 +++- crates/engine/src/tests_menucmds.rs | 6 +- crates/engine/src/tests_save.rs | 102 +++++++++++++++- crates/format/src/lib.rs | 37 +++++- crates/ui-egui/src/io.rs | 33 ++++- crates/ui-egui/src/lib.rs | 22 ++-- crates/ui-egui/src/menus.rs | 115 +++++++++++++----- crates/ui-egui/src/palette.rs | 23 ++-- crates/ui-egui/src/state.rs | 15 +++ crates/ui-egui/src/tests_save.rs | 85 ++++++++++++- docs/control-protocol.md | 9 +- docs/mcp.md | 2 + 22 files changed, 576 insertions(+), 157 deletions(-) diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 7141347c..d0a866cf 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -107,6 +107,36 @@ fn file_dialog(pick: &FilePick) -> rfd::FileDialog { if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) } } +/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere. +fn reveal(path: &str) -> Result<(), String> { + reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}")) +} + +#[cfg(target_os = "macos")] +fn reveal_command(path: &str) -> std::process::Command { + let mut c = std::process::Command::new("open"); + c.args(["-R", path]); + c +} + +#[cfg(windows)] +fn reveal_command(path: &str) -> std::process::Command { + use std::os::windows::process::CommandExt as _; + // Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces) + // and needs backslashes. + let mut c = std::process::Command::new("explorer"); + c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\"))); + c +} + +#[cfg(not(any(target_os = "macos", windows)))] +fn reveal_command(path: &str) -> std::process::Command { + let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new(".")); + let mut c = std::process::Command::new("xdg-open"); + c.arg(folder); + c +} + fn services() -> Services { Services { pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))), @@ -134,6 +164,7 @@ fn services() -> Services { open_url: Some(Box::new(|url: &str| { let _ = webbrowser::open(url); })), + reveal: Some(Box::new(reveal)), ..Default::default() } } diff --git a/apps/vectorcraft/src/native_menu.rs b/apps/vectorcraft/src/native_menu.rs index 8112b1ce..022b8dff 100644 --- a/apps/vectorcraft/src/native_menu.rs +++ b/apps/vectorcraft/src/native_menu.rs @@ -1,6 +1,7 @@ //! macOS: VectorCraft's menu tree as the native system menu bar (like Illustrator on the Mac). //! Items dispatch through the same command path as the in-window menus; enablement, check marks -//! and dynamic labels ("Undo Move") are refreshed a few times per second. +//! and dynamic labels ("Undo Move") are refreshed a few times per second, and the menu is rebuilt +//! when saved views or recent files come and go. use std::collections::HashMap; use std::str::FromStr; @@ -22,6 +23,8 @@ pub struct NativeMenu { last_refresh: f64, /// Shortcut-override / workspace-list generation the menu was built for. generation: u64, + /// Saved views and recent files listed (empty slots are left out, so a change rebuilds). + slots: usize, } /// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they @@ -53,7 +56,7 @@ impl NativeMenu { .filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some()) .map(|(_, _, _, c)| c.clone()) .collect(); - Self { _menu: menu, items, last_refresh: 0.0, generation } + Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app) } } /// Dispatch clicked items and refresh state. @@ -69,8 +72,11 @@ impl NativeMenu { return; } self.last_refresh = now; - // Shortcuts edited or workspaces added: rebuild so accelerators and lists are current. - if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation { + // Shortcuts edited, workspaces added, saved views or recent files changed: rebuild so + // accelerators and lists are current. + if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation + || menus::listed_slots(app) != self.slots + { *self = NativeMenu::install(app); return; } @@ -137,6 +143,8 @@ fn build( build(app, &sub, children, items, counter); let _ = parent.append(&sub); } + // Unused saved-view and recent-file slots are left out, as in the in-window menus. + Item::Cmd(_, cmd, _) if menus::hidden_slot(app, cmd) => {} Item::Cmd(label, cmd, params) => { *counter += 1; let id = format!("dc{counter}"); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 39013a0b..3607c7c1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -493,6 +493,10 @@ pub struct Document { /// ([`Document::raster_effects_ppi`]). #[serde(default, skip_serializing_if = "skip::is_default")] pub raster_effects: RasterEffectsSettings, + /// The view (zoom, centre, rotation) the document was saved with; it reopens there. Written at + /// save time only, so changing the view never marks the document modified. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_view: Option, } fn ppi72() -> f64 { @@ -552,6 +556,7 @@ impl Document { paste_remembers_layers: false, metadata: DocMetadata::default(), raster_effects: RasterEffectsSettings::default(), + last_view: None, }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index b7f28de8..2ffabbb4 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -51,9 +51,9 @@ pub fn specs() -> Vec { cmd!( "object.convertDocumentColorMode", "Convert Document Color Mode", - ["File", "Document Color Mode"], + [], None, - "{mode: \"cmyk\"|\"rgb\", intent?} set the document colour mode and convert every colour (art, symbols, swatches; swatch links kept; Gray and Lab colours stay) through the colour settings → {changed}", + "same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?} → {changed} (an alias kept for older scripts; the command palette leaves it out)", has_doc, convert_mode ), @@ -235,54 +235,75 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result { Ok(json!({"rgb": rgb, "cmyk": cmyk})) } -fn convert_mode(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.convertDocumentColorMode"; +/// File → Document Color Mode (`file.documentColorMode` and its alias +/// `object.convertDocumentColorMode`): set the mode and, unless `convert: false`, convert every +/// colour through the colour settings. +pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.documentColorMode"; let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() { Some("cmyk") => ColorMode::Cmyk, Some("rgb") => ColorMode::Rgb, _ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")), }; - let c = cms::active(); - let intent = intent_param(p, C)?.unwrap_or(c.settings().intent); - let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb }; - // Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour - // definitions) are device independent and fit either mode. - let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) }; + let intent = intent_param(p, C)?; + // Choosing the mode the document already has (the checked menu item) is not an edit. + if s.doc()?.doc.color_mode == mode { + return Ok(json!({ "changed": 0 })); + } + let convert = bool_or(p, "convert", true); let mut changed = 0usize; s.edit("Document Color Mode", |d, _| { - d.color_mode = mode; - proof::map_document_colors(d, &mut |col, _| { - let n = conv(col); - if n != *col { - changed += 1; - } - n - }); - for sw in d.swatches_iter_mut() { - match &mut sw.paint { - Paint::Solid { color, .. } => *color = conv(color), - Paint::Gradient(g) => { - for st in &mut g.gradient.stops { - st.color = conv(&st.color); - } - } - _ => {} - } - } - // Tints are their converted swatch's colour at their tint (exact ink percentages). - let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect(); - d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) { - Some((_, base)) if *tint < 1.0 => { - *c = base.tinted(*tint); - true - } - _ => false, - }); + changed = set_color_mode(d, mode, convert, intent); Ok(()) })?; Ok(json!({ "changed": changed })) } +/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols and +/// swatches through the colour settings (`intent`, default: theirs) → how many colours changed. +/// Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour +/// definitions) are device independent and fit either mode. Document Color Mode and +/// `document.open {colorMode}` both convert this way. +pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option) -> usize { + d.color_mode = mode; + if !convert { + return 0; + } + let c = cms::active(); + let intent = intent.unwrap_or(c.settings().intent); + let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb }; + let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) }; + let mut changed = 0usize; + proof::map_document_colors(d, &mut |col, _| { + let n = conv(col); + if n != *col { + changed += 1; + } + n + }); + for sw in d.swatches_iter_mut() { + match &mut sw.paint { + Paint::Solid { color, .. } => *color = conv(color), + Paint::Gradient(g) => { + for st in &mut g.gradient.stops { + st.color = conv(&st.color); + } + } + _ => {} + } + } + // Tints are their converted swatch's colour at their tint (exact ink percentages). + let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect(); + d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) { + Some((_, base)) if *tint < 1.0 => { + *c = base.tinted(*tint); + true + } + _ => false, + }); + changed +} + fn values(c: &Color) -> Vec { match *c { Color::Rgb { r, g, b } => vec![r, g, b], diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 4126557d..8bfd15f3 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -6,9 +6,8 @@ use std::collections::BTreeSet; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{CharStyle, ColorMode, Guide, Node, NodeId, NodeKind, TextKind}; +use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, TextKind}; -use super::colorcmds::{Recolor, Scope, to_cmyk}; use super::edit::selected_roots; use super::*; @@ -149,9 +148,9 @@ pub fn specs() -> Vec { "Document Color Mode", ["File", "Document Color Mode"], None, - "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour and swatch to the mode)}", + "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols and swatches through the colour settings; swatch links kept; greys stay greys), intent?} → {changed}", has_doc, - color_mode + super::colormgmt::convert_mode ), cmd!( "file.info", @@ -444,43 +443,3 @@ fn close_all(s: &mut Session, _: &Value) -> Result { } Ok(json!({ "closed": n })) } - -fn color_mode(s: &mut Session, p: &Value) -> Result { - const C: &str = "file.documentColorMode"; - let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() { - Some("cmyk") => ColorMode::Cmyk, - Some("rgb") => ColorMode::Rgb, - _ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")), - }; - let convert = bool_or(p, "convert", true); - if s.doc()?.doc.color_mode == mode { - return ok(); - } - s.edit("Document Color Mode", |d, _| { - set_color_mode(d, mode, convert); - Ok(()) - })?; - ok() -} - -/// Put `d` in colour `mode`; with `convert`, every colour of its art and swatches too. -pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool) { - d.color_mode = mode; - if !convert { - return; - } - let f = move |c: vectorcraft_color::Color| match mode { - ColorMode::Cmyk => to_cmyk(c), - ColorMode::Rgb => { - let [r, g, b] = c.to_rgb(); - vectorcraft_color::Color::rgb(r, g, b) - } - }; - let layers: Vec = d.layers.iter().map(|l| l.id).collect(); - Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers); - for sw in d.swatches_iter_mut() { - if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint { - *color = f(*color); - } - } -} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index f4947642..0a73afaf 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -18,6 +18,9 @@ pub struct Loaded { pub warnings: Vec, /// The document is the native one the file carries (SVG or PDF saved with Preserve Editing). pub restored: bool, + /// An older native file (the former `.drawcraft` name or format version): saving it again + /// rewrites it in today's format. + pub converted: bool, } /// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG @@ -113,8 +116,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?; let title = file_name(name); + let mut converted = false; let (mut doc, warnings, restored) = match format.id { - "vectorcraft" | "template" => (vectorcraft_format::load(bytes).map_err(err)?, vec![], false), + "vectorcraft" | "template" => { + let (d, info) = vectorcraft_format::load_info(bytes).map_err(err)?; + converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION; + (d, vec![], false) + } "svg" | "svgz" => { let text = vectorcraft_svg::text_of(bytes).map_err(err)?; let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); @@ -129,14 +137,14 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result _ => return Err(err(format!("{} files can't be opened yet", format.label))), }; if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { - super::super::docmenu::set_color_mode(&mut doc, mode, true); + super::super::colormgmt::set_color_mode(&mut doc, mode, true, None); } // Imports are named after the file; a native document keeps its own title (the tab shows the // file name once it has a path). if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() { doc.title = title; } - Ok(Loaded { doc, format, warnings, restored }) + Ok(Loaded { doc, format, warnings, restored, converted }) } /// Open a file's bytes as the new active document (what `document.open` does) → @@ -162,7 +170,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st /// 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 Loaded { mut doc, format, warnings, restored, converted } = 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. @@ -178,10 +186,13 @@ fn open_loaded(s: &mut Session, loaded: Loaded, path: Option, opts: &Loa let lossy_ai = format.id == "ai" && !restored; let path = path.filter(|_| !template && !partial && !lossy_ai && SAVE_FORMATS.contains(&format.id)); let saves_back = path.is_some(); + let converted = saves_back && converted && s.prefs.append_converted; let index = s.add_document(doc, path); + let st = s.doc_mut()?; if saves_back { - s.doc_mut()?.format = format.id; + st.format = format.id; } + st.converted = converted; let title = s.documents()[index].title(); Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json())) } diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 57a9519f..5009f7ce 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -6,6 +6,8 @@ //! the caller's writer (the file system here; a save panel or a browser download in the apps) and //! makes the file the document's own (path, format, options, saved state) for Save and Save As. +use std::borrow::Cow; + use serde_json::{Map, Value, json}; use vectorcraft_doc::Document; @@ -62,8 +64,8 @@ impl SaveMode { #[derive(Clone, Debug)] pub struct SavePlan { pub mode: SaveMode, - /// Where to write. `None` when no path is known (never saved, or a format other than the - /// document's own): the caller asks for one or takes the bytes. + /// Where to write. `None` when no path is known (never saved, converted from an older version, + /// or a format other than the document's own): the caller asks for one or takes the bytes. pub path: Option, pub format: &'static Format, /// The format's options (only those its encoder reads). @@ -159,7 +161,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { // None given: the ones the document was last saved with in this format. let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given }; // Save writes the document's own file; the other modes only where they are told to. - let path = path.or_else(|| (mode == SaveMode::Save && format.id == st.format).then(|| st.path.clone()).flatten()); + let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && format.id == st.format).then(|| st.path.clone()).flatten()); let stem = file_stem(st.path.as_deref().unwrap_or(&st.doc.title)); let ext = format.extensions[0]; let name = match mode { @@ -197,6 +199,17 @@ fn is_svg(f: &Format) -> bool { matches!(f.id, "svg" | "svgz") } +/// The document as written: native files carry the view to reopen at. +fn doc_to_save<'a>(st: &'a DocState, f: &Format) -> Cow<'a, Document> { + if is_native(f) && st.doc.last_view != st.view { + let mut d = (*st.doc).clone(); + d.last_view = st.view.clone(); + Cow::Owned(d) + } else { + Cow::Borrowed(&st.doc) + } +} + /// What a format loses against a native file (reported whenever a save writes it). fn fidelity_warning(f: &Format) -> Option { (!is_native(f)).then(|| { @@ -218,10 +231,10 @@ pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[ if plan.path.is_some() && retargets { stamp_save_dates(s.doc_mut()?); } - let own: &Document = &s.doc()?.doc; + let own = doc_to_save(s.doc()?, plan.format); // A native file records its links' paths relative to where it is written. - let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(own, p)); - let doc = relative.as_ref().unwrap_or(own); + let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p)); + let doc: &Document = relative.as_ref().unwrap_or(&own); let mut params = plan.options.clone(); if is_svg(plan.format) { // A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out. @@ -269,6 +282,7 @@ pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[ st.path = Some(path.clone()); st.format = plan.format.id; st.save_options = plan.options; + st.converted = false; st.mark_saved(); } Ok(out) @@ -325,7 +339,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/tests_pdfcolor.rs b/crates/engine/src/cmd/fileio/tests_pdfcolor.rs index b5b3c651..853a4446 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfcolor.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfcolor.rs @@ -49,7 +49,9 @@ fn color_mode_opens_the_file_in_the_mode_asked_for() { s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf(), "colorMode": "RGB"})).unwrap(); assert_eq!(s.active().unwrap().doc.color_mode, ColorMode::Rgb); let f = fills(&s); - assert!(f.iter().all(|p| matches!(p.color(), Some(Color::Rgb { .. }))), "converted as Document Color Mode does: {f:?}"); + // Converted as Document Color Mode does: no CMYK left, greys stay grey. + assert!(f.iter().all(|p| matches!(p.color(), Some(Color::Rgb { .. } | Color::Gray { .. }))), "{f:?}"); + assert!(matches!(f[0].color(), Some(Color::Rgb { .. })) && matches!(f[1].color(), Some(Color::Gray { .. })), "{f:?}"); // Any format opens in the mode asked for. let mut svg = Session::new(); svg.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4f0fbd4b..f0a11042 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -394,3 +394,12 @@ pub use flatten::FlattenOptions; /// A built-in or saved flattener preset ([`crate::Prefs::flattener_presets`]). pub use flatten::FlattenerPreset; + +/// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`. +/// They run as before but the command palette leaves them out (it lists the command once). +pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")]; + +/// Is `id` an [`ALIASES`] entry? +pub fn is_alias(id: &str) -> bool { + ALIASES.iter().any(|(a, _)| *a == id) +} diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index aa5242b0..d546717e 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -317,6 +317,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("newArtBasic", "General", "Appearance Panel", "New Art Has Basic Appearance", bool), // File Handling (continued) p!("templatesFolder", "File Handling", "Files", "Templates Folder", text), + p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool), ]; pub fn spec(key: &str) -> Option<&'static PrefSpec> { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 50599b78..6f95c64d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -118,13 +118,21 @@ pub struct DocState { /// That format's options as last saved (SVG options for SVG, the Save PDF settings for PDF; /// empty for native files): Save reuses them and `file.formatOptions` reads them back. pub save_options: serde_json::Map, + /// Opened from an older native file (former name or format version): the title says + /// "[Converted]" and Save asks for a new name instead of overwriting it. + pub converted: bool, + /// The view saved into native files (`Document::last_view`); the UI keeps it current before a + /// save and restores it when the document opens. + pub view: Option, } static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); impl DocState { - pub fn new(doc: Document, path: Option) -> Self { + pub fn new(mut doc: Document, path: Option) -> Self { let active_layer = doc.default_layer(); + // The saved view lives here while the document is open (saves write it back). + let view = doc.last_view.take(); let doc = Arc::new(doc); Self { saved_doc: doc.clone(), @@ -143,6 +151,8 @@ impl DocState { transparency_grid: false, format: "vectorcraft", save_options: Default::default(), + converted: false, + view, } } /// Unsaved changes: the document differs from the saved one (selection changes don't count). @@ -154,11 +164,13 @@ impl DocState { self.saved_doc = self.doc.clone(); } pub fn title(&self) -> String { - self.path + let name = self + .path .as_deref() .and_then(|p| std::path::Path::new(p).file_name()) .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| self.doc.title.clone()) + .unwrap_or_else(|| self.doc.title.clone()); + if self.converted { format!("{} [Converted]", cmd::fileio::file_stem(&name)) } else { name } } /// Where new art is inserted: the isolation container, else the active layer. /// The current layer, if the remembered id still names a layer (ids are reused after undo). @@ -375,6 +387,8 @@ pub struct Prefs { // File Handling (continued) /// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`). pub templates_folder: String, + /// Open older native files as " [Converted]" so Save asks for a new name. + pub append_converted: bool, } impl Default for Prefs { @@ -502,6 +516,7 @@ impl Default for Prefs { recent_new_docs: vec![], pdf_presets: vec![], templates_folder: String::new(), + append_converted: true, } } } @@ -676,7 +691,7 @@ impl Session { i } /// Replace the document in tab `index` (File → Revert): new content, cleared history and - /// selection, saved state. The tab keeps its place, path and format. + /// selection, saved state. The tab keeps its place, path, format and view. pub fn replace_document(&mut self, index: usize, mut doc: Document) -> bool { if index >= self.docs.len() { return false; @@ -694,6 +709,8 @@ impl Session { st.revision = old.revision + 1; st.format = old.format; st.save_options = old.save_options.clone(); + st.converted = old.converted; + st.view = old.view.clone(); self.docs[index] = st; true } diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 832385cf..aa5e9440 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -1,7 +1,7 @@ //! Menu long-tail commands (Object/Edit/Select/Type/View/File), driven through `Session::execute`. use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::Color; use vectorcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind}; use vectorcraft_geom::Rect; @@ -672,7 +672,9 @@ fn document_color_mode_converts_colours() { fill(&mut s, a, "#ff0000"); s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap(); assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Cmyk); - assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0))); + // Converted through the colour settings: a press red, mostly magenta and yellow. + let Some(Color::Cmyk { c, m, y, k }) = node(&s, a).appearance.fill_paint().color() else { panic!("not CMYK") }; + assert!(m > 0.8 && y > 0.8 && c < 0.05 && k < 0.05, "{c} {m} {y} {k}"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Rgb); assert!(s.execute("file.documentColorMode", &json!({"mode": "lab"})).is_err()); diff --git a/crates/engine/src/tests_save.rs b/crates/engine/src/tests_save.rs index af9445e7..4c61d0e5 100644 --- a/crates/engine/src/tests_save.rs +++ b/crates/engine/src/tests_save.rs @@ -1,9 +1,11 @@ -//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template, Revert -//! and templates. +//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template, Revert, +//! templates, converted legacy files, the saved view and the single Document Color Mode command. use std::path::PathBuf; use serde_json::{Value, json}; +use vectorcraft_doc::SavedView; +use vectorcraft_geom::Point; use super::*; use crate::cmd::fileio::{SaveMode, save_plan}; @@ -195,6 +197,69 @@ fn templates_suggest_a_name_in_the_templates_folder_and_open_untitled() { let _ = std::fs::remove_dir_all(d); } +/// A native file as an older version wrote it: under the former name, or format version 1. +fn old_file(legacy: bool) -> Vec { + let text = String::from_utf8(vectorcraft_format::save(&vectorcraft_doc::Document::new(50.0, 50.0), false)).unwrap(); + let text = if legacy { + text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1) + } else { + text.replacen(&format!("\"version\":{}", vectorcraft_format::VERSION), "\"version\":1", 1) + }; + text.into_bytes() +} + +#[test] +fn older_native_files_open_converted_and_save_under_a_new_name() { + let d = dir("converted"); + let legacy = path(&d, "logo.drawcraft"); + std::fs::write(&legacy, old_file(true)).unwrap(); + let v1 = path(&d, "badge.vectorcraft"); + std::fs::write(&v1, old_file(false)).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": legacy})).unwrap(); + assert_eq!(s.doc().unwrap().title(), "logo [Converted]"); + // Save doesn't overwrite the old file: it asks for a .vectorcraft name. + let r = s.execute("document.save", &json!({})).unwrap(); + assert_eq!((r["name"].as_str(), r.get("path")), (Some("logo.vectorcraft"), None)); + assert_eq!(std::fs::read(&legacy).unwrap(), old_file(true)); + let r = s.execute("document.open", &json!({"path": v1})).unwrap(); + assert_eq!(r["title"], "badge [Converted]"); + let saved = path(&d, "badge2.vectorcraft"); + s.execute("file.saveAs", &json!({"path": saved})).unwrap(); + assert_eq!(s.doc().unwrap().title(), "badge2.vectorcraft"); + assert!(!s.doc().unwrap().converted); + // A current file isn't converted; with the preference off old files open as they are. + s.execute("document.open", &json!({"path": saved})).unwrap(); + assert_eq!(s.doc().unwrap().title(), "badge2.vectorcraft"); + s.prefs.append_converted = false; + s.execute("document.open", &json!({"path": legacy})).unwrap(); + assert_eq!(s.doc().unwrap().title(), "logo.drawcraft"); + assert_eq!(s.execute("document.save", &json!({})).unwrap()["path"].as_str(), Some(legacy.as_str())); + let _ = std::fs::remove_dir_all(d); +} + +#[test] +fn the_saved_view_round_trips_without_dirtying() { + let d = dir("view"); + let mut s = session(); + let view = SavedView { name: String::new(), center: Point::new(40.0, 25.0), zoom: 3.0, rotation: 15.0 }; + s.doc_mut().unwrap().view = Some(view.clone()); + assert!(!s.doc().unwrap().is_dirty(), "the view isn't an edit"); + let native = path(&d, "v.vectorcraft"); + s.execute("document.save", &json!({"path": native})).unwrap(); + assert_eq!(s.doc().unwrap().doc.last_view, None, "only the written file carries it"); + s.execute("document.open", &json!({"path": native})).unwrap(); + assert_eq!(s.doc().unwrap().view.as_ref(), Some(&view)); + // Files without a view (older ones) open with none; SVG doesn't carry it. + let doc = vectorcraft_format::load(&vectorcraft_format::save(&vectorcraft_doc::Document::new(5.0, 5.0), false)).unwrap(); + assert_eq!(doc.last_view, None); + let svg = path(&d, "v.svg"); + s.execute("document.save", &json!({"path": svg})).unwrap(); + s.execute("document.open", &json!({"path": svg})).unwrap(); + assert_eq!(s.doc().unwrap().view, None); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn save_plans_suggest_names_and_folders() { let mut s = session(); @@ -208,6 +273,39 @@ fn save_plans_suggest_names_and_folders() { assert_eq!(crate::cmd::fileio::save_filters("svg")[0], ("SVG", &["svg"][..])); } +#[test] +fn document_color_mode_is_one_command_with_a_hidden_alias() { + let mut s = session(); + let ids: Vec<&str> = s.commands().iter().filter(|c| c.menu.first() == Some(&"File") && c.label.contains("Color Mode")).map(|c| c.id).collect(); + assert_eq!(ids, ["file.documentColorMode"], "one File menu entry"); + assert!(crate::cmd::is_alias("object.convertDocumentColorMode") && !crate::cmd::is_alias("file.documentColorMode")); + // Both ids run the same conversion; `convert: false` only switches the mode. + rect(&mut s); + assert!(s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap()["changed"].as_u64().unwrap() >= 1); + let before = s.doc().unwrap().doc.clone(); + let r = s.execute("file.documentColorMode", &json!({"mode": "rgb", "convert": false})).unwrap(); + assert_eq!(r["changed"], 0); + assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb); + assert_eq!(s.doc().unwrap().doc.layers, before.layers, "colours untouched"); + let e = s.execute("file.documentColorMode", &json!({"mode": "lab"})).unwrap_err().to_string(); + assert!(e.contains("file.documentColorMode"), "{e}"); +} + +#[test] +fn choosing_the_current_color_mode_is_not_an_edit() { + let mut s = session(); + rect(&mut s); + let st = s.doc_mut().unwrap(); + st.mark_saved(); + let undo = st.history.undo.len(); + for id in ["file.documentColorMode", "object.convertDocumentColorMode"] { + assert_eq!(s.execute(id, &json!({"mode": "rgb"})).unwrap()["changed"], 0); + } + let st = s.doc().unwrap(); + assert!(!st.is_dirty(), "the document stays saved"); + assert_eq!(st.history.undo.len(), undo, "no empty undo step"); +} + #[test] fn a_pdf_opened_in_part_is_not_saved_back_over_the_whole_file() { let d = dir("pdfpart"); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 6f2aa4c6..7bf8a7bc 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -100,6 +100,27 @@ pub fn save_file(doc: &Document) -> Vec { } pub fn load(bytes: &[u8]) -> Result { + load_info(bytes).map(|(doc, _)| doc) +} + +/// What a native file says about itself besides its document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FileInfo { + /// The file's format version (see [`VERSION`]). + pub version: u32, + /// Written under the project's former name (`"format": "drawcraft"`). + pub legacy: bool, +} + +impl FileInfo { + /// Written by an older version or under the former name: saving rewrites it in today's format. + pub fn is_old(&self) -> bool { + self.legacy || self.version < VERSION + } +} + +/// [`load`], also telling the file's version and name. +pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> { let f: File = serde_json::from_slice(bytes).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; if f.format != EXTENSION && f.format != LEGACY_EXTENSION { return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format))); @@ -122,7 +143,7 @@ pub fn load(bytes: &[u8]) -> Result { // Saved before per-fill/stroke overprint: the Overprint Black list becomes overprint flags. doc.migrate_overprint_black(); doc.fix_next_id(); - Ok(doc) + Ok((doc, FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION })) } /// Does this look like a `.vectorcraft` file? @@ -188,6 +209,20 @@ mod tests { assert!(is_native_name("old/Poster.DrawCraft") && is_native_name("new.vectorcraft") && !is_native_name("x.svg")); assert!(load(br#"{"format":"other","version":1,"document":{}}"#).is_err()); } + + #[test] + fn file_info_tells_old_files() { + let d = Document::new(10.0, 10.0); + let current = save(&d, false); + assert_eq!(load_info(¤t).unwrap().1, FileInfo { version: VERSION, legacy: false }); + assert!(!load_info(¤t).unwrap().1.is_old()); + let text = String::from_utf8(current).unwrap(); + let legacy = text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1); + assert!(load_info(legacy.as_bytes()).unwrap().1.is_old()); + let v1 = text.replacen(&format!("\"version\":{VERSION}"), "\"version\":1", 1); + assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false }); + assert!(load_info(v1.as_bytes()).unwrap().1.is_old()); + } use vectorcraft_doc::{Appearance, Node}; use vectorcraft_geom::{Rect, shapes}; diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index e0c032b2..83fcd946 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -1,7 +1,8 @@ -//! File I/O through the injected services (pick, read, write, download); the engine's +//! File I/O through the injected services (pick, read, write, download, reveal); the engine's //! `fileio` decodes, encodes and saves every format. use serde_json::{Value, json}; +use vectorcraft_doc::SavedView; use vectorcraft_engine::EngineError; use vectorcraft_engine::cmd::fileio::{self, Format, SAVE_FORMATS, SaveMode, SavePlan}; @@ -141,6 +142,14 @@ pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: & pick_path(app, &FilePick { name, folder, filters }) } +/// Keep the active document's saved view current before a save (native files reopen at it). +pub fn remember_view(app: &mut VectorcraftApp) { + let Some(v) = app.view().copied().filter(|v| v.fitted) else { return }; + if let Some(st) = app.session.active_mut() { + st.view = Some(SavedView { name: String::new(), center: v.center, zoom: v.zoom, rotation: v.rotation }); + } +} + /// A path typed in a save panel: kept when its extension names a save format (the panel's file /// type), else `f`'s extension is added. fn with_save_extension(path: &str, f: &Format) -> String { @@ -174,6 +183,7 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result, /// path?}` while a dialog is open. pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { + remember_view(app); let first = plan(app, mode, p)?; let ask = ask_options && !has_options(p); if let Some(path) = first.path.clone() { @@ -288,12 +298,22 @@ fn open_options(app: &mut VectorcraftApp, action: &str, f: &Format, path: &str, json!({ "pending": dialogs::save_options::KIND }) } -/// Put `path` at the top of File → Open Recent Files (capped by Preferences → Recent Files). +/// The most recent files remembered: Preferences → File Handling → Number of Recent Files to Display +/// shows 0 to this many of them. +pub const MAX_RECENT_FILES: usize = 30; + +/// Put `path` at the top of File → Open Recent Files. pub fn note_recent(app: &mut VectorcraftApp, path: &str) { let r = &mut app.ui.recent_files; r.retain(|p| p != path); r.insert(0, path.to_string()); - r.truncate((app.session.prefs.recent_files_count as usize).clamp(1, 10)); + r.truncate(MAX_RECENT_FILES); +} + +/// The recent files File → Open Recent Files lists. +pub fn recent_files(app: &VectorcraftApp) -> &[String] { + let r = &app.ui.recent_files; + &r[..r.len().min(app.session.prefs.recent_files_count as usize)] } /// Export the active document in `format` (default: the path's extension, else PNG) with the @@ -407,6 +427,13 @@ pub fn ask_revert(app: &mut VectorcraftApp) -> Result { Ok(json!({ "pending": dialogs::confirm::KIND })) } +/// File → Show in Folder: the document's file in the system file manager. +pub fn reveal(app: &mut VectorcraftApp) -> Result { + let path = app.session.active().and_then(|d| d.path.clone()).ok_or("the document has never been saved")?; + app.services.reveal.as_mut().ok_or("no file manager here")?(&path)?; + Ok(json!({ "path": path })) +} + /// Place a file's bytes (no path, so embedded) centred in the view: `file.place`. pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb1e65b0..7f6d75bc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -65,6 +65,7 @@ use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio; use vectorcraft_engine::{Session, ViewInfo}; pub use control::{ControlRequest, ControlResponse}; @@ -98,6 +99,9 @@ pub type DownloadFn = Box; /// Opens a URL in the system browser. pub type OpenUrlFn = Box; +/// Shows a file in the system file manager. +pub type RevealFn = Box Result<(), String>>; + /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { @@ -129,6 +133,8 @@ pub struct Services { /// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through /// egui and `clipboard_read`). pub system_clipboard: Option>, + /// File → Show in Folder: select a file in the system file manager (desktop). + pub reveal: Option, } /// Cached canvas raster. @@ -221,7 +227,7 @@ const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; impl VectorcraftApp { pub fn new(session: Session, services: Services) -> Self { - let views = session.documents().iter().map(|_| View::default()).collect(); + let views = session.documents().iter().map(View::of).collect(); Self { session, ui: UiState::default(), @@ -268,13 +274,11 @@ impl VectorcraftApp { self } - /// Keep `views` aligned with the session's documents. + /// Keep `views` aligned with the session's documents (a new one starts at its saved view). pub fn sync_views(&mut self) { - let n = self.session.documents().len(); - while self.views.len() < n { - self.views.push(View::default()); - } - self.views.truncate(n); + let docs = self.session.documents(); + self.views.truncate(docs.len()); + self.views.extend(docs.iter().skip(self.views.len()).map(View::of)); } pub fn view(&self) -> Option<&View> { @@ -322,6 +326,10 @@ impl VectorcraftApp { return r; } } + // Native files carry the view they reopen at. + if fileio::SaveMode::of(id).is_some() { + io::remember_view(self); + } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { self.publish_clipboard(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b0069464..2ed6ee41 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -54,7 +54,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.save", "Save", "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", + "{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, converted) the Save As panel asks first", ), ( "file.place", @@ -404,6 +404,27 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets", ), + ("file.openRecent11", "Open Recent File 11", "", "{}"), + ("file.openRecent12", "Open Recent File 12", "", "{}"), + ("file.openRecent13", "Open Recent File 13", "", "{}"), + ("file.openRecent14", "Open Recent File 14", "", "{}"), + ("file.openRecent15", "Open Recent File 15", "", "{}"), + ("file.openRecent16", "Open Recent File 16", "", "{}"), + ("file.openRecent17", "Open Recent File 17", "", "{}"), + ("file.openRecent18", "Open Recent File 18", "", "{}"), + ("file.openRecent19", "Open Recent File 19", "", "{}"), + ("file.openRecent20", "Open Recent File 20", "", "{}"), + ("file.openRecent21", "Open Recent File 21", "", "{}"), + ("file.openRecent22", "Open Recent File 22", "", "{}"), + ("file.openRecent23", "Open Recent File 23", "", "{}"), + ("file.openRecent24", "Open Recent File 24", "", "{}"), + ("file.openRecent25", "Open Recent File 25", "", "{}"), + ("file.openRecent26", "Open Recent File 26", "", "{}"), + ("file.openRecent27", "Open Recent File 27", "", "{}"), + ("file.openRecent28", "Open Recent File 28", "", "{}"), + ("file.openRecent29", "Open Recent File 29", "", "{}"), + ("file.openRecent30", "Open Recent File 30", "", "{}"), + ("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -440,13 +461,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option 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); - match n.checked_sub(1).and_then(|i| app.ui.recent_files.get(i)).cloned() { - Some(path) => io::open_path(app, &path).map(|_| Value::Null), - None => Err("no such recent file".into()), - } - } + "file.reveal" => io::reveal(app), + id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() { + Some(path) => io::open_path(app, &path).map(|_| Value::Null), + None => Err("no such recent file".into()), + }, "type.findFont" => { crate::find_font::open(app); Ok(Value::Null) @@ -455,7 +474,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(json!(app.ui.recent_files)), + "file.recentFiles" => Ok(json!(io::recent_files(app))), "file.place" => crate::place::run(app, p), "file.place.queue" => crate::place::queue(app, p), "file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => { @@ -856,13 +875,9 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { "view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(), "view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(), "view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(), - id if id.starts_with("file.openRecent") => { - let n: usize = id["file.openRecent".len()..].parse().unwrap_or(0); - n.checked_sub(1) - .and_then(|i| app.ui.recent_files.get(i)) - .map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string())) - .unwrap_or_else(|| "—".into()) - } + id if id.starts_with("file.openRecent") => recent_slot(app, id) + .map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string())) + .unwrap_or_else(|| "—".into()), "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), id if id.starts_with(crate::panels::swatches::USER_SLOT) => { @@ -875,14 +890,17 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { } } -/// Menu slots that are left out while they have nothing to show (unused saved views, User -/// Defined swatch and graphic style libraries). +/// Menu slots that are left out while they have nothing to show (unused saved views, recent files, +/// User Defined swatch and graphic style libraries). fn hidden_when_disabled(id: &str) -> bool { - id.starts_with("view.goto") || id.starts_with(crate::panels::swatches::USER_SLOT) || id.starts_with(crate::panels::graphic_styles::USER_SLOT) + id.starts_with("view.goto") + || id.starts_with("file.openRecent") + || id.starts_with(crate::panels::swatches::USER_SLOT) + || id.starts_with(crate::panels::graphic_styles::USER_SLOT) } -/// File → Open Recent Files slots. -const RECENT_IDS: [&str; 10] = [ +/// File → Open Recent Files slots (Preferences → File Handling shows 0–30 of them). +const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [ "file.openRecent1", "file.openRecent2", "file.openRecent3", @@ -893,8 +911,49 @@ const RECENT_IDS: [&str; 10] = [ "file.openRecent8", "file.openRecent9", "file.openRecent10", + "file.openRecent11", + "file.openRecent12", + "file.openRecent13", + "file.openRecent14", + "file.openRecent15", + "file.openRecent16", + "file.openRecent17", + "file.openRecent18", + "file.openRecent19", + "file.openRecent20", + "file.openRecent21", + "file.openRecent22", + "file.openRecent23", + "file.openRecent24", + "file.openRecent25", + "file.openRecent26", + "file.openRecent27", + "file.openRecent28", + "file.openRecent29", + "file.openRecent30", ]; +/// The recent file a `file.openRecentN` slot names (none past the preference's count). +fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a String> { + let n: usize = id.strip_prefix("file.openRecent")?.parse().ok()?; + io::recent_files(app).get(n.checked_sub(1)?) +} + +/// A menu slot hidden while it is empty ([`hidden_when_disabled`]) rather than shown disabled (the +/// native menu leaves it out). +pub fn hidden_slot(app: &VectorcraftApp, id: &str) -> bool { + hidden_when_disabled(id) && !enabled(app, id) +} + +/// How many saved-view, recent-file and User Defined library slots are listed: a menu that can't +/// hide items (the native one) is rebuilt when this changes. +pub fn listed_slots(app: &VectorcraftApp) -> usize { + use vectorcraft_engine::cmd::{stylelib, swatchlib}; + let user = |libs: Vec| libs.iter().filter(|l| l.category == "user").count().min(10); + let views = app.session.active().map_or(0, |d| d.doc.views.len().min(10)); + views + io::recent_files(app).len().min(RECENT_IDS.len()) + user(swatchlib::libraries(&app.session)) + user(stylelib::libraries(&app.session)) +} + /// Type → Recent Fonts slots. const RECENT_FONT_IDS: [&str; 10] = [ "type.recentFont1", @@ -922,7 +981,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { } match id { // Save is off for a clean document that already has its own file. - "file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.is_dirty()), + "file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()), + "file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()), "file.place" | "file.export.svg" | "file.export.png" @@ -934,9 +994,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { | "view.fitArtboard" | "view.fitAll" | "view.actualSize" => app.session.active().is_some(), - id if id.starts_with("file.openRecent") => { - id["file.openRecent".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.ui.recent_files.len()) - } + id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(), "file.clearRecent" => !app.ui.recent_files.is_empty(), id if id.starts_with("type.recentFont") => { id["type.recentFont".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.ui.recent_fonts.len()) && app.session.active().is_some() @@ -990,6 +1048,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { v.push(c("Clear Recent Files", "file.clearRecent")); v }), + c("Show in Folder", "file.reveal"), Sep, c("Close", "file.close"), c("Close All", "file.closeAll"), @@ -1021,8 +1080,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub( "Document Color Mode", vec![ - cp("CMYK Color", "object.convertDocumentColorMode", json!({"mode": "cmyk"})), - cp("RGB Color", "object.convertDocumentColorMode", json!({"mode": "rgb"})), + cp("CMYK Color", "file.documentColorMode", json!({"mode": "cmyk"})), + cp("RGB Color", "file.documentColorMode", json!({"mode": "rgb"})), ], ), c("File Info…", "file.info"), @@ -1635,7 +1694,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked } Item::Cmd(label, id, p) => { let en = enabled(app, id); - // Unused saved-view slots are hidden (Illustrator lists only the saved views). + // Unused saved-view and recent-file slots are hidden (only the real ones are listed). if !en && hidden_when_disabled(id) { continue; } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index cd4ce845..31ac6527 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -5,14 +5,11 @@ use serde_json::json; use crate::theme::Tokens; use crate::{VectorcraftApp, menus}; -pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { - if !app.ui.palette_open { - return; - } - let t = Tokens::get(ctx); - let q = app.ui.palette_query.to_lowercase(); - let mut items: Vec<(String, String, String)> = vec![]; // (label, kind/id, shortcut) - for c in vectorcraft_engine::command_specs() { +/// Everything the palette searches: (label, command id or `tool:`, shortcut). +pub fn items() -> Vec<(String, String, String)> { + let mut items = vec![]; + // Aliases kept for older scripts run a command that is listed already. + for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)) { let path = c.menu.join(" › "); let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) }; items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string())); @@ -23,6 +20,16 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { for tool in vectorcraft_tools::catalog::all_tools() { items.push((tool.label.to_string(), format!("tool:{}", tool.id), crate::shortcut_editor::tool_shortcut(tool.id).unwrap_or("").to_string())); } + items +} + +pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { + if !app.ui.palette_open { + return; + } + let t = Tokens::get(ctx); + let q = app.ui.palette_query.to_lowercase(); + let items = items(); let matches: Vec<&(String, String, String)> = items .iter() .filter(|(l, id, _)| q.is_empty() || q.split_whitespace().all(|w| l.to_lowercase().contains(w) || id.to_lowercase().contains(w))) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e3884caf..993414b8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -25,6 +25,21 @@ impl Default for View { } } +impl View { + /// The view a document opens at: the one it was saved with, else fitted on first display. + pub fn of(st: &vectorcraft_engine::DocState) -> Self { + match &st.view { + Some(v) if v.zoom.is_finite() && v.zoom > 0.0 && v.center.x.is_finite() && v.center.y.is_finite() => Self { + zoom: v.zoom.clamp(0.0313, 640.0), + center: v.center, + fitted: true, + rotation: if v.rotation.is_finite() { v.rotation } else { 0.0 }, + }, + _ => Self::default(), + } + } +} + /// Illustrator's preset zoom stops, in percent. pub const ZOOM_STOPS: [f64; 26] = [ 3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0, diff --git a/crates/ui-egui/src/tests_save.rs b/crates/ui-egui/src/tests_save.rs index 976df367..39826283 100644 --- a/crates/ui-egui/src/tests_save.rs +++ b/crates/ui-egui/src/tests_save.rs @@ -1,9 +1,14 @@ -//! The File menu around saving, in the app: Revert's confirmation. +//! The File menu around saving, in the app: Revert's confirmation, recent files, Show in Folder, +//! the single Document Color Mode entry and documents reopening at their saved view. + +use std::cell::RefCell; +use std::rc::Rc; use serde_json::json; use vectorcraft_engine::Session; +use vectorcraft_geom::Point; -use crate::{Services, VectorcraftApp, dialogs, menus, theme}; +use crate::{Services, VectorcraftApp, dialogs, io, menus, palette, theme}; /// An app that reads and writes real files. fn app() -> VectorcraftApp { @@ -85,3 +90,79 @@ fn new_from_template_takes_bytes_from_agents() { assert_eq!((app.session.documents().len(), app.views.len()), (2, 2)); assert_eq!(app.session.active().unwrap().path, None); } + +#[test] +fn recent_files_show_as_many_as_the_preference_keeps() { + let mut app = app(); + for i in 0..35 { + io::note_recent(&mut app, &format!("/art/{i}.svg")); + } + let listed = |app: &VectorcraftApp| { + menus::menu_entries(app).iter().filter(|e| e.command.as_deref().is_some_and(|c| c.starts_with("file.openRecent"))).count() + }; + assert_eq!((io::recent_files(&app).len(), listed(&app)), (20, 20), "the default"); + assert_eq!(menus::dynamic_label(&app, "file.openRecent1", ""), "34.svg"); + app.session.prefs.recent_files_count = 30; + assert_eq!((io::recent_files(&app).len(), listed(&app)), (30, 30)); + assert!(menus::enabled(&app, "file.openRecent30")); + app.session.prefs.recent_files_count = 0; + assert_eq!((io::recent_files(&app).len(), listed(&app)), (0, 0), "0 hides the list"); + assert_eq!(menus::listed_slots(&app), 0, "the native menu rebuilds without them"); + assert!(app.run("file.openRecent1", json!({})).is_err()); + app.session.prefs.recent_files_count = 5; + assert_eq!(io::recent_files(&app)[0], "/art/34.svg", "hiding them didn't forget them"); +} + +#[test] +fn show_in_folder_needs_a_saved_document_and_a_file_manager() { + let shown = Rc::new(RefCell::new(vec![])); + let s = shown.clone(); + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + assert!(!menus::enabled(&app, "file.reveal")); + app.session.doc_mut().unwrap().path = Some("/art/a.vectorcraft".into()); + assert!(!menus::enabled(&app, "file.reveal"), "no file manager (web)"); + app.services.reveal = Some(Box::new(move |p: &str| { + s.borrow_mut().push(p.to_string()); + Ok(()) + })); + assert!(menus::enabled(&app, "file.reveal")); + assert_eq!(app.run("file.reveal", json!({})).unwrap()["path"], "/art/a.vectorcraft"); + assert_eq!(*shown.borrow(), ["/art/a.vectorcraft"]); +} + +#[test] +fn the_palette_lists_document_color_mode_once() { + let items = palette::items(); + let ids: Vec<&str> = items.iter().map(|(_, id, _)| id.as_str()).filter(|id| id.to_ascii_lowercase().contains("documentcolormode")).collect(); + assert_eq!(ids, ["file.documentColorMode"]); + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + let entries = menus::menu_entries(&app); + let modes: Vec<&str> = + entries.iter().filter(|e| e.path.last().is_some_and(|p| p == "Document Color Mode")).filter_map(|e| e.command.as_deref()).collect(); + assert_eq!(modes, ["file.documentColorMode", "file.documentColorMode"], "CMYK and RGB run the one command"); +} + +#[test] +fn a_document_reopens_at_its_saved_view() { + let d = dir("view"); + let path = d.join("v.vectorcraft").to_string_lossy().to_string(); + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + { + let v = app.view_mut().unwrap(); + (v.zoom, v.center, v.rotation, v.fitted) = (3.0, Point::new(70.0, 40.0), 30.0, true); + } + app.run("file.saveAs", json!({"path": path})).unwrap(); + // Moving around afterwards isn't an edit. + app.view_mut().unwrap().zoom = 0.5; + assert!(!app.session.active().unwrap().is_dirty()); + io::open_path(&mut app, &path).unwrap(); + let v = *app.view().unwrap(); + assert_eq!((v.zoom, v.center, v.rotation, v.fitted), (3.0, Point::new(70.0, 40.0), 30.0, true)); + // A document without a saved view is fitted on first display. + app.run("file.new", json!({})).unwrap(); + assert!(!app.view().unwrap().fitted); + let _ = std::fs::remove_dir_all(d); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b159ed26..ec669659 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -22,7 +22,7 @@ | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | -| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | @@ -284,3 +284,10 @@ lists to the system clipboard, and every Paste command first loads what another `importPdf`, `importText` or `importImage`, centred in the view); an import error answers the Paste with that error and pastes nothing. Cmd+V also pastes a bitmap or PDF with no text beside it (on the key's release, as egui sends no Paste event for it). + +File menu details: `file.openRecent1`…`file.openRecent30` open the recent files the `recentFilesCount` preference +lists (0 hides them all; `file.recentFiles` returns the listed paths), `file.reveal` shows the document's file in the +system file manager (desktop only; answers `{path}`), and `file.newFromTemplate` without params opens a file picker +in the Templates folder (preference `templatesFolder`). Older native files open as " [Converted]" (preference +`appendConverted`), so their Save asks for a new `.vectorcraft` name. Native files reopen at the view they were saved +with. diff --git a/docs/mcp.md b/docs/mcp.md index bdfcd87a..3e0dd2f6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -146,6 +146,8 @@ without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOp 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. +`file.documentColorMode {mode, convert?, intent?}` switches the document colour mode, converting its colours +through the colour settings (`object.convertDocumentColorMode` is an alias kept for older scripts). ## Resources