From e29f5e51a0e14719b99849c69c6b38e7b7814793 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:00:40 -0800 Subject: [PATCH 01/12] New Document: each preset category starts documents from its own profile Each built-in category (Mobile, Web, Print, Film & Video, Art & Illustration, Branding, Social) has a New Document profile, a native document in the New Document Profiles folder next to the preferences. file.new copies the profile's swatches, graphic styles, brushes, symbols, patterns, character and paragraph styles, rulers, Document Setup options, assigned color profiles and print settings into the new document. The preset's size, units, artboards, bleed, background, color mode and resolution still apply, and the profile's art and layers are not copied. - file.newProfiles lists the profiles, file.newProfiles.edit opens one as a document (writing the shipped profile first), and file.newProfiles.reset deletes its file. file.new takes `profile`. - Saved and Recent presets keep the profile of the preset they came from. - A profile that cannot be read, is larger than 64 MiB or is not a native document leaves the defaults, and file.new reports it in profileError. - New Document has a Manage Profiles pop-up with Edit and Reset. Docs: docs/mcp.md, docs/target-app-parity.md. --- apps/vectorcraft-cli/src/main.rs | 4 +- apps/vectorcraft/src/main.rs | 1 + apps/vectorcraft/src/prefs_dir.rs | 8 + crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/newdoc.rs | 303 ++++++++++++++++++++- crates/engine/src/lib.rs | 3 + crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/newdoc.rs | 170 ++++++++++++ crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/dialogs/new_document.rs | 138 +++++++++- docs/mcp.md | 40 +++ docs/target-app-parity.md | 1 + 12 files changed, 655 insertions(+), 17 deletions(-) diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 57ab103590..9a92efb37e 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -238,9 +238,11 @@ fn main() -> ExitCode { /// A headless session that remembers what the user keeps between runs: the PDF presets saved, the /// ICC profiles loaded, the plug-ins installed (#2168, #2174, #2541), the custom dictionary's words (#4936) and the Color Settings (#6335), in the folders /// and files next to the preferences that the desktop app uses too, and lists the scripts of the Scripts folder there -/// (`script.listInstalled`). `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there. +/// (`script.listInstalled`) and starts new documents from the New Document profiles there. `VECTORCRAFT_NO_PREFS` and +/// `VECTORCRAFT_CONFIG_DIR` apply as they do there. fn with_stores(mut h: Headless) -> Headless { prefs_dir::attach_scripts_dir(&mut h.session); + prefs_dir::attach_new_doc_profiles(&mut h.session); prefs_dir::attach_profile_store(&mut h.session); prefs_dir::attach_color_settings(&mut h.session); prefs_dir::attach_plugin_store(&mut h.session); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 33e03b1f72..d6baab7e2b 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -476,6 +476,7 @@ fn main() -> std::process::ExitCode { prefs_dir::attach_plugin_store(&mut session); // File › Scripts lists the scripts of the Scripts folder next to the preferences. prefs_dir::attach_scripts_dir(&mut session); + prefs_dir::attach_new_doc_profiles(&mut session); let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone())); app.set_startup_update_check(true); load_prefs(&mut app, saved); diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 96e09f5e78..775ef73841 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -65,6 +65,14 @@ pub fn attach_scripts_dir(session: &mut vectorcraft_engine::Session) { } } +/// Have File › New start each category's documents from its profile in the `New Document +/// Profiles` folder, which `file.newProfiles.edit` makes. +pub fn attach_new_doc_profiles(session: &mut vectorcraft_engine::Session) { + if let Some(dir) = store_dir("New Document Profiles") { + session.attach_new_doc_profiles(dir); + } +} + /// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from /// now on there. After the preferences were restored: the folder is the one place the presets live. pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 108b8593c9..0713273f2c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), profile?: a category of file.newPresets|null (the New Document profile the document starts from, see file.newProfiles; default: a built-in preset's category, kept by the saved and Recent presets made from it; null or no preset: the defaults), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode, profile?: the profile applied, profileError?: {profile, reason} (its file could not be read, was larger than 64 MiB or is not a native document, or the request may not read the profiles folder: the document has the defaults)}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index bd15d748ca..59bcb0614a 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -1,10 +1,21 @@ //! File → New: the settings `file.new` takes, the New Document presets (generated in code, by //! category), the user's saved presets and the recently used sizes (both kept in the //! preferences), and the artboard grid layout shared with Rearrange All Artboards. +//! +//! New Document profiles: each built-in category has a profile, a native document in the +//! `New Document Profiles` folder next to the preferences ([`Session::attach_new_doc_profiles`]). +//! `file.new` starts a document of that category with the profile's swatches, graphic styles, +//! brushes, symbols, character and paragraph styles, rulers, document setup and print settings +//! ([`DocSettings::document_from_profile`]). The grid stays that of Preferences › Guides & Grid, +//! which applies to every open document. A category without a profile file starts with the +//! defaults, and a profile that can't be read leaves them in place and is reported in `file.new`'s +//! result. + +use std::sync::Arc; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use vectorcraft_doc::{Artboard, Background, ColorMode, Document, Unit}; +use vectorcraft_doc::{Artboard, Background, ColorMode, Document, LayerColor, Node, Unit}; use vectorcraft_geom::{Point, Rect}; use super::{CommandSpec, Result, Session, always, bad, clock_date, cmd, date_param, str_param, switch_or}; @@ -38,6 +49,33 @@ pub fn specs() -> Vec { |s| if s.prefs.new_doc_presets.is_empty() { Err("no saved presets".into()) } else { Ok(()) }, delete ), + cmd!( + query "file.newProfiles", + "New Document Profiles", + [], + None, + "{} the New Document profiles, one per built-in category of file.newPresets: native documents in the New Document Profiles folder next to the preferences, whose swatches, swatch groups, graphic styles, brushes, symbols, patterns, character and paragraph styles, composite fonts, SVG filters, graph designs, ruler origin and mode, transparency grid, document setup (other than bleed, background and Align New Objects to Pixel Grid), assigned color profiles, Document Raster Effects Settings (other than the resolution) and print settings a new document of that category starts with → {profiles: [{name, customized?: a profile file exists (else the shipped profile, today's defaults, applies), path?; both left out for a request that may not read the folder}], folder?: the folder (none in runs without preferences and on the web)}", + always, + list_profiles + ), + cmd!( + "file.newProfiles.edit", + "Edit Profile", + [], + None, + "{name: a category of file.newPresets} open the category's New Document profile as a document to edit (File › Save keeps the changes for the next file.new); a category without a profile file gets the shipped profile written first → document.open's result", + always, + edit_profile + ), + cmd!( + "file.newProfiles.reset", + "Reset Profile", + [], + None, + "{name: a category of file.newPresets} delete the category's profile file, so the shipped profile (today's defaults) applies again; an open copy of it stays open → {name, reset: whether a file was deleted}", + always, + reset_profile + ), ] } @@ -148,6 +186,11 @@ pub struct DocSettings { /// on in the Mobile and Web presets, off in the others and in a saved or Recent preset from /// before it. pub align_new_objects_to_pixel_grid: bool, + /// The New Document profile the document starts from (a built-in category's name): the + /// category of a built-in preset, kept by the saved and Recent presets made from it; `None`: + /// the defaults. + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, } impl Default for DocSettings { @@ -168,6 +211,7 @@ impl Default for DocSettings { raster_effects_ppi: 72.0, preview_mode: PreviewMode::Default, align_new_objects_to_pixel_grid: false, + profile: None, } } } @@ -217,6 +261,7 @@ impl DocSettings { "rasterEffectsPpi": self.raster_effects_ppi, "previewMode": self.preview_mode.id(), "alignNewObjectsToPixelGrid": self.align_new_objects_to_pixel_grid, + "profile": self.profile, }) } @@ -280,6 +325,11 @@ impl DocSettings { self.preview_mode = m.as_str().and_then(PreviewMode::parse).ok_or_else(|| bad(cmd, "previewMode must be default, pixel or overprint"))?; } self.align_new_objects_to_pixel_grid = switch_or(p, "alignNewObjectsToPixelGrid", self.align_new_objects_to_pixel_grid, cmd)?; + match p.get("profile") { + None => {} + Some(Value::Null) => self.profile = None, + Some(v) => self.profile = Some(profile_name(v, cmd)?.to_string()), + } let extent = self.boards().iter().fold(Rect::ZERO, |a, r| a.union(*r)); if extent.width().max(extent.height()) + self.bleed.iter().fold(0.0f64, |a, b| a.max(*b)) * 2.0 > crate::MAX_COORD { return Err(bad(cmd, "the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size")); @@ -357,6 +407,101 @@ impl DocSettings { .collect(); d } + + /// The new document started from New Document profile `profile`: [`Self::document`] with the + /// profile's swatches, swatch groups, graphic styles, brushes, symbols, patterns and the images + /// they use, character and paragraph styles, composite fonts, SVG filters, graph designs, ruler + /// origin and mode, transparency grid, document setup (bleed, background and Align New Objects + /// to Pixel Grid stay these settings'), assigned color profiles, raster effects settings (the + /// resolution stays these settings') and print settings, in these settings' color mode (the + /// profile's colors converted as Document Color Mode converts them). The profile's art, + /// layers and artboards are left out: the document has one empty layer. + pub fn document_from_profile(&self, mut profile: Document) -> Document { + if profile.color_mode != self.color_mode { + super::colormgmt::set_color_mode(&mut profile, self.color_mode, true, None, super::colormgmt::Grays::Profile); + } + let profile_next_id = profile.peek_next_id(); + let mut d = self.document(); + d.swatches = profile.swatches; + d.swatch_groups = profile.swatch_groups; + d.graphic_styles = profile.graphic_styles; + d.char_styles = profile.char_styles; + d.para_styles = profile.para_styles; + d.composite_fonts = profile.composite_fonts; + d.symbols = profile.symbols; + d.graph_designs = profile.graph_designs; + d.patterns = profile.patterns; + d.images = profile.images; + d.svg_filters = profile.svg_filters; + d.spot_use_lab = profile.spot_use_lab; + d.ruler_origin = profile.ruler_origin; + d.rulers = profile.rulers; + d.artboard_ruler_origins = profile.artboard_ruler_origins.into_iter().filter(|(id, _)| d.artboards.iter().any(|a| a.id == *id)).collect(); + d.transparency_grid = profile.transparency_grid; + d.print_setup = profile.print_setup; + d.color_profiles = profile.color_profiles; + // The resolution stays the preset's (raster_effects_ppi); the other raster effects settings + // are the profile's. + d.raster_effects = profile.raster_effects; + let setup = std::mem::replace(&mut d.setup, profile.setup); + d.setup.bleed = setup.bleed; + d.setup.background = setup.background; + d.setup.align_new_objects_to_pixel_grid = setup.align_new_objects_to_pixel_grid; + if let Some(brushes) = profile.unknown.get("brushes") { + d.unknown.insert("brushes".into(), brushes.clone()); + } + // Symbol, pattern and graph design art keeps the profile's ids, which a damaged file can + // have above its counter: the counter goes past all of them, as it does for a loaded + // document's layers, before the new layer takes an id. + d.raise_next_id(profile_next_id); + d.layers = d + .symbols + .iter() + .map(|s| s.art.clone()) + .chain(d.graph_designs.iter().map(|g| g.art.clone())) + .chain(d.patterns.iter().flat_map(|p| p.art.iter().cloned())) + .collect(); + d.fix_next_id(); + let layer = d.alloc_id(); + d.layers = vec![Arc::new(Node::layer(layer, "Layer 1", LayerColor::Preset(0)))]; + // Images only the profile's art used stay behind: an image is kept when an object of the new + // document names it in its `key`, as a native save keeps only the images its objects name. + let images = std::mem::take(&mut d.images); + if !images.is_empty() { + d.images = match serde_json::to_value(&d) { + Ok(v) => { + let used = image_keys(&v); + images.into_iter().filter(|(k, _)| used.contains(k.as_str())).collect() + } + Err(_) => images, + }; + } + d + } +} + +/// The string values of every `key` member at any depth of `v`, which name the images a document's +/// objects use, collected in one pass. +fn image_keys(v: &Value) -> std::collections::HashSet<&str> { + let mut keys = std::collections::HashSet::new(); + let mut stack = vec![v]; + while let Some(v) = stack.pop() { + match v { + Value::Object(m) => { + for (k, v) in m { + match v.as_str() { + Some(s) if k == "key" => { + keys.insert(s); + } + _ => stack.push(v), + } + } + } + Value::Array(a) => stack.extend(a), + _ => {} + } + } + keys } /// Largest artboard side `file.new` accepts, points. @@ -573,6 +718,7 @@ fn builtin(c: &Category, general: Unit) -> impl Iterator + ' color_mode, raster_effects_ppi: ppi, align_new_objects_to_pixel_grid: align, + profile: Some(category.into()), ..DocSettings::default() }) } @@ -623,7 +769,18 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { if size != Some(sorted(&settings)) { settings.name = "Custom".into(); } - let mut d = settings.document(); + let (profile, profile_error) = match settings.profile.as_deref() { + Some(name) => match s.read_profile(name) { + Ok(p) => (p, None), + Err(e) => (None, Some(e)), + }, + None => (None, None), + }; + let applied = profile.is_some(); + let mut d = match profile { + Some(p) => settings.document_from_profile(p), + None => settings.document(), + }; d.grid = s.prefs.grid(); d.title = match str_param(p, "name").or(str_param(p, "title")).map(str::trim).filter(|t| !t.is_empty()) { // New Document offers the next untitled name: taking it uses it up. @@ -639,7 +796,14 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { s.execute("view.overprintPreview", &json!({ "on": true }))?; } remember(s, &settings); - Ok(json!({ "index": i, "previewMode": settings.preview_mode.id() })) + let mut out = json!({ "index": i, "previewMode": settings.preview_mode.id() }); + if applied { + out["profile"] = json!(settings.profile); + } + if let Some(e) = profile_error { + out["profileError"] = json!({ "profile": settings.profile, "reason": e }); + } + Ok(out) } /// Put `settings` first in Recent (one entry per size and units). @@ -693,3 +857,136 @@ fn delete(s: &mut Session, p: &Value) -> Result { let i = saved.iter().position(|q| q.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no saved preset called `{name}`")))?; Ok(json!({ "deleted": saved.remove(i).name })) } + +/// The largest New Document profile file `file.new` reads, bytes (VectorCraft's own limit); a +/// larger one is reported and the document starts with the defaults. +pub const MAX_PROFILE_BYTES: u64 = 64 << 20; + +/// The New Document profiles' names: the built-in categories, in tab order. +pub fn profile_names() -> impl Iterator { + CATEGORIES.iter().map(|c| c.0) +} + +/// The profile `v` names (a built-in category, any case). +fn profile_name(v: &Value, cmd: &str) -> Result<&'static str> { + let name = v.as_str().map(str::trim).unwrap_or_default(); + profile_names().find(|c| c.eq_ignore_ascii_case(name)).ok_or_else(|| { + let shown = v.as_str().map_or_else(|| v.to_string(), str::to_string); + bad(cmd, format!("no New Document profile called `{shown}` ({})", profile_names().collect::>().join(", "))) + }) +} + +impl Session { + /// Keep the New Document profiles in the folder `dir` (`New Document Profiles` next to the + /// preferences). The folder is made when a profile is first edited. + pub fn attach_new_doc_profiles(&mut self, dir: std::path::PathBuf) { + self.new_doc_profile_dir = Some(dir); + } + + /// The New Document profiles' folder ([`Session::attach_new_doc_profiles`]); none on the web. + pub fn new_doc_profile_dir(&self) -> Option<&std::path::Path> { + self.new_doc_profile_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32")) + } + + /// The file of profile `name`, whether or not it exists. + fn profile_path(&self, name: &str) -> Option { + Some(self.new_doc_profile_dir()?.join(format!("{name}.{}", vectorcraft_format::EXTENSION))) + } + + /// Whether profile `name` has a file, which `file.newProfiles.reset` deletes. + pub fn new_doc_profile_customized(&self, name: &str) -> bool { + self.profile_path(name).is_some_and(|p| p.is_file()) + } + + /// Profile `name`'s document: `None` when it has no file (the shipped profile, the defaults), + /// the reason when the request being served may not read it, or its file can't be read, is + /// larger than [`MAX_PROFILE_BYTES`] or isn't a native document VectorCraft reads. + fn read_profile(&self, name: &str) -> std::result::Result, String> { + let Some(path) = self.profile_path(name) else { return Ok(None) }; + // Checked before the file is looked for, so a request that may not read the folder learns + // nothing of its files. + crate::file_access::check_read(&path.to_string_lossy())?; + if std::fs::symlink_metadata(&path).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) { + return Ok(None); + } + crate::file_access::check_read(&path.to_string_lossy())?; + let bytes = crate::store::read_capped(&path, MAX_PROFILE_BYTES)?; + let mut doc = vectorcraft_format::load(&bytes).map_err(|e| e.to_string())?; + // Linked images in its symbols, patterns and brushes are read from their files, as placing + // a document reads them. + super::links::resolve(&mut doc, super::links::LinkBase::file(Some(&path.to_string_lossy())), false, false); + Ok(Some(doc)) + } +} + +/// The shipped profile of category `name`: the defaults, as a document of the category's first +/// preset. +fn shipped_profile(s: &Session, name: &str) -> Document { + let first = CATEGORIES.iter().find(|c| c.0 == name).and_then(|c| builtin(c, s.default_units()).next()); + let mut d = first.unwrap_or_default().document(); + d.title = name.to_string(); + d +} + +/// `path` when the request being served may read it. +fn shown(path: &std::path::Path) -> Option { + let path = path.to_string_lossy().to_string(); + crate::file_access::check_read(&path).is_ok().then_some(path) +} + +fn list_profiles(s: &mut Session, _: &Value) -> Result { + let profiles: Vec = profile_names() + .map(|name| { + let mut v = json!({ "name": name }); + match s.profile_path(name) { + // A request that may not read the folder learns nothing of its files. + Some(path) => { + if let Some(p) = shown(&path) { + v["customized"] = json!(path.is_file()); + v["path"] = json!(p); + } + } + None => v["customized"] = json!(false), + } + v + }) + .collect(); + Ok(json!({ "profiles": profiles, "folder": s.new_doc_profile_dir().and_then(shown) })) +} + +/// The file of profile `name` for `cmd` to change, checked against the request's write access. +fn writable_profile(s: &Session, name: &str, cmd: &str) -> Result { + let path = s.profile_path(name).ok_or_else(|| bad(cmd, "New Document profiles need the preferences folder, which this run doesn't have"))?; + crate::file_access::check_write(&path.to_string_lossy()).map_err(super::EngineError::Other)?; + Ok(path) +} + +fn edit_profile(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.newProfiles.edit"; + let name = profile_name(p.get("name").unwrap_or(&Value::Null), C)?; + let file = writable_profile(s, name, C)?; + let path = file.to_string_lossy().to_string(); + // document.open reads it next: a profile it can't open is not written either. + crate::file_access::check_read(&path).map_err(super::EngineError::Other)?; + if !file.exists() { + let bytes = vectorcraft_format::save(&shipped_profile(s, name), false); + file.parent() + .map_or(Ok(()), std::fs::create_dir_all) + .and_then(|()| std::fs::write(&file, bytes)) + .map_err(|e| super::EngineError::Other(format!("{path}: {e}")))?; + } + s.execute("document.open", &json!({ "path": path })) +} + +fn reset_profile(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.newProfiles.reset"; + let name = profile_name(p.get("name").unwrap_or(&Value::Null), C)?; + let file = writable_profile(s, name, C)?; + let path = file.to_string_lossy().to_string(); + let reset = match std::fs::remove_file(&file) { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(super::EngineError::Other(format!("{path}: {e}"))), + }; + Ok(json!({ "name": name, "reset": reset })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 745a6fb62c..5600276b5c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -171,6 +171,9 @@ pub struct Session { /// The Scripts folder, whose scripts File › Scripts lists; `None`: no folder /// ([`Session::attach_scripts_dir`]). scripts_dir: Option, + /// The folder of the New Document profiles `file.new` starts documents from; `None`: none, + /// every document starts with the defaults ([`Session::attach_new_doc_profiles`]). + new_doc_profile_dir: Option, /// A filter's output that a run made in the background handed in for `plugin.run` to apply /// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it. plugin_output: Option, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 2d850fd44d..f55e2f1e25 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -87,6 +87,7 @@ impl Session { color_settings_file: None, plugin_dir: None, scripts_dir: None, + new_doc_profile_dir: None, plugin_output: None, plugin_install_approval: None, plugin_grants_from_requests: false, diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 4bc58e5f6b..bf560156f2 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -455,3 +455,173 @@ fn rearrange_that_moves_no_artboard_records_no_undo_step() { assert_eq!(s.execute("artboard.rearrange", &p).unwrap(), json!({"artboards": 0, "rows": 1, "columns": 1})); assert_eq!(history(&s), before); } + +/// A session whose New Document profiles are in a fresh folder for test `name` (not made yet). +fn with_profiles(name: &str) -> (Session, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("vc-newdoc-profiles-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.attach_new_doc_profiles(dir.clone()); + (s, dir) +} + +fn swatch_names(s: &Session) -> Vec { + s.doc().unwrap().doc.swatches.iter().map(|w| w.name.clone()).collect() +} + +/// Edit the Print profile: Brand Red, the Brand Box style, the Badge and Logo symbols (Logo's art an +/// embedded image) and an image left on its layer, saved. +fn edit_print_profile(s: &mut Session, dir: &std::path::Path) { + // Edit writes the shipped profile and opens it. + s.execute("file.newProfiles.edit", &json!({"name": "print"})).unwrap(); + assert!(dir.join("Print.vectorcraft").is_file()); + assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Cmyk); + s.execute("swatch.new", &json!({"name": "Brand Red", "color": "#cc2200"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + s.execute("graphicStyle.new", &json!({"name": "Brand Box"})).unwrap(); + s.execute("symbol.new", &json!({"name": "Badge"})).unwrap(); + // One embedded image becomes a symbol's art, another stays on the profile's layer. + let png = |rgb: [u8; 3]| { + let mut out = vec![]; + image::RgbaImage::from_pixel(4, 4, image::Rgba([rgb[0], rgb[1], rgb[2], 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + vectorcraft_format::base64_encode(&out) + }; + s.execute("file.place", &json!({"name": "red.png", "dataBase64": png([255, 0, 0]), "link": false})).unwrap(); + s.execute("symbol.new", &json!({"name": "Logo"})).unwrap(); + s.execute("file.place", &json!({"name": "blue.png", "dataBase64": png([0, 0, 255]), "link": false})).unwrap(); + assert_eq!(s.doc().unwrap().doc.images.len(), 2); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(s.execute("file.newProfiles", &json!({})).unwrap()["profiles"][2]["customized"], true); +} + +#[test] +fn an_edited_profile_starts_the_documents_of_its_category() { + let (mut s, dir) = with_profiles("edited"); + let listed = s.execute("file.newProfiles", &json!({})).unwrap(); + let names: Vec<&str> = listed["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Mobile", "Web", "Print", "Film & Video", "Art & Illustration", "Branding", "Social"]); + assert!(listed["profiles"].as_array().unwrap().iter().all(|p| p["customized"] == false), "{listed}"); + edit_print_profile(&mut s, &dir); + + let r = s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + assert_eq!(r["profile"], "Print", "{r}"); + assert!(swatch_names(&s).contains(&"Brand Red".to_string())); + let d = &s.doc().unwrap().doc; + assert!(d.graphic_styles.iter().any(|g| g.name == "Brand Box")); + let badge = d.symbols.iter().find(|y| y.name == "Badge").expect("the profile's symbol"); + assert!(d.symbols.iter().any(|y| y.name == "Logo")); + assert_eq!(d.images.len(), 1, "only the symbol's image comes along"); + // The profile's art stays behind: one empty layer, with an id the symbol's art doesn't use. + assert_eq!(d.layers.len(), 1); + assert!(d.layers[0].children().is_none_or(Vec::is_empty)); + let mut symbol_ids = vec![]; + badge.art.walk(&mut |n| symbol_ids.push(n.id)); + assert!(!symbol_ids.contains(&d.layers[0].id), "{symbol_ids:?}"); + assert_eq!(d.artboards[0].rect.width().round(), 595.0); + let red = d.swatches.iter().find(|w| w.name == "Brand Red").cloned().unwrap(); + assert!(matches!(red.paint, vectorcraft_color::Paint::Solid { color: vectorcraft_color::Color::Cmyk { .. }, .. }), "{:?}", red.paint); + // In RGB the profile's colors are converted, as Document Color Mode converts them. + s.execute("file.new", &json!({"preset": "A4", "colorMode": "rgb"})).unwrap(); + let red = s.doc().unwrap().doc.swatches.iter().find(|w| w.name == "Brand Red").cloned().expect("converted, not dropped"); + assert!(matches!(red.paint, vectorcraft_color::Paint::Solid { color: vectorcraft_color::Color::Rgb { .. }, .. }), "{:?}", red.paint); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_profile_follows_the_preset_access_and_reset() { + let (mut s, dir) = with_profiles("choice"); + edit_print_profile(&mut s, &dir); + // A request that may not read the folder gets the defaults, and the reason; edit is refused + // before anything is written. + let elsewhere = dir.with_extension("elsewhere"); + std::fs::create_dir_all(&elsewhere).unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[elsewhere.as_path()], &[dir.as_path()]).unwrap().unwrap(); + let r = crate::file_access::confine(Some(&roots), || s.execute("file.new", &json!({"preset": "A4"}))).unwrap(); + assert!(r.get("profile").is_none() && r["profileError"]["profile"] == "Print", "{r}"); + assert!(!swatch_names(&s).contains(&"Brand Red".to_string())); + // Nothing tells such a request which profiles have files. + let r = crate::file_access::confine(Some(&roots), || s.execute("file.new", &json!({"preset": "Web 1920×1080"}))).unwrap(); + assert_eq!(r["profileError"]["profile"], "Web", "{r}"); + let listed = crate::file_access::confine(Some(&roots), || s.execute("file.newProfiles", &json!({}))).unwrap(); + assert!(listed["profiles"].as_array().unwrap().iter().all(|p| p.get("customized").is_none() && p.get("path").is_none()), "{listed}"); + assert!(crate::file_access::confine(Some(&roots), || s.execute("file.newProfiles.edit", &json!({"name": "Web"}))).is_err()); + assert!(!dir.join("Web.vectorcraft").exists()); + let _ = std::fs::remove_dir_all(&elsewhere); + // A saved preset made from a Print preset keeps the profile. + s.execute("file.newPresets.save", &json!({"name": "Zine", "preset": "A5"})).unwrap(); + assert_eq!(presets(&mut s, "Saved")[0]["profile"], "Print"); + assert_eq!(s.execute("file.new", &json!({"preset": "Zine"})).unwrap()["profile"], "Print"); + + // Other categories, no preset and `profile: null` start with the defaults. + for p in [json!({"preset": "Web 1920×1080"}), json!({}), json!({"preset": "A4", "profile": null})] { + let r = s.execute("file.new", &p).unwrap(); + assert!(r.get("profile").is_none() && !swatch_names(&s).contains(&"Brand Red".to_string()), "{p}: {r}"); + } + // `profile` picks one for any settings. + assert_eq!(s.execute("file.new", &json!({"width": 300, "height": 300, "profile": "PRINT"})).unwrap()["profile"], "Print"); + + // Reset deletes the file, once; the shipped profile applies again. + assert_eq!(s.execute("file.newProfiles.reset", &json!({"name": "Print"})).unwrap(), json!({"name": "Print", "reset": true})); + assert_eq!(s.execute("file.newProfiles.reset", &json!({"name": "Print"})).unwrap(), json!({"name": "Print", "reset": false})); + let r = s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + assert!(r.get("profile").is_none() && !swatch_names(&s).contains(&"Brand Red".to_string()), "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported() { + let (mut s, dir) = with_profiles("damaged"); + s.execute("file.new", &json!({"preset": "Letter", "profile": null})).unwrap(); + let defaults = swatch_names(&s); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Print.vectorcraft"), b"not a document").unwrap(); + let r = s.execute("file.new", &json!({"preset": "Letter"})).unwrap(); + assert_eq!(r["profileError"]["profile"], "Print", "{r}"); + assert!(!r["profileError"]["reason"].as_str().unwrap_or("").is_empty(), "{r}"); + assert!(r.get("profile").is_none()); + assert_eq!(swatch_names(&s), defaults); + // Past the limit the file is not read at all (a sparse file of that length). + let big = std::fs::File::create(dir.join("Web.vectorcraft")).unwrap(); + big.set_len(crate::cmd::newdoc::MAX_PROFILE_BYTES + 1).unwrap(); + drop(big); + let r = s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap(); + assert!(r["profileError"]["reason"].as_str().unwrap_or("").contains("larger than"), "{r}"); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 1920.0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn profile_names_are_checked_and_editing_needs_the_folder() { + let mut s = Session::new(); + for p in [json!({"profile": "Posters"}), json!({"profile": 5}), json!({"profile": ""})] { + let e = s.execute("file.new", &p).unwrap_err().to_string(); + assert!(e.contains("no New Document profile called") && e.contains("Film & Video"), "{p}: {e}"); + } + assert!(s.execute("file.newProfiles.edit", &json!({"name": "Posters"})).is_err()); + for cmd in ["file.newProfiles.edit", "file.newProfiles.reset"] { + let e = s.execute(cmd, &json!({"name": "Print"})).unwrap_err().to_string(); + assert!(e.contains("preferences folder"), "{cmd}: {e}"); + } + // Without a folder every profile is the shipped one and documents start with the defaults. + let r = s.execute("file.newProfiles", &json!({})).unwrap(); + assert!(r["folder"].is_null() && r["profiles"][0].get("path").is_none(), "{r}"); + let r = s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + assert!(r.get("profile").is_none() && r.get("profileError").is_none(), "{r}"); +} + +#[test] +fn the_new_layer_takes_an_id_no_profile_art_has() { + // A damaged profile whose symbol art has the id the new layer would otherwise take. + let settings = crate::cmd::newdoc::DocSettings::default(); + let mut profile = settings.document(); + let next = settings.document().peek_next_id().max(profile.peek_next_id()); + let art = std::sync::Arc::new(vectorcraft_doc::Node::group(vectorcraft_doc::NodeId(next), vec![])); + profile.symbols.push(vectorcraft_doc::Symbol { name: "Badge".into(), art, dynamic: false }); + let d = settings.document_from_profile(profile); + assert_eq!(d.layers.len(), 1); + assert_ne!(d.layers[0].id, vectorcraft_doc::NodeId(next)); + // Ids given out later skip the symbol's art too. + assert!(d.peek_next_id() > d.layers[0].id.0 && d.peek_next_id() > next); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f9788d59a8..9c1432d76d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -365,6 +365,7 @@ registry! { JavaScriptFiles: [javascript_files::KIND] => javascript_files::SPEC, SvgFilter: [svg_filter::KIND] => svg_filter::SPEC, NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC, + NewDocumentProfiles: [new_document::PROFILES] => new_document::MORE_SPEC, Place: [place::KIND] => place::SPEC, RasterOptions: ["pngOptions", "jpgOptions", "webpOptions", "gifOptions", "png8Options"] => png_options::SPEC, ExportAs: ["exportAs"] => export_as::SPEC, diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 76403ff451..96e33684bc 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -4,11 +4,13 @@ //! Close and Create. More Settings opens a pop-up over the dialog with the artboard layout, spacing //! and columns, editing the same fields. Save Preset turns the top of Preset Details into the //! preset's name, Cancel and Save, and hides Close and Create until the preset is saved or canceled; -//! the rows under it stay editable and are what the preset stores. +//! the rows under it stay editable and are what the preset stores. Manage Profiles opens a pop-up +//! listing the New Document profiles (`file.newProfiles`), each with Edit, which closes the dialog +//! and opens the profile as a document, and Reset, which brings back the shipped profile. //! //! Fields: `file.new`'s params (`preset`, `name`, `width`, `height` (pt), `units`, `artboards`, //! `artboardLayout`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi`, -//! `previewMode`, `alignNewObjectsToPixelGrid`), plus `category` (the tab), `advanced` (Advanced Options open), `bleedLinked` and +//! `previewMode`, `alignNewObjectsToPixelGrid`, `profile`), plus `category` (the tab), `advanced` (Advanced Options open), `bleedLinked` and //! `presetName` (Save Preset's name). Setting `preset` fills the fields from that preset; changing //! any of them afterwards makes the settings custom. @@ -24,12 +26,14 @@ use crate::{VectorcraftApp, widgets}; pub(super) const KIND: &str = "newDocument"; /// New Document with its More Settings pop-up open. pub(super) const MORE: &str = "newDocumentMore"; +/// New Document with its Manage Profiles pop-up open. +pub(super) const PROFILES: &str = "newDocumentProfiles"; pub(super) const SPEC: DialogSpec = DialogSpec { inputs: |_| &["presetName"], ..DialogSpec::window(show, confirm) }; pub(super) const MORE_SPEC: DialogSpec = SPEC; /// The fields `file.new` takes. -const PARAMS: [&str; 13] = [ +const PARAMS: [&str; 14] = [ "preset", "name", "width", @@ -43,6 +47,7 @@ const PARAMS: [&str; 13] = [ "rasterEffectsPpi", "previewMode", "alignNewObjectsToPixelGrid", + "profile", ]; /// The preset the fields were last filled from, as `apply` set them; a Recent or Saved card keeps /// its own units here, which the units chosen in Preset Details don't change. @@ -139,12 +144,21 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } let p = params(app, d); let r = app.run("file.new", Value::Object(p)); - if r.is_ok() { + if let Ok(r) = &r { app.ui.dialog = None; + report_profile(app, r); } r } +/// Say in the status bar when `file.new`'s result `r` reports a profile it could not read. +fn report_profile(app: &mut VectorcraftApp, r: &Value) { + if let Some(e) = r.get("profileError") { + let (name, reason) = (e["profile"].as_str().unwrap_or_default(), e["reason"].as_str().unwrap_or_default()); + app.status(format!("The {name} profile could not be read, so the new document has the default settings: {reason}")); + } +} + /// New Without Dialog (`file.newWithoutDialog`, Cmd+Alt+N): create the document [`open`] would /// offer, as its Create would, without showing the dialog. A dialog already open stays open. pub fn create_without_dialog(app: &mut VectorcraftApp) -> Result { @@ -153,7 +167,11 @@ pub fn create_without_dialog(app: &mut VectorcraftApp) -> Result let offered = app.ui.dialog.take(); app.ui.dialog = shown; let p = offered.map(|d| params(app, &d)).unwrap_or_default(); - app.run("file.new", Value::Object(p)) + let r = app.run("file.new", Value::Object(p)); + if let Ok(r) = &r { + report_profile(app, r); + } + r } /// The `file.new` params of the fields. @@ -194,15 +212,15 @@ fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui: super::modal::show(ctx, id, egui::Id::unique(("dialog", id)), -20.0, margin, add); } -/// Whether More Settings' pop-up is open over New Document. +/// Whether More Settings' or Manage Profiles' pop-up is open over New Document. pub(crate) fn popup_open(d: &Dialog) -> bool { - d.kind == MORE + d.kind == MORE || d.kind == PROFILES } -/// Close New Document's More Settings pop-up, or else leave its Save Preset, keeping the fields. -/// False when neither is showing, or `d` is another dialog. +/// Close New Document's More Settings or Manage Profiles pop-up, or else leave its Save Preset, +/// keeping the fields. False when none is showing, or `d` is another dialog. pub(super) fn close_popup(d: &mut Dialog) -> bool { - if d.kind != KIND && d.kind != MORE { + if d.kind != KIND && !popup_open(d) { return false; } if popup_open(d) { @@ -461,12 +479,14 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut if saving { return; } + ui.add_space(6.0); + b.close |= manage_profiles(app, ui, d); // Close and Create at the bottom right. ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0)); ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { b.create |= widgets::primary_button(ui, tl!("Create")).clicked(); ui.add_space(8.0); - b.close = widgets::secondary_button(ui, tl!("Close")).clicked(); + b.close |= widgets::secondary_button(ui, tl!("Close")).clicked(); }); } @@ -563,7 +583,60 @@ fn more_settings(ui: &mut egui::Ui, d: &mut Dialog) { .align(egui::RectAlign::TOP_END) .accessible_name(tl!("More Settings")) .show(|ui| done = more_settings_popup(ui, opening, d)); - d.kind = if open && !done { MORE } else { KIND }.into(); + if open && !done { + d.kind = MORE.into(); + } else if d.kind == MORE { + d.kind = KIND.into(); + } +} + +/// The Manage Profiles button and its pop-up: a row per New Document profile with Edit, which opens +/// the profile as a document and closes New Document (true then), and Reset, which deletes an edited +/// profile's file so the shipped profile applies again. A click outside it and Escape close it. +fn manage_profiles(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let button = widgets::flat_button(ui, tl!("Manage Profiles"), DETAILS); + let mut open = (d.kind == PROFILES) != button.clicked(); + let mut chosen: Option<(&str, &str)> = None; + let has_folder = app.session.new_doc_profile_dir().is_some(); + egui::Popup::from_response(&button) + .open_bool(&mut open) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .align(egui::RectAlign::TOP_END) + .accessible_name(tl!("Manage Profiles")) + .show(|ui| { + ui.set_width(MORE_WIDTH); + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("NEW DOCUMENT PROFILES")).font(theme::semibold(11.0)).color(t.text_dim)); + ui.add_space(6.0); + for name in newdoc::profile_names() { + let customized = app.session.new_doc_profile_customized(name); + ui.horizontal(|ui| { + ui.label(tl!(name)); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(customized, |ui| widgets::secondary_button(ui, tl!("Reset"))).inner.clicked() { + chosen = Some(("file.newProfiles.reset", name)); + } + // Without a profiles folder (the web, runs without preferences) there is nothing to edit. + if ui.add_enabled_ui(has_folder, |ui| widgets::secondary_button(ui, tl!("Edit"))).inner.clicked() { + chosen = Some(("file.newProfiles.edit", name)); + } + }); + }); + } + }); + if open { + d.kind = PROFILES.into(); + } else if d.kind == PROFILES { + d.kind = KIND.into(); + } + let Some((cmd, name)) = chosen else { return false }; + match app.run(cmd, json!({ "name": name })) { + Ok(_) => cmd == "file.newProfiles.edit", + Err(e) => { + app.status(e); + false + } + } } /// More Settings' pop-up. True when Done (or Enter) finished with it. @@ -1518,4 +1591,45 @@ mod tests { assert!(screen.contains_rect(h.get_by_label(label).rect()), "{label} is off screen"); } } + + /// Manage Profiles lists a row per profile; Reset is off until a profile has been edited, Escape + /// closes only the pop-up, and Edit opens the profile as a document and closes New Document. + #[test] + fn manage_profiles_edits_and_resets_the_profiles() { + let dir = std::env::temp_dir().join(format!("vc-ui-newdoc-profiles-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut h = big(); + // Without a profiles folder Edit is off too. + h.get_by_label("Manage Profiles").click(); + h.run_steps(4); + assert!(h.get_all_by_label("Edit").all(|b| b.accesskit_node().is_disabled())); + h.key_press(egui::Key::Escape); + h.run_steps(3); + h.state_mut().session.attach_new_doc_profiles(dir.clone()); + h.get_by_label("Manage Profiles").click(); + h.run_steps(4); + assert_eq!(kind(&h), super::PROFILES); + assert_eq!(h.get_all_by_label("Edit").count(), 7); + assert!(h.get_all_by_label("Reset").all(|b| b.accesskit_node().is_disabled())); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(kind(&h), super::KIND, "Escape closes only the pop-up"); + // An edited profile's Reset deletes its file. + h.state_mut().run("file.newProfiles.edit", json!({"name": "Web"})).unwrap(); + h.state_mut().run("file.newDialog", json!({})).unwrap(); + h.run_steps(3); + h.get_by_label("Manage Profiles").click(); + h.run_steps(4); + assert_eq!(h.get_all_by_label("Reset").filter(|b| !b.accesskit_node().is_disabled()).count(), 1); + h.get_all_by_label("Reset").find(|b| !b.accesskit_node().is_disabled()).unwrap().click(); + h.run_steps(3); + assert!(!dir.join("Web.vectorcraft").exists()); + // Edit on the third row (Print). + h.get_all_by_label("Edit").nth(2).unwrap().click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none(), "{:?}", h.state().ui.status); + assert_eq!(h.state().session.active().unwrap().doc.title, "Print"); + assert!(dir.join("Print.vectorcraft").is_file()); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..90b2955832 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5337,6 +5337,46 @@ every group as it is. The groups and their contents are VectorCraft's own. {"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}} ``` +### New Document profiles + +Each built-in category (Mobile, Web, Print, Film & Video, Art & Illustration, Branding, Social) has a New Document +profile: a native document that holds the starting assets and settings of the documents made from that category's +presets ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). +`file.new` copies the profile's swatches, swatch groups, graphic styles, brushes, symbols, patterns, character and +paragraph styles, composite fonts, SVG filters, graph designs, ruler origin and ruler mode, transparency grid, Document +Setup options, assigned color profiles, Document Raster Effects Settings other than the resolution, and print settings +into the new document. The preset's size, units, artboards, bleed, background, color mode, raster effects resolution +and Align New Objects to Pixel Grid still apply. The profile's art, layers and artboards are not copied: the document +starts with one empty layer. The grid comes from Preferences › Guides & Grid, as it does for every document. When the +preset's color mode differs from the profile's, the profile's colors are converted as `file.documentColorMode` +converts them. + +A built-in preset names its category's profile in `file.newPresets` (`profile`). Saved and Recent presets keep the +profile of the preset they were made from. `file.new {profile: "Print"}` picks a profile for any settings, and +`profile: null` starts with the defaults. A document made without a preset starts with the defaults. + +The profiles are `.vectorcraft` files named after their category in the `New Document Profiles` folder next to the +preferences (VectorCraft's own place and form). A category without a file has the shipped profile, which holds the +defaults. Runs without preferences (`VECTORCRAFT_NO_PREFS`) and the web build have no folder, so every document starts +with the defaults. + +- `file.newProfiles` lists `{profiles: [{name, customized?, path?}], folder?}`. `customized` is true when the category + has a file. A request whose read roots leave out the folder gets neither `customized` nor `path`. +- `file.newProfiles.edit {name}` opens the profile as a document and returns `document.open`'s result. A category + without a file gets the shipped profile written first. File › Save keeps the changes, and the next `file.new` reads + them. +- `file.newProfiles.reset {name}` deletes the category's file, so the shipped profile applies again, and returns + `{name, reset}`. `reset` is false when there was no file. A copy of the profile that is open stays open. + +The profile is read on every `file.new`, so a replayed journal or a recorded action uses the profile as it is when it +runs, as `document.open` reads its file when it runs. A file larger than 64 MiB (VectorCraft's own limit) is not read. +When the file cannot be read, is too large or is not a native document, or the request's read roots leave out the +folder (whether or not the file exists), the document starts with the defaults and +`file.new`'s result carries `profileError: {profile, reason}`; the desktop app shows the reason in the status bar. In +the New Document dialog, Manage Profiles lists the profiles with Edit and Reset buttons. Edit is available only when +there is a profiles folder, and Reset only for a profile that has a file. An unknown profile name is an error that +lists the names. + ## Place `file.place` puts another file's art into the active document as one undo step without touching the clipboard: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..d7d30d628c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -682,6 +682,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. +- **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). - **Names of new and renamed artboards, brushes and patterns:** `artboard.new` and `artboard.setProps` trim the white space around the name they are given, as the Artboards panel's rename does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. `brush.new`, `brush.duplicate`, `brush.options`, `object.pattern.make`, `object.pattern.saveCopy` and `pattern.options` trim the name as the Brush Options dialog and the Pattern Options panel do, and compare the trimmed name with the names there are; a blank name gives the name each command gives when there is none, and Brush Options and Pattern Options keep the name. Tests in `tests/panelcmds.rs`, `tests/brushsym.rs` and `tests/pattern.rs` cover each command. Public references: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html), [Brushes](https://web.archive.org/web/20251018101754/https://helpx.adobe.com/illustrator/using/brushes.html) and [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copies). - **Pattern names and swatch names:** `pattern.options` refuses a new name that another pattern, a swatch or a color group has. It takes a name that only the pattern's own swatches have. The reference page does not say what happens when the new name is taken; the refusal follows VectorCraft's rule that swatches and color groups share one namespace (`Document::swatch_name_taken`), which `swatch.new` and `swatch.edit` also follow. A rename gives the new name only to the pattern's swatches that had the old one; any other swatch with the old name keeps it, and art linked to that swatch stays linked. `object.pattern.make` and `object.pattern.saveCopy` number a color group's name as they number a swatch's. Tests in `tests/pattern.rs` cover each command. Public reference: [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copy). From 2e968acc47af9d1919d7d57254cf4483bf3f32c3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:46:06 -0800 Subject: [PATCH 02/12] Align: the Align panel aligns and distributes the selected artboards With the Artboard tool chosen, the Align panel's Align Objects, Distribute Objects and Distribute Spacing buttons, their UI commands and the Control bar's Align and Distribute buttons act on the artboards the tool has selected, as the public page describes: https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/align-and-distribute-artboards.html - artboard.align, artboard.distribute and artboard.distributeSpacing move whole artboards with their guides, and with the art inside them when Move Artwork with Artboard is on, in one undo step. No undo step is recorded when no artboard moves. - Align To offers Align to Selection and Align to Artboard. Align to Artboard keeps the active artboard in place, and the Distribute buttons then send the Distribute Spacing value. A locked artboard stays where it is. These rules are VectorCraft's own. - The Control bar shows the Align buttons, the Align To menu and the Distribute buttons after the artboard controls while it has room. Docs: docs/mcp.md, docs/ui-parity.md. --- crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/artboardalign.rs | 262 +++++++++++++++++ crates/engine/src/cmd/artboardalign/tests.rs | 277 ++++++++++++++++++ crates/engine/src/cmd/panelcmds.rs | 4 +- crates/engine/src/cmd/plugin_call/reviewed.rs | 3 + crates/engine/src/command_sets.rs | 8 +- crates/mcp/src/ui_catalog.rs | 30 +- crates/ui-egui/src/chrome/control_bar.rs | 21 ++ crates/ui-egui/src/chrome/tests.rs | 43 +++ crates/ui-egui/src/menus/state.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 30 +- crates/ui-egui/src/panels/align.rs | 98 ++++++- crates/ui-egui/src/panels/align/tests.rs | 93 ++++++ docs/mcp.md | 49 +++- docs/ui-parity.md | 1 + 15 files changed, 873 insertions(+), 50 deletions(-) create mode 100644 crates/engine/src/cmd/artboardalign.rs create mode 100644 crates/engine/src/cmd/artboardalign/tests.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 465025302f..038b6e9635 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -4,6 +4,7 @@ pub mod actionset; mod addons; mod apiref; pub(crate) mod appearance; +mod artboardalign; mod assets; mod attributes; pub mod brushlib; @@ -334,6 +335,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(variablecmds::specs()); v.extend(docinfo::specs()); v.extend(panelcmds::specs()); + v.extend(artboardalign::specs()); v.extend(buildcmds::specs()); v.extend(shaper::specs()); v.extend(brushsym::specs()); diff --git a/crates/engine/src/cmd/artboardalign.rs b/crates/engine/src/cmd/artboardalign.rs new file mode 100644 index 0000000000..9832133729 --- /dev/null +++ b/crates/engine/src/cmd/artboardalign.rs @@ -0,0 +1,262 @@ +//! Align and Distribute for artboards: what the Align panel's buttons run while the Artboard tool +//! is chosen. + +use serde_json::{Value, json}; +use vectorcraft_doc::Scaling; +use vectorcraft_geom::{Rect, Vec2}; + +use super::object::choice; +use super::panelcmds::{artboard_index, artboards_art, locked_and_hidden, named_or_selected, note_artboards}; +use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, switch_or}; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!( + "artboard.align", + "Align", + [], + None, + "{index? | indices?: [n…], selectedArtboards?: [n…] (as artboard.duplicate; default: the artboards the Artboard tool has selected, else its active one), horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\" (one of the two is needed), to?: \"selection\"|\"artboard\" (default \"selection\": the bounds of the artboards together; \"artboard\": artboard `artboard`, which stays where it is), artboard?: n (0-based; the artboard to align to with to: \"artboard\"; default: the Artboard tool's active one; it need not be one of the artboards aligned), activeArtboard?: n (the journal records that default here, for a replay; `artboard` goes ahead of it), moveArt?: bool (the art fully inside each artboard moves with it; default: the Artboard tool's Move Artwork with Artboard option), patterns?: bool, lockedAndHidden?: bool (as artboard.move)} (Align panel › Align Objects with the Artboard tool chosen) each artboard moves until its edge or center is on that edge or center of what it aligns to, with its guides, in one undo step; a locked artboard stays where it is and still counts in the bounds, and the call fails with `the artboard is locked` when every artboard it names is locked; when no artboard moves, no undo step is recorded → {moved: [index…]}", + has_doc, + align + ), + cmd!( + "artboard.distribute", + "Distribute", + [], + None, + "{index? | indices?, selectedArtboards? (as artboard.align), horizontal?: \"left\"|\"center\"|\"right\" | vertical?: \"top\"|\"center\"|\"bottom\" (one is needed; horizontal goes first), to?: \"selection\"|\"artboard\", artboard?: n, activeArtboard?: n (as artboard.align; with `spacing` the artboard must be one of those distributed), spacing?: pt (used with to: \"artboard\"), moveArt?, patterns?, lockedAndHidden? (as artboard.align)} (Align panel › Distribute Objects with the Artboard tool chosen) the named edges or centers of the artboards are spaced evenly between the first and the last along the axis, which stay where they are (three or more artboards); with to: \"artboard\", the artboards are spaced the same way and then moved together so that artboard `artboard`, when it is one of them, stays where it is; with to: \"artboard\" and `spacing`, that artboard stays and the edges or centers are placed `spacing` apart, outward from it in their order (two or more); a locked artboard stays where it is and keeps its place in the order, and the call fails with `the artboard is locked` when every artboard it names is locked; a spacing that would move an artboard past the canvas is an error; with fewer artboards, or when none moves, no undo step is recorded → {moved: [index…]}", + has_doc, + distribute + ), + cmd!( + "artboard.distributeSpacing", + "Distribute Spacing", + [], + None, + "{index? | indices?, selectedArtboards? (as artboard.align), axis?: \"horizontal\"|\"vertical\" (default horizontal), to?: \"selection\"|\"artboard\", artboard?: n, activeArtboard?: n (as artboard.distribute), spacing?: pt (used with to: \"artboard\"), moveArt?, patterns?, lockedAndHidden? (as artboard.align)} (Align panel › Distribute Spacing with the Artboard tool chosen) the gaps between the artboards along the axis become equal, the first and the last staying where they are (three or more artboards); with to: \"artboard\", the gaps become equal the same way and the artboards then move together so that artboard `artboard`, when it is one of them, stays where it is; with to: \"artboard\" and `spacing`, that artboard stays and the others are placed with a gap of `spacing` between neighbors (two or more; a negative spacing overlaps them); a locked artboard stays where it is and keeps its place in the order, and the call fails with `the artboard is locked` when every artboard it names is locked; a spacing that would move an artboard past the canvas is an error; with fewer artboards, or when none moves, no undo step is recorded → {moved: [index…]}", + has_doc, + distribute_spacing + ), + ] +} + +/// What an artboard Align or Distribute command acts on, read from its params. +struct Boards { + /// The artboards named, in artboard order, each with its rectangle. + items: Vec<(usize, Rect)>, + /// With `to: "artboard"`, the artboard to align to and its rectangle. + reference: Option<(usize, Rect)>, + /// Whether the art inside each artboard moves with it. + move_art: bool, + /// Whether locked and hidden art moves too. + locked_art: bool, +} + +/// Reads the params that `artboard.align`, `artboard.distribute` and `artboard.distributeSpacing` +/// (command `cmd`) share, and adds the artboards and the art choice to the journal entry. The +/// artboard to align to is `artboard`, else `activeArtboard` (a replay of the journal), else the +/// Artboard tool's active one, which the journal entry then records as `activeArtboard`. It fails +/// when every artboard named is locked. +fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result { + check_switches(p, &["patterns"], cmd)?; + let to_artboard = choice(p, cmd, "to", &["selection", "artboard"])? == Some("artboard"); + let indices = named_or_selected(s, p, cmd)?; + let given = |key: &str| -> Result> { + match p.get(key).filter(|v| !v.is_null()) { + Some(v) => artboard_index(&s.doc()?.doc, &json!({ "index": v }), cmd).map(Some), + None => Ok(None), + } + }; + let (named, recorded) = (given("artboard")?, given("activeArtboard")?); + let move_art = switch_or(p, "moveArt", s.tool_options_of("artboard")["moveArt"].as_bool().unwrap_or(true), cmd)?; + let locked_art = locked_and_hidden(s, p, move_art, cmd)?; + note_artboards(s, p, &indices); + s.note_journal("moveArt", json!(move_art)); + let reference = match (to_artboard, named.or(recorded)) { + (false, _) => None, + (true, Some(i)) => Some(i), + (true, None) => { + let active = s.tool_options_of("artboard")["active"].clone(); + Some(artboard_index(&s.doc()?.doc, &json!({ "index": active }), cmd)?) + } + }; + if let Some(i) = reference.filter(|_| named.is_none()) { + s.note_journal("activeArtboard", json!(i)); + } + let d = &s.doc()?.doc; + let rect = |i: usize| d.artboards.get(i).map(|a| (i, a.rect)).ok_or_else(|| EngineError::Other("no such artboard".into())); + let items = indices.iter().map(|i| rect(*i)).collect::>>()?; + if indices.iter().all(|i| d.artboards.get(*i).is_some_and(|a| a.locked)) { + return Err(EngineError::Other("the artboard is locked".into())); + } + Ok(Boards { items, reference: reference.map(rect).transpose()?, move_art, locked_art }) +} + +/// `spacing` of the Distribute commands: a finite number, or `None` when it is missing or null. +fn spacing(p: &Value, cmd: &str) -> Result> { + p.get("spacing") + .filter(|v| !v.is_null()) + .map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad(cmd, "`spacing` must be a number"))) + .transpose() +} + +/// The place in `items` of the artboard `b` aligns to, which the Distribute commands keep where it +/// is. `None` without one, or when it is not one of `items`; with a `gap` that is an error +/// (`no such artboard`), since the gap is measured from it. +fn kept(b: &Boards, items: &[(usize, Rect)], gap: Option) -> Result> { + let Some((reference, _)) = b.reference else { return Ok(None) }; + match items.iter().position(|(i, _)| *i == reference) { + None if gap.is_some() => Err(EngineError::Other("no such artboard".into())), + at => Ok(at), + } +} + +/// Moves each artboard of `moves` by its offset as undo step `label`, with its guides and, when +/// `b` says so, the art inside it. Locked artboards stay where they are. When no artboard moves +/// more than 1e-9 pt on either axis, the document and its undo history stay as they are. The edit +/// fails with nothing changed when an artboard would end up past the canvas, as every edit does. +fn apply(s: &mut Session, label: &str, p: &Value, b: &Boards, moves: &[(usize, Vec2)]) -> Result { + let d = &s.doc()?.doc; + let moves: Vec<(usize, Vec2)> = + moves.iter().copied().filter(|(i, dv)| (dv.x.abs() > 1e-9 || dv.y.abs() > 1e-9) && d.artboards.get(*i).is_some_and(|a| !a.locked)).collect(); + let moved: Vec = moves.iter().map(|(i, _)| *i).collect(); + if moves.is_empty() { + return Ok(json!({ "moved": moved })); + } + let arts = artboards_art(s, &moved, b.move_art, b.locked_art, true)?; + let ids: Vec = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect(); + let sc = Scaling { patterns: super::object::transform_patterns(s, p, &ids)?, ..Scaling::default() }; + s.edit(label, |d, _| { + for ((i, dv), (_, art)) in moves.iter().zip(&arts) { + let a = d.artboards.get_mut(*i).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + a.rect = a.rect + *dv; + let id = a.id; + d.move_artboard_guides(id, *dv); + for id in art { + if let Some(n) = d.node_mut(*id) { + n.transform(Affine::translate(*dv), sc); + super::object::keep_pixel_aligned(n, sc); + } + } + } + Ok(()) + })?; + Ok(json!({ "moved": moved })) +} + +fn align(s: &mut Session, p: &Value) -> Result { + const C: &str = "artboard.align"; + let h = choice(p, C, "horizontal", &["left", "center", "right"])?; + let v = choice(p, C, "vertical", &["top", "center", "bottom"])?; + if h.is_none() && v.is_none() { + return Err(bad(C, "give horizontal or vertical")); + } + let b = boards(s, p, C)?; + let Some(r) = b.reference.map(|(_, r)| r).or_else(|| b.items.iter().map(|(_, r)| *r).reduce(|a, b| a.union(b))) else { + return Ok(json!({ "moved": [] })); + }; + let moves: Vec<(usize, Vec2)> = b + .items + .iter() + .map(|(i, a)| { + let dx = match h { + Some("left") => r.x0 - a.x0, + Some("center") => r.center().x - a.center().x, + Some("right") => r.x1 - a.x1, + _ => 0.0, + }; + let dy = match v { + Some("top") => r.y0 - a.y0, + Some("center") => r.center().y - a.center().y, + Some("bottom") => r.y1 - a.y1, + _ => 0.0, + }; + (*i, Vec2::new(dx, dy)) + }) + .collect(); + apply(s, "Align", p, &b, &moves) +} + +fn distribute(s: &mut Session, p: &Value) -> Result { + const C: &str = "artboard.distribute"; + let h = choice(p, C, "horizontal", &["left", "center", "right"])?; + let v = choice(p, C, "vertical", &["top", "center", "bottom"])?; + let (horiz, line): (bool, fn(&Rect) -> f64) = match (h, v) { + (Some("left"), _) => (true, |r| r.x0), + (Some("right"), _) => (true, |r| r.x1), + (Some(_), _) => (true, |r| r.center().x), + (None, Some("top")) => (false, |r| r.y0), + (None, Some("bottom")) => (false, |r| r.y1), + (None, Some(_)) => (false, |r| r.center().y), + (None, None) => return Err(bad(C, "give horizontal or vertical")), + }; + let gap = spacing(p, C)?; + let b = boards(s, p, C)?; + let mut items = b.items.clone(); + items.sort_by(|x, y| line(&x.1).total_cmp(&line(&y.1))); + let kept = kept(&b, &items, gap)?; + // With a spacing: the place in the order and the line of the artboard it is measured from. + let from = kept.zip(gap).and_then(|(at, gap)| items.get(at).map(|(_, r)| (at, gap, line(r)))); + let n = items.len(); + // Two artboards are the ends already; with a spacing the second is placed from the first. + if n < if from.is_some() { 2 } else { 3 } { + return Ok(json!({ "moved": [] })); + } + let (Some((_, first)), Some((_, last))) = (items.first(), items.last()) else { return Ok(json!({ "moved": [] })) }; + let (first, last) = (line(first), line(last)); + let deltas: Vec = items + .iter() + .enumerate() + .map(|(k, (_, r))| { + let target = match from { + Some((at, gap, origin)) => origin + gap * (k as f64 - at as f64), + None => first + (last - first) * k as f64 / (n - 1) as f64, + }; + target - line(r) + }) + .collect(); + // The artboard aligned to stays where it is, and the others keep their places from it. + let hold = kept.and_then(|at| deltas.get(at)).copied().unwrap_or(0.0); + let moves: Vec<(usize, Vec2)> = items.iter().zip(&deltas).map(|((i, _), d)| (*i, along(horiz, d - hold))).collect(); + apply(s, "Distribute", p, &b, &moves) +} + +fn distribute_spacing(s: &mut Session, p: &Value) -> Result { + const C: &str = "artboard.distributeSpacing"; + let horiz = choice(p, C, "axis", &["horizontal", "vertical"])? != Some("vertical"); + let gap = spacing(p, C)?; + let b = boards(s, p, C)?; + let start = |r: &Rect| if horiz { r.x0 } else { r.y0 }; + let size = |r: &Rect| if horiz { r.width() } else { r.height() }; + let mut items = b.items.clone(); + items.sort_by(|x, y| start(&x.1).total_cmp(&start(&y.1))); + let kept = kept(&b, &items, gap)?; + let gap = gap.filter(|_| kept.is_some()); + let n = items.len(); + if n < if gap.is_some() { 2 } else { 3 } { + return Ok(json!({ "moved": [] })); + } + let (Some((_, first)), Some((_, last))) = (items.first(), items.last()) else { return Ok(json!({ "moved": [] })) }; + let (lo, hi) = (start(first), start(last) + size(last)); + let gap = gap.unwrap_or_else(|| (hi - lo - items.iter().map(|(_, r)| size(r)).sum::()) / (n - 1) as f64); + let mut pos = lo; + let mut deltas = Vec::with_capacity(n); + for (i, r) in &items { + deltas.push((*i, pos - start(r))); + pos += size(r) + gap; + } + // The artboard aligned to stays where it is, and the others keep their places from it. + let hold = kept.and_then(|at| deltas.get(at)).map_or(0.0, |d| d.1); + let moves: Vec<(usize, Vec2)> = deltas.into_iter().map(|(i, d)| (i, along(horiz, d - hold))).collect(); + apply(s, "Distribute Spacing", p, &b, &moves) +} + +/// An offset of `d` along the axis: x when `horiz`, else y. +fn along(horiz: bool, d: f64) -> Vec2 { + if horiz { Vec2::new(d, 0.0) } else { Vec2::new(0.0, d) } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/artboardalign/tests.rs b/crates/engine/src/cmd/artboardalign/tests.rs new file mode 100644 index 0000000000..94733b099e --- /dev/null +++ b/crates/engine/src/cmd/artboardalign/tests.rs @@ -0,0 +1,277 @@ +//! Align and Distribute for artboards. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use crate::Session; +use crate::tooling::ViewInfo; + +/// A document with three artboards, each with a 10 pt square 5 pt inside its top-left corner: +/// 0 at (0, 0) 100 × 100, 1 at (150, 40) 60 × 80 and 2 at (400, 10) 100 × 50 → the squares' ids. +fn three_boards() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("artboard.new", &json!({"x": 150, "y": 40, "width": 60, "height": 80})).unwrap(); + s.execute("artboard.new", &json!({"x": 400, "y": 10, "width": 100, "height": 50})).unwrap(); + let mut sq = |x: u64, y: u64| s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let ids = [sq(5, 5), sq(155, 45), sq(405, 15)]; + s.execute("select.none", &json!({})).unwrap(); + (s, ids) +} + +/// The top-left corner of each artboard. +fn corners(s: &Session) -> Vec<(f64, f64)> { + s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0)).collect() +} + +/// The top-left corner of object `id`. +fn corner(s: &Session, id: u64) -> (f64, f64) { + let b = s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap(); + (b.x0, b.y0) +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +const ALL: [u64; 3] = [0, 1, 2]; + +/// Align Objects lines the artboards up on the bounds of all of them, each with the art inside +/// it, in one undo step. +#[test] +fn align_moves_the_artboards_to_their_common_edge_with_their_art() { + let (mut s, [a, b, c]) = three_boards(); + let before = corners(&s); + let steps = undo_steps(&s); + let out = s.execute("artboard.align", &json!({"indices": ALL, "vertical": "top"})).unwrap(); + assert_eq!(out, json!({"moved": [1, 2]})); + assert_eq!(corners(&s), [(0.0, 0.0), (150.0, 0.0), (400.0, 0.0)]); + assert_eq!([corner(&s, a), corner(&s, b), corner(&s, c)], [(5.0, 5.0), (155.0, 5.0), (405.0, 5.0)], "each square stays on its artboard"); + assert_eq!(undo_steps(&s), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((corners(&s), corner(&s, b)), (before, (155.0, 45.0)), "one undo step"); + s.execute("artboard.align", &json!({"indices": ALL, "horizontal": "right", "vertical": "bottom"})).unwrap(); + assert_eq!(corners(&s), [(400.0, 20.0), (440.0, 40.0), (400.0, 70.0)], "the right edges at 500 and the bottom edges at 120"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("artboard.align", &json!({"indices": ALL, "horizontal": "center", "moveArt": false})).unwrap(); + assert_eq!(corners(&s), [(200.0, 0.0), (220.0, 40.0), (200.0, 10.0)], "the centers at 250"); + assert_eq!(corner(&s, a), (5.0, 5.0), "moveArt false leaves the art"); +} + +/// Align to an artboard: that artboard stays where it is and the others line up on it. It need not +/// be one of the artboards aligned. +#[test] +fn align_to_an_artboard_keeps_that_artboard_in_place() { + let (mut s, _) = three_boards(); + let out = s.execute("artboard.align", &json!({"indices": ALL, "horizontal": "left", "to": "artboard", "artboard": 2})).unwrap(); + assert_eq!(out, json!({"moved": [0, 1]})); + assert_eq!(corners(&s), [(400.0, 0.0), (400.0, 40.0), (400.0, 10.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("artboard.align", &json!({"index": 0, "vertical": "center", "to": "artboard", "artboard": 1})).unwrap(); + assert_eq!(corners(&s), [(0.0, 30.0), (150.0, 40.0), (400.0, 10.0)], "artboard 1's center is at y 80"); +} + +/// With no artboard named, the commands act on the artboards the Artboard tool has selected and +/// align to its active one; the journal records both for a replay, and an action leaves them out. +#[test] +fn the_artboard_tools_selection_is_the_default_and_is_recorded() { + let (mut s, [_, _, c]) = three_boards(); + s.select_tool("artboard", ViewInfo::default()).unwrap(); + s.set_tool_option("selected", &json!([1, 2])); + let start = s.journal.len(); + assert_eq!(s.execute("artboard.align", &json!({"horizontal": "left"})).unwrap(), json!({"moved": [2]})); + assert_eq!(corners(&s), [(0.0, 0.0), (150.0, 40.0), (150.0, 10.0)]); + assert_eq!(corner(&s, c), (155.0, 15.0), "the tool's Move Artwork with Artboard option is on"); + let recorded = json!({"horizontal": "left", "selectedArtboards": [1, 2], "moveArt": true, "lockedAndHidden": false}); + assert_eq!(s.journal.last().unwrap(), &("artboard.align".to_string(), recorded)); + assert_eq!( + s.journal_for_action(start), + [("artboard.align".to_string(), json!({"horizontal": "left", "moveArt": true, "lockedAndHidden": false}))] + ); + s.execute("artboard.align", &json!({"vertical": "top", "to": "artboard"})).unwrap(); + assert_eq!(corners(&s)[2], (150.0, 40.0), "artboard 2 lines up on the active artboard, 1"); + assert_eq!(s.journal.last().unwrap().1["activeArtboard"], json!(1)); + assert_eq!(s.journal_for_action(start)[1].1.get("activeArtboard"), None, "an action aligns to the artboard active when it plays"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("artboard.align", &json!({"vertical": "top", "to": "artboard", "activeArtboard": 0})).unwrap(); + assert_eq!(corners(&s)[1..], [(150.0, 0.0), (150.0, 0.0)], "a replay aligns to the artboard it recorded, 0"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("artboard.align", &json!({"vertical": "bottom", "to": "artboard", "artboard": 0, "activeArtboard": 2})).unwrap(); + assert_eq!(corners(&s)[1..], [(150.0, 20.0), (150.0, 50.0)], "`artboard` goes ahead of the recorded one"); + let action = s.journal_for_action(start); + let entry = &action.last().unwrap().1; + assert_eq!((entry.get("artboard"), entry.get("activeArtboard")), (Some(&json!(0)), None), "an action keeps the artboard a call named"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("artboard.align", &json!({"vertical": "top", "to": "artboard"})).unwrap(); + s.set_tool_option("moveArt", &json!(false)); + s.execute("artboard.align", &json!({"horizontal": "right"})).unwrap(); + assert_eq!((corners(&s)[1], corner(&s, c)), ((190.0, 40.0), (155.0, 45.0)), "with the option off the art stays"); +} + +/// A command that moves no artboard leaves the document and its undo history as they are. +#[test] +fn nothing_to_move_records_no_undo_step() { + let (mut s, _) = three_boards(); + s.execute("artboard.align", &json!({"indices": ALL, "vertical": "top"})).unwrap(); + let (steps, before) = (undo_steps(&s), corners(&s)); + for (id, p) in [ + ("artboard.align", json!({"indices": ALL, "vertical": "top"})), + ("artboard.align", json!({"index": 1, "horizontal": "left", "vertical": "bottom"})), + ("artboard.align", json!({"index": 1, "horizontal": "center", "to": "artboard", "artboard": 1})), + ("artboard.distribute", json!({"indices": [0, 2], "horizontal": "center"})), + ("artboard.distribute", json!({"index": 2, "vertical": "top", "to": "artboard", "artboard": 2, "spacing": 30})), + ("artboard.distributeSpacing", json!({"indices": [0, 1], "axis": "horizontal"})), + ("artboard.distributeSpacing", json!({"indices": [1], "axis": "vertical"})), + ] { + assert_eq!(s.execute(id, &p).unwrap(), json!({"moved": []}), "{id} {p}"); + assert_eq!((undo_steps(&s), corners(&s)), (steps, before.clone()), "{id} {p}"); + } +} + +/// A locked artboard stays where it is and still counts in the bounds and in the order. When every +/// artboard named is locked, the command fails. +#[test] +fn a_locked_artboard_stays_where_it_is() { + let (mut s, _) = three_boards(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + assert_eq!(s.execute("artboard.align", &json!({"indices": ALL, "vertical": "bottom"})).unwrap(), json!({"moved": [0, 2]})); + assert_eq!(corners(&s), [(0.0, 20.0), (150.0, 40.0), (400.0, 70.0)], "the bottom edges at 120, the locked artboard's"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("artboard.distributeSpacing", &json!({"indices": ALL})).unwrap(), json!({"moved": []}), "the middle artboard is locked"); + let out = + s.execute("artboard.distribute", &json!({"indices": ALL, "horizontal": "left", "to": "artboard", "artboard": 0, "spacing": 100})).unwrap(); + assert_eq!((out, corners(&s)), (json!({"moved": [2]}), vec![(0.0, 0.0), (150.0, 40.0), (200.0, 10.0)]), "the third left edge goes to 200"); + s.execute("edit.undo", &json!({})).unwrap(); + let (steps, before) = (undo_steps(&s), corners(&s)); + for (id, p) in [ + ("artboard.align", json!({"index": 1, "horizontal": "left", "to": "artboard", "artboard": 0})), + ("artboard.distribute", json!({"index": 1, "horizontal": "left"})), + ("artboard.distributeSpacing", json!({"indices": [1]})), + ] { + assert_eq!(s.execute(id, &p).unwrap_err().to_string(), "the artboard is locked", "{id}"); + assert_eq!((undo_steps(&s), corners(&s)), (steps, before.clone()), "{id}"); + } +} + +/// Distribute Objects spaces the named edges or centers evenly between the outermost artboards; +/// with a spacing and an artboard to align to, it places them that far apart from that artboard. +#[test] +fn distribute_spaces_the_edges_or_centers() { + let (mut s, [_, b, _]) = three_boards(); + assert_eq!(s.execute("artboard.distribute", &json!({"indices": ALL, "horizontal": "center"})).unwrap(), json!({"moved": [1]})); + assert_eq!(corners(&s), [(0.0, 0.0), (220.0, 40.0), (400.0, 10.0)], "the centers at 50, 250 and 450"); + assert_eq!(corner(&s, b), (225.0, 45.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("artboard.distribute", &json!({"indices": ALL, "vertical": "bottom"})).unwrap(); + assert_eq!(corners(&s), [(0.0, -10.0), (150.0, 40.0), (400.0, 10.0)], "the bottom edges at 60, 90 and 120"); + s.execute("edit.undo", &json!({})).unwrap(); + let p = json!({"indices": ALL, "horizontal": "left", "to": "artboard", "artboard": 1, "spacing": 100}); + assert_eq!(s.execute("artboard.distribute", &p).unwrap(), json!({"moved": [0, 2]})); + assert_eq!(corners(&s), [(50.0, 0.0), (150.0, 40.0), (250.0, 10.0)], "the left edges 100 apart, artboard 1 in place"); + s.execute("edit.undo", &json!({})).unwrap(); + let two = json!({"indices": [0, 2], "horizontal": "right", "to": "artboard", "artboard": 2, "spacing": 150}); + assert_eq!(s.execute("artboard.distribute", &two).unwrap(), json!({"moved": [0]})); + assert_eq!(corners(&s)[0], (250.0, 0.0), "two artboards are enough with a spacing: the right edges at 350 and 500"); + s.execute("edit.undo", &json!({})).unwrap(); + let held = json!({"indices": ALL, "horizontal": "center", "to": "artboard", "artboard": 1}); + assert_eq!(s.execute("artboard.distribute", &held).unwrap(), json!({"moved": [0, 2]})); + assert_eq!(corners(&s), [(-70.0, 0.0), (150.0, 40.0), (330.0, 10.0)], "the centers 200 apart, artboard 1 in place"); +} + +/// Distribute Spacing makes the gaps between the artboards equal; with a spacing and an artboard +/// to align to, the gaps are that wide and that artboard stays. +#[test] +fn distribute_spacing_makes_the_gaps_equal() { + let (mut s, _) = three_boards(); + assert_eq!(s.execute("artboard.distributeSpacing", &json!({"indices": ALL})).unwrap(), json!({"moved": [1]})); + assert_eq!(corners(&s), [(0.0, 0.0), (220.0, 40.0), (400.0, 10.0)], "gaps of 120 between 0 and 500"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("artboard.distributeSpacing", &json!({"indices": ALL, "axis": "vertical"})).unwrap(); + assert_eq!(corners(&s), [(0.0, 0.0), (150.0, 40.0), (400.0, 45.0)], "top to bottom: artboards 0, 2 and 1, overlapping by 55"); + s.execute("edit.undo", &json!({})).unwrap(); + let p = json!({"indices": ALL, "axis": "horizontal", "to": "artboard", "artboard": 1, "spacing": 20}); + assert_eq!(s.execute("artboard.distributeSpacing", &p).unwrap(), json!({"moved": [0, 2]})); + assert_eq!(corners(&s), [(30.0, 0.0), (150.0, 40.0), (230.0, 10.0)], "gaps of 20 on both sides of artboard 1"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("artboard.distributeSpacing", &json!({"indices": [0, 2], "to": "artboard", "artboard": 0, "spacing": -25})).unwrap(); + assert_eq!(corners(&s)[2], (75.0, 10.0), "a negative spacing overlaps them"); + s.execute("edit.undo", &json!({})).unwrap(); + let held = json!({"indices": ALL, "to": "artboard", "artboard": 1}); + assert_eq!(s.execute("artboard.distributeSpacing", &held).unwrap(), json!({"moved": [0, 2]})); + assert_eq!(corners(&s), [(-70.0, 0.0), (150.0, 40.0), (330.0, 10.0)], "gaps of 120, artboard 1 in place"); +} + +/// A value outside what a param takes is an error, and nothing changes. +#[test] +fn bad_params_are_errors() { + let (mut s, _) = three_boards(); + let (steps, before) = (undo_steps(&s), corners(&s)); + let with = |extra: Value| { + let mut p = json!({"indices": ALL, "horizontal": "left"}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + p + }; + let shared = [ + json!({"horizontal": "middle"}), + json!({"horizontal": 1}), + json!({"to": "key"}), + json!({"to": "artboard", "artboard": 9}), + json!({"artboard": "1"}), + json!({"artboard": -1}), + json!({"moveArt": "yes"}), + json!({"patterns": 1}), + json!({"lockedAndHidden": "no"}), + json!({"indices": [9]}), + json!({"indices": []}), + json!({"indices": "0"}), + ]; + for id in ["artboard.align", "artboard.distribute", "artboard.distributeSpacing"] { + let axis = if id == "artboard.distributeSpacing" { json!({"horizontal": null, "axis": "horizontal"}) } else { json!({}) }; + for extra in &shared { + let mut p = with(axis.clone()); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + if id == "artboard.distributeSpacing" && extra.get("horizontal").is_some() { + p = json!({"indices": ALL, "axis": extra["horizontal"]}); + } + assert!(s.execute(id, &p).is_err(), "{id} {p}"); + } + } + for (id, p) in [ + ("artboard.align", json!({"indices": ALL})), + ("artboard.align", json!({"indices": ALL, "vertical": "middle"})), + ("artboard.distribute", json!({"indices": ALL})), + ("artboard.distribute", with(json!({"spacing": "10"}))), + ("artboard.distribute", with(json!({"spacing": true}))), + ("artboard.distribute", json!({"indices": [0, 1], "horizontal": "left", "to": "artboard", "artboard": 2, "spacing": 10})), + ("artboard.distributeSpacing", json!({"indices": ALL, "spacing": [10]})), + ("artboard.distributeSpacing", json!({"indices": [0, 1], "to": "artboard", "artboard": 2, "spacing": 10})), + ] { + assert!(s.execute(id, &p).is_err(), "{id} {p}"); + } + // Spacings that would put an artboard past the canvas, or overflow to infinity. + for (id, p) in [ + ("artboard.distribute", with(json!({"to": "artboard", "artboard": 0, "spacing": 1e9}))), + ("artboard.distribute", with(json!({"to": "artboard", "artboard": 1, "spacing": 1e308}))), + ("artboard.distributeSpacing", json!({"indices": ALL, "to": "artboard", "artboard": 0, "spacing": 1e9})), + ("artboard.distributeSpacing", json!({"indices": ALL, "to": "artboard", "artboard": 1, "spacing": 1e308})), + ] { + let e = s.execute(id, &p).unwrap_err().to_string(); + assert!(e.contains("exceed the canvas"), "{id} {p}: {e}"); + } + assert_eq!((undo_steps(&s), corners(&s)), (steps, before)); +} + +/// An artboard's own guides move with it. +#[test] +fn an_artboards_guides_move_with_it() { + let (mut s, _) = three_boards(); + s.execute("guide.add", &json!({"vertical": true, "pos": 160, "artboard": 1})).unwrap(); + s.execute("artboard.align", &json!({"indices": [0, 1], "horizontal": "left"})).unwrap(); + let d = &s.doc().unwrap().doc; + let positions: Vec = d.ruler_guides().iter().map(|g| g.pos).collect(); + assert_eq!(positions, [10.0], "the guide stays 10 pt inside artboard 1"); +} diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 83a0655284..9f05168f63 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -122,7 +122,7 @@ pub(crate) fn boards_and_art_noted(s: &mut Session, p: &Value, cmd: &str) -> Res /// Add `selectedArtboards` to the running command's journal entry when `p` names no artboard, so a /// replay acts on the artboards the Artboard tool had selected. An action leaves it out /// (`REPLAY_ONLY`) and keeps an `index` or `indices` the call gave. -fn note_artboards(s: &mut Session, p: &Value, indices: &[usize]) { +pub(crate) fn note_artboards(s: &mut Session, p: &Value, indices: &[usize]) { if p.get("index").is_none() && p.get("indices").is_none() { s.note_journal("selectedArtboards", json!(indices)); } @@ -130,7 +130,7 @@ fn note_artboards(s: &mut Session, p: &Value, indices: &[usize]) { /// The artboards `cmd` acts on: `index` or `indices`, else `selectedArtboards` (a replay of the /// journal), else those the Artboard tool has selected, else its active one. -fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result> { +pub(crate) fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result> { let t = s.tool_options_of("artboard"); let d = &s.doc()?.doc; if p.get("index").is_some() || p.get("indices").is_some() { diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 4c9abdb357..c1fc4d6d0a 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -85,9 +85,12 @@ pub(super) const ALLOWED: &[&str] = &[ "appearance.showAllHidden", "appearance.targetContents", "appearance.transfer", + "artboard.align", "artboard.convertToArtboards", "artboard.delete", "artboard.deleteEmpty", + "artboard.distribute", + "artboard.distributeSpacing", "artboard.duplicate", "artboard.fitToArt", "artboard.fitToSelection", diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 95b44776f0..3eff647cc2 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -28,7 +28,7 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo /// Artboard tool had selected for its commands and for Paste in Place, in Front and in Back; the artboards and art Cut and Copy took with that /// tool chosen; the view's center the app passes to Edit Symbol); an action leaves them out /// ([`Session::journal_for_action`]). -const REPLAY_ONLY: [(&str, &str); 29] = [ +const REPLAY_ONLY: [(&str, &str); 35] = [ ("file.new", "created"), ("edit.newDocumentFromState", "created"), ("document.save", "modified"), @@ -50,6 +50,12 @@ const REPLAY_ONLY: [(&str, &str); 29] = [ ("artboard.cut", "selectedArtboards"), ("artboard.rename", "selectedArtboards"), ("artboard.switchOrientation", "selectedArtboards"), + ("artboard.align", "selectedArtboards"), + ("artboard.align", "activeArtboard"), + ("artboard.distribute", "selectedArtboards"), + ("artboard.distribute", "activeArtboard"), + ("artboard.distributeSpacing", "selectedArtboards"), + ("artboard.distributeSpacing", "activeArtboard"), ("edit.copy", "selectedArtboards"), ("edit.copy", "art"), ("edit.cut", "selectedArtboards"), diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index b07964b225..51a9addf70 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -261,91 +261,91 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "align.horizontalLeft", "Horizontal Align Left", "", - "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.horizontalCenter", "Horizontal Align Center", "", - "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.horizontalRight", "Horizontal Align Right", "", - "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.horizontalVerticalCenter", "Horizontal & Vertical Align Center", "", - "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.verticalTop", "Vertical Align Top", "", - "{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.verticalCenter", "Vertical Align Center", "", - "{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.verticalBottom", "Vertical Align Bottom", "", - "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeVerticalTop", "Vertical Distribute Top", "", - "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeVerticalCenter", "Vertical Distribute Center", "", - "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeVerticalBottom", "Vertical Distribute Bottom", "", - "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeHorizontalLeft", "Horizontal Distribute Left", "", - "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeHorizontalCenter", "Horizontal Distribute Center", "", - "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeHorizontalRight", "Horizontal Distribute Right", "", - "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeVerticalSpace", "Vertical Distribute Space", "", - "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeHorizontalSpace", "Horizontal Distribute Space", "", - "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "type.bold", diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 53bfb98c1c..e7c4145397 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -282,6 +282,26 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize transform } +/// Draws the Control bar's Align group after the artboard controls, with the Artboard tool in use: +/// the Align buttons, the Align To menu and, for two or more selected artboards, the Distribute +/// buttons. They align and distribute the selected artboards +/// ([`crate::panels::align::run_button`]). Each shows only while the bar has room for it, and none +/// while the panel menu has the group off. +fn artboard_align_group(app: &mut VectorcraftApp, ui: &mut Ui) { + if !group_on(app, "alignDistribute") || app.session.active().is_none() || ui.available_width() < separator_width(ui) + buttons_width(ui, 7) { + return; + } + separator(ui); + crate::panels::align::align_buttons(app, ui, BUTTON); + if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x { + crate::panels::align::align_to_menu(app, ui, BUTTON); + if crate::panels::align::artboards_selected(app) > 1 && ui.available_width() >= GROUP_GAP + buttons_width(ui, 6) { + ui.add_space(GROUP_GAP); + crate::panels::align::distribute_buttons(app, ui, BUTTON); + } + } +} + /// The edges of the window the Control bar docks to (`window.control.dock {edge}`): the top, then /// the bottom. pub const CONTROL_BAR_EDGES: [&str; 2] = ["top", "bottom"]; @@ -516,6 +536,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { // of the object controls. let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform")); if crate::panels::artboards::control_bar(app, ui, artboard_options, transform) { + artboard_align_group(app, ui); return; } if anchors != AnchorControls::None && group_on(app, "anchors") { diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index d7620e8d03..9350180586 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -2669,6 +2669,49 @@ fn menus_stop_above_a_control_bar_docked_at_the_bottom() { assert!(f < bar_top - 1.0, "the status and hint bars show above the Control bar: menus stop above them, {f}"); } +/// With the Artboard tool in use, the Control bar shows the Align buttons after the artboard +/// controls while it has room, and the Distribute buttons for two or more selected artboards. A +/// click aligns the selected artboards as the Align panel's button does. The group is left out +/// while the panel menu has it off. +#[test] +fn the_control_bar_aligns_the_selected_artboards_with_the_artboard_tool() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("artboard.new", json!({"x": 150, "y": 40, "width": 60, "height": 80})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(2400.0, 300.0)); + let frame = |app: &mut crate::VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default() + }; + let button = |app: &mut crate::VectorcraftApp, name: &str| { + frame(app, vec![]); + let nodes = frame(app, vec![]); + let n = nodes.iter().find(|(_, n)| n.label() == Some(name)).map(|(_, n)| n)?; + n.bounds().map(|b| egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32))) + }; + app.session.set_tool_option("selected", &json!([1])); + assert!(button(&mut app, "Vertical Align Top").is_some(), "one artboard: the Align buttons"); + assert!(button(&mut app, "Horizontal Distribute Left").is_none(), "one artboard: no Distribute buttons"); + app.session.set_tool_option("selected", &json!([1, 0])); + assert!(button(&mut app, "Horizontal Distribute Left").is_some(), "two artboards: the Distribute buttons"); + let r = button(&mut app, "Vertical Align Top").expect("Vertical Align Top"); + let press = + |pressed| egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for events in [vec![egui::Event::PointerMoved(r.center()), press(true)], vec![press(false)]] { + frame(&mut app, events); + } + let tops: Vec = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect.y0).collect(); + assert_eq!(tops, [0.0, 0.0], "{}", app.ui.status); + app.run("window.control.options", json!({"group": "alignDistribute", "visible": false})).unwrap(); + assert!(button(&mut app, "Vertical Align Top").is_none(), "the group is off"); +} + /// Two or more selected objects show all six Distribute Objects buttons in the Control bar; /// a single object keeps the bar as it was. A click distributes as the Align panel's button does. #[test] diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index d68608824b..e5c35f6c6e 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -225,7 +225,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { } id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(), "type.applyBullets" | "type.applyNumbers" | "type.convertListToText" => crate::panels::character::text_style(app).is_some(), - id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()), + id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command_now(app)), "file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()), "file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()), "file.cancelOpen" => !app.opening.pending.is_empty(), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f435bfff22..3800d7e71c 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -262,91 +262,91 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "align.horizontalLeft", "Horizontal Align Left", "", - "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.horizontalCenter", "Horizontal Align Center", "", - "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.horizontalRight", "Horizontal Align Right", "", - "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.horizontalVerticalCenter", "Horizontal & Vertical Align Center", "", - "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.verticalTop", "Vertical Align Top", "", - "{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.verticalCenter", "Vertical Align Center", "", - "{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.verticalBottom", "Vertical Align Bottom", "", - "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeVerticalTop", "Vertical Distribute Top", "", - "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeVerticalCenter", "Vertical Distribute Center", "", - "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeVerticalBottom", "Vertical Distribute Bottom", "", - "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeHorizontalLeft", "Horizontal Distribute Left", "", - "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeHorizontalCenter", "Horizontal Distribute Center", "", - "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeHorizontalRight", "Horizontal Distribute Right", "", - "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeVerticalSpace", "Vertical Distribute Space", "", - "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "align.distributeHorizontalSpace", "Horizontal Distribute Space", "", - "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"", ), ( "type.bold", diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 82005595f0..1888ea177f 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -1,6 +1,7 @@ //! Align panel: Align Objects, Distribute Objects and Distribute Spacing (both with the spacing //! value when aligning to a key object or a key guide) and Align To (selection / key object / -//! artboard). +//! artboard). With the Artboard tool chosen, the buttons align and distribute the artboards the +//! tool has selected. use egui::Ui; use serde::Serialize; @@ -104,7 +105,7 @@ pub enum Button { } impl Button { - /// The engine command the button runs. + /// The engine command the button runs on the selected objects. pub(crate) fn command(self) -> &'static str { match self { Button::Align(..) | Button::AlignCenters => "object.align", @@ -112,6 +113,51 @@ impl Button { Button::Spacing(_) => "object.distributeSpacing", } } + + /// The engine command the button runs on the selected artboards ([`on_artboards`]). + fn artboard_command(self) -> &'static str { + match self { + Button::Align(..) | Button::AlignCenters => "artboard.align", + Button::Distribute(..) => "artboard.distribute", + Button::Spacing(_) => "artboard.distributeSpacing", + } + } + + /// The engine command a click on the button runs now: the artboard command with the Artboard + /// tool chosen, else the object command. + pub(crate) fn command_now(self, app: &VectorcraftApp) -> &'static str { + if on_artboards(app) { self.artboard_command() } else { self.command() } + } +} + +/// Whether the Align panel's buttons, and the Control bar's, align and distribute artboards: the +/// Artboard tool is chosen. They then act on the artboards the tool has selected. +pub(crate) fn on_artboards(app: &VectorcraftApp) -> bool { + app.session.tool_id() == "artboard" +} + +/// The number of artboards the Artboard tool has selected ([`super::artboards::picked`]). +pub(crate) fn artboards_selected(app: &VectorcraftApp) -> usize { + let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); + if n == 0 { 0 } else { super::artboards::picked(app, n).len() } +} + +/// Runs what a click on button `b` runs with the Artboard tool chosen: the artboard command on +/// the artboards the tool has selected. With Align to Artboard chosen, it sends `to: "artboard"`, +/// which keeps the tool's active artboard in place, and the Distribute buttons send the +/// Distribute Spacing value. With Align to Selection chosen, it sends `to: "selection"`. +fn run_on_artboards(app: &mut VectorcraftApp, b: Button) -> Result { + let to_artboard = app.ui.align_to == AlignTo::Artboard; + let mut p = match b { + Button::Align(axis, v) | Button::Distribute(axis, v) => json!({axis: v}), + Button::AlignCenters => json!({"horizontal": "center", "vertical": "center"}), + Button::Spacing(axis) => json!({"axis": axis}), + }; + p["to"] = json!(if to_artboard { "artboard" } else { "selection" }); + if to_artboard && matches!(b, Button::Distribute(..) | Button::Spacing(_)) { + p["spacing"] = json!(app.ui.align_spacing); + } + app.run(b.artboard_command(), p) } /// The Align panel's buttons as UI commands ([`crate::menus::UI_COMMANDS`]), in the panel's order: @@ -143,8 +189,12 @@ pub(crate) fn button(id: &str) -> Option