diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs new file mode 100644 index 00000000..ec84333a --- /dev/null +++ b/crates/doc/src/assets.rs @@ -0,0 +1,57 @@ +//! Asset Export (Window → Asset Export, Object → Collect for Export): pieces of art collected to +//! export on their own, at the export settings Export for Screens shares. +//! +//! An asset names objects rather than copying them: it follows edits (a moved object moves the +//! crop, a recoloured one exports recoloured) and lets go of objects that are deleted +//! ([`Document::prune_assets`]; an asset left with none goes too). Asset ids come from the +//! document's id counter, so they never repeat within a document. + +use serde::{Deserialize, Serialize}; + +use crate::{Document, NodeId}; + +/// One asset: what it is called (its files' names) and the objects it exports, cropped to their +/// visual bounds. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExportAsset { + pub id: u64, + pub name: String, + pub nodes: Vec, +} + +/// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`… +const ASSET: &str = "Asset "; + +impl Document { + /// Asset `id`. + pub fn asset(&self, id: u64) -> Option<&ExportAsset> { + self.assets.iter().find(|a| a.id == id) + } + + /// The name of the next unnamed asset: one more than the highest `Asset N` so far. + pub fn next_asset_name(&self) -> String { + let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::().ok()).max().unwrap_or(0); + format!("{ASSET}{}", n.saturating_add(1)) + } + + /// Forget the objects of assets that are no longer in the document, and the assets left with + /// none (after every edit). + pub fn prune_assets(&mut self) { + if self.assets.iter().all(|a| a.nodes.iter().all(|id| self.node(*id).is_some())) { + return; + } + let mut assets = std::mem::take(&mut self.assets); + for a in &mut assets { + a.nodes.retain(|id| self.node(*id).is_some()); + } + assets.retain(|a| !a.nodes.is_empty()); + self.assets = assets; + } + + /// `ids` that are in the document, back to front. + pub fn paint_order(&self, ids: impl IntoIterator) -> Vec { + let mut v: Vec<(Vec, NodeId)> = ids.into_iter().filter_map(|id| self.index_path(id).map(|p| (p, id))).collect(); + v.sort(); + v.into_iter().map(|(_, id)| id).collect() + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 24713339..2e04dc6b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] pub mod appearance; +pub mod assets; pub mod graph; pub mod hit; pub mod inks; @@ -49,6 +50,7 @@ pub use appearance::{ Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, }; +pub use assets::ExportAsset; pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; @@ -526,6 +528,10 @@ pub struct Document { /// as JSON: the print engine sits above this crate); `None` until they are set up. #[serde(default, skip_serializing_if = "Option::is_none")] pub print_setup: Option, + /// Window → Asset Export: art collected for export ([`ExportAsset`]), in panel order. Their + /// export settings are Export for Screens' ([`Document::export_settings`]). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assets: Vec, } fn ppi72() -> f64 { @@ -592,6 +598,7 @@ impl Document { slices: vec![], slices_clip_to_artboard: true, print_setup: None, + assets: vec![], }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); @@ -614,6 +621,7 @@ impl Document { l.walk(&mut |n| max = max.max(n.id.0)); } max = self.slices.iter().fold(max, |m, s| m.max(s.id.0)); + max = self.assets.iter().fold(max, |m, a| m.max(a.id)); self.next_id = self.next_id.max(max + 1); } diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 6c598f87..70ce0fe3 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -117,9 +117,7 @@ impl Selection { } /// Top-level ordering: selected ids sorted by paint order (bottom first). pub fn in_paint_order(&self, doc: &Document) -> Vec { - let mut v: Vec<(Vec, NodeId)> = self.objects.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect(); - v.sort(); - v.into_iter().map(|(_, id)| id).collect() + doc.paint_order(self.objects.iter().copied()) } } diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs new file mode 100644 index 00000000..c8a817d5 --- /dev/null +++ b/crates/engine/src/cmd/assets.rs @@ -0,0 +1,227 @@ +//! Window → Asset Export and Object → Collect for Export: art collected as assets +//! ([`vectorcraft_doc::ExportAsset`]), exported each on its own, cropped to its art, at the export +//! settings Export for Screens shares (`document.exportSettings`). File → Export Selection collects +//! the selection this way and opens Export for Screens on its Assets tab (a UI command). + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::{ExportAsset, NodeId}; + +use super::edit::{roots_of, selected_roots}; +use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_screens, store_screen_settings}; +use super::*; + +/// The longest asset name kept (characters). +const MAX_NAME: usize = 255; + +pub fn specs() -> Vec { + vec![ + cmd!( + "assets.add", + "Collect for Export", + ["Object", "Collect for Export"], + None, + "{ids?: [id…] (default: the selected objects), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset", + has_doc, + add + ), + cmd!( + "assets.remove", + "Remove Asset", + [], + None, + "{assets: [asset id…]} take assets out of the Asset Export panel (their art stays) → {removed: n}", + has_doc, + remove + ), + cmd!("assets.rename", "Rename Asset", [], None, "{asset: id, name} rename an asset (its files' names)", has_doc, rename), + cmd!( + query "assets.list", + "Assets", + [], + None, + "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the visual bounds of its art)}]} the Asset Export panel's assets, in order", + has_doc, + list + ), + cmd!( + "assets.export", + "Export Assets", + [], + None, + "{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + has_doc, + export + ), + cmd!( + "assets.settings.set", + "Asset Export Settings", + [], + None, + "{formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step", + has_doc, + settings_set + ), + ] +} + +/// `e` as an error of command `cmd` (for errors of the export underneath). +fn of_cmd(cmd: &str, e: EngineError) -> EngineError { + match e { + EngineError::BadParams { msg, .. } => bad(cmd, msg), + e => e, + } +} + +/// `name` trimmed and at most [`MAX_NAME`] characters long (`None` when empty). +fn clean_name(name: &str) -> Option { + let n: String = name.trim().chars().take(MAX_NAME).collect(); + (!n.is_empty()).then_some(n) +} + +fn add(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.add"; + let ids = match ids_param(p, "ids") { + Some(ids) => { + let d = &s.doc()?.doc; + roots_of(d, d.paint_order(ids)) + } + None => selected_roots(s)?, + }; + if ids.is_empty() { + return Err(bad(C, "select the art to collect (or give its ids)")); + } + let name = str_param(p, "name").and_then(clean_name); + let sets: Vec> = if bool_or(p, "multiple", true) { ids.iter().map(|id| vec![*id]).collect() } else { vec![ids] }; + if name.is_some() && sets.len() > 1 { + return Err(bad(C, "a name names one asset: give multiple: false, or one object")); + } + let existing = |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes).map(|a| a.id); + let d = &s.doc()?.doc; + // Nothing new: no undo step. + if sets.iter().all(|set| existing(d, set).is_some()) { + return Ok(json!({ "assets": sets.iter().filter_map(|set| existing(d, set)).collect::>(), "added": 0 })); + } + s.edit("Collect for Export", |d, _| { + let (mut out, mut added) = (vec![], 0); + for nodes in sets { + if let Some(id) = existing(d, &nodes) { + out.push(id); + continue; + } + let own = match nodes.as_slice() { + [one] => d.node(*one).and_then(|n| n.name.as_deref()).and_then(clean_name), + _ => None, + }; + let name = name.clone().or(own).unwrap_or_else(|| d.next_asset_name()); + let id = d.alloc_id().0; + d.assets.push(ExportAsset { id, name, nodes }); + out.push(id); + added += 1; + } + Ok(json!({ "assets": out, "added": added })) + }) +} + +/// The asset ids of param `key`. +fn asset_ids(p: &Value, key: &str, cmd: &str) -> Result> { + let ids = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{key}` must be an array of asset ids (see assets.list)")))?; + ids.iter().map(|v| v.as_u64().ok_or_else(|| bad(cmd, format!("{v} is not an asset id")))).collect() +} + +fn remove(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.remove"; + let ids = asset_ids(p, "assets", C)?; + let n = s.doc()?.doc.assets.iter().filter(|a| ids.contains(&a.id)).count(); + if n == 0 { + return Err(bad(C, "no such assets (see assets.list)")); + } + s.edit("Remove Asset", |d, _| { + d.assets.retain(|a| !ids.contains(&a.id)); + Ok(json!({ "removed": n })) + }) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.rename"; + let id = p.get("asset").and_then(Value::as_u64).ok_or_else(|| bad(C, "`asset` is the asset id (see assets.list)"))?; + let name = str_param(p, "name").and_then(clean_name).ok_or_else(|| bad(C, "give the asset a name"))?; + let at = s.doc()?.doc.assets.iter().position(|a| a.id == id).ok_or_else(|| bad(C, format!("no asset {id}")))?; + s.edit("Rename Asset", |d, _| { + if let Some(a) = d.assets.get_mut(at) { + a.name = name; + } + ok() + }) +} + +fn list(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let rows: Vec = d + .assets + .iter() + .map(|a| { + let bounds = a.nodes.iter().filter_map(|id| d.node(*id)?.visual_bounds()).reduce(|x, y| x.union(y)); + json!({ + "id": a.id, + "name": a.name, + "nodes": a.nodes.iter().map(|n| n.0).collect::>(), + "bounds": bounds.map(|b| [b.x0, b.y0, b.width(), b.height()]), + }) + }) + .collect(); + Ok(json!({ "assets": rows })) +} + +/// The shared export settings of the document (the [`SCREEN_SETTINGS_KEYS`] it has). +fn shared_settings(s: &Session) -> Result> { + let saved = &s.doc()?.doc.export_settings; + Ok(SCREEN_SETTINGS_KEYS.iter().filter_map(|k| Some((k.to_string(), saved.get(*k).filter(|v| !v.is_null())?.clone()))).collect()) +} + +/// `base` with the settings of `p` over it: a given value replaces (null removes) the saved one; +/// formats drop a saved preset and a preset (`""`: none) saved formats. +fn overlay(base: &mut Map, p: &Map) { + let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + if given("formats") && !p.contains_key("preset") { + base.remove("preset"); + } + if given("preset") && !p.contains_key("formats") { + base.remove("formats"); + } + for (k, v) in p { + match v { + Value::Null => _ = base.remove(k), + Value::String(id) if k == "preset" && id.is_empty() => _ = base.remove(k), + v => _ = base.insert(k.clone(), v.clone()), + } + } +} + +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.export"; + let mut q = shared_settings(s)?; + if let Some(o) = p.as_object() { + overlay(&mut q, o); + } + if !q.contains_key("assets") { + let all: Vec = s.doc()?.doc.assets.iter().map(|a| a.id).collect(); + if all.is_empty() { + return Err(bad(C, "no assets yet: collect art for export first (assets.add)")); + } + q.insert("assets".into(), json!(all)); + } + export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e)) +} + +fn settings_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.settings.set"; + let o = p.as_object().ok_or_else(|| bad(C, "give the settings to change, e.g. {\"formats\": [{\"format\": \"png\", \"scale\": \"2x\"}]}"))?; + if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str())) { + return Err(bad(C, format!("`{k}` isn't an export setting: {}", SCREEN_SETTINGS_KEYS.join(", ")))); + } + let mut settings = s.doc()?.doc.export_settings.clone(); + overlay(&mut settings, o); + check_screen_settings(s, &Value::Object(settings.clone())).map_err(|e| of_cmd(C, e))?; + store_screen_settings(s, settings)?; + Ok(json!({ "settings": shared_settings(s)? })) +} diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index c9b7716f..2c2ffffc 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -125,15 +125,22 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom:: let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); // On a template layer or sublayer, at any depth. let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); - let nodes: Vec> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect(); - let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(cmd, "select something to export"))?; - let mut d = single_artboard(&st.doc, bounds, "Selection"); - let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); + let ids: Vec = ids.into_iter().filter(|id| !on_template(*id)).collect(); + isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export")) +} + +/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard +/// named `name`: their visual bounds (`None` when they have none). +pub(super) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> { + let nodes: Vec> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect(); + let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))?; + let mut d = single_artboard(doc, bounds, name); + let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0)); if let Some(ch) = layer.children_mut() { *ch = nodes; } d.layers = vec![Arc::new(layer)]; - Ok((d, bounds)) + Some((d, bounds)) } /// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 95f779cf..1339a4cf 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -58,6 +58,9 @@ pub use save::{ templates_folder, }; pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows}; +pub(crate) use screens::{ + SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings, +}; pub use svg::options_map as svg_options; /// Atomic file writes (a temporary file renamed over the target: a failed write never damages the /// file it replaces), for the apps' own writers too. @@ -110,7 +113,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png). The params are remembered in the document (document.exportSettings; not an undo step)", + "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)", has_doc, screens::export_for_screens ), @@ -721,14 +724,20 @@ pub(crate) fn create_dir(path: &str) -> Result<()> { /// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without /// case: `Icon` and `icon` are one file on most desktop file systems). pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec { + unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))) +} + +/// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe +/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used +/// (compared without case). +pub(crate) fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { let mut taken = std::collections::HashSet::new(); - boards - .iter() - .map(|&b| { - let name = doc.artboards.get(b).map_or("", |a| a.name.as_str()); + names + .into_iter() + .map(|(name, fallback)| { let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); if base.is_empty() { - base = format!("Artboard-{}", b + 1); + base = fallback; } let mut name = base.clone(); for i in 2.. { diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index dedd5f9b..62ff1894 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -1,7 +1,8 @@ //! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the -//! whole document) in every format row at each row's size, into a folder and its sub-folders, or -//! returned for download (each file, or one ZIP); the presets the dialog offers; and the settings -//! the document remembers (`document.exportSettings`). +//! whole document, or the chosen assets of the Asset Export panel) in every format row at each +//! row's size, into a folder and its sub-folders, or returned for download (each file, or one ZIP); +//! the presets the dialog offers; and the settings the document remembers +//! (`document.exportSettings`), which the Asset Export panel shares ([`SHARED_KEYS`]). use std::collections::HashSet; use std::sync::Arc; @@ -13,8 +14,8 @@ use vectorcraft_geom::Rect; use super::super::*; use super::encode::single_artboard; -use super::export::without_artboards; -use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file}; +use super::export::{isolated, without_artboards}; +use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, writable, write_file}; const C: &str = "document.exportForScreens"; @@ -219,6 +220,10 @@ impl Row { } } +/// The settings the Asset Export panel shares with the dialog: what is exported, not which art +/// or where to. +pub(crate) const SHARED_KEYS: [&str; 6] = ["formats", "preset", "settings", "prefix", "subfolders", "antiAlias"]; + /// What `document.exportForScreens` reads besides the formats. #[derive(Default, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -232,6 +237,45 @@ struct ScreenParams { subfolders: bool, preset: Option, prefix: Option, + /// Asset ids (Asset Export) to write instead of artboards. + assets: Option>, +} + +impl ScreenParams { + fn of(p: &Value) -> Result { + if p.is_object() { Self::deserialize(p).map_err(|e| bad(C, e.to_string())) } else { Ok(Self::default()) } + } +} + +/// The format rows `p` asks for (`formats`, else `preset`'s, else one PNG at 1x), each with the +/// format's `settings` and the top-level `antiAlias`. +fn rows_of(s: &Session, p: &Value, preset: Option<&str>) -> Result> { + let settings = p.get("settings").cloned().unwrap_or(Value::Null); + let rows: Vec = match (preset, p.get("formats")) { + (Some(_), Some(f)) if !f.is_null() => return Err(bad(C, "give formats or a preset, not both")), + (Some(id), _) => preset_rows(id).ok_or_else(|| bad(C, format!("preset `{id}`: mobile or density")))?, + (None, Some(Value::Array(rows))) => rows.clone(), + (None, None | Some(Value::Null)) => vec![json!({})], + (None, Some(v)) => return Err(bad(C, format!("formats must be an array of rows, not {v}"))), + }; + let mut rows: Vec = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::>()?; + if rows.is_empty() { + return Err(bad(C, "no formats to export")); + } + // A top-level anti-aliasing mode applies to every row that doesn't name its own. + if let Some(aa) = p.get("antiAlias") { + for r in &mut rows { + if let Some(m) = r.options.as_object_mut() { + m.entry("antiAlias").or_insert_with(|| aa.clone()); + } + } + } + Ok(rows) +} + +/// Check the export settings in `p` (formats, preset, settings…) as an export would read them. +pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> { + rows_of(s, p, ScreenParams::of(p)?.preset.as_deref()).map(|_| ()) } /// The file names (and bytes) an export wrote, or the files it returns. @@ -272,31 +316,23 @@ impl Output { } } -/// File → Export for Screens: every chosen artboard in every format row, one file each (a PDF holds -/// its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard, else the -/// bounds of all art). Artboards that share a name get `-2`, `-3`… instead of overwriting. The -/// params are remembered in the document (`document.exportSettings`). +/// File → Export for Screens: [`export`], with the params remembered in the document +/// (`document.exportSettings`). pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { - let o: ScreenParams = if p.is_object() { ScreenParams::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ScreenParams::default() }; - let settings = p.get("settings").cloned().unwrap_or(Value::Null); - let rows: Vec = match (o.preset.as_deref(), p.get("formats")) { - (Some(_), Some(f)) if !f.is_null() => return Err(bad(C, "give formats or a preset, not both")), - (Some(id), _) => preset_rows(id).ok_or_else(|| bad(C, format!("preset `{id}`: mobile or density")))?, - (None, Some(Value::Array(rows))) => rows.clone(), - (None, None | Some(Value::Null)) => vec![json!({})], - (None, Some(v)) => return Err(bad(C, format!("formats must be an array of rows, not {v}"))), - }; - let mut rows: Vec = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::>()?; - if rows.is_empty() { - return Err(bad(C, "no formats to export")); - } - // A top-level anti-aliasing mode applies to every row that doesn't name its own. - if let Some(aa) = p.get("antiAlias") { - for r in &mut rows { - if let Some(m) = r.options.as_object_mut() { - m.entry("antiAlias").or_insert_with(|| aa.clone()); - } - } + let r = export(s, p)?; + remember(s, p); + Ok(r) +} + +/// Every chosen artboard in every format row, one file each (a PDF holds its artboard alone), or +/// with `fullDocument` one file per row (a PDF of every artboard, else the bounds of all art), or +/// with `assets` each asset's art alone, cropped to it. Artboards (assets) that share a name get +/// `-2`, `-3`… instead of overwriting. +pub(crate) fn export(s: &mut Session, p: &Value) -> Result { + let o = ScreenParams::of(p)?; + let rows = rows_of(s, p, o.preset.as_deref())?; + if o.full_document && o.assets.is_some() { + return Err(bad(C, "give assets or fullDocument, not both")); } let subfolders = o.subfolders || o.preset.is_some(); // A prefix names files, not folders. @@ -347,6 +383,24 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { }; out.put(folder, o.zip, name, bytes)?; } + } else if let Some(ids) = &o.assets { + let assets = + ids.iter().map(|id| doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))).collect::>>()?; + if assets.is_empty() { + return Err(bad(C, "no assets to export")); + } + for (a, stem) in assets.iter().zip(unique_file_names(assets.iter().map(|a| (a.name.as_str(), "Asset".to_string())))) { + let (d, bounds) = isolated(&doc, &doc.paint_order(a.nodes.iter().copied()), &a.name) + .ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?; + for r in &rows { + let name = name_of(r, &stem); + if !out.claim(&name) { + continue; + } + let bytes = encode(&d, r.format.id, &r.options_for(bounds, Some(0))?)?; + out.put(folder, o.zip, name, bytes)?; + } + } } else { let n = doc.artboards.len(); let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()); @@ -365,7 +419,6 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { } } } - remember(s, p); let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect(); if o.zip { let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?; @@ -387,17 +440,26 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { }) } -/// Keep the params of a successful export in the document (without `zip`, a delivery detail), so -/// the dialog reopens on them. Not an undo step; the document has changed only if they differ. +/// Keep the params of a successful export in the document (without `zip`, a delivery detail, and +/// `assets`, a choice of art), so the dialog reopens on them. fn remember(s: &mut Session, p: &Value) { let Some(o) = p.as_object() else { return }; let mut settings = o.clone(); settings.remove("zip"); - let Ok(st) = s.doc_mut() else { return }; + settings.remove("assets"); + // Only after a successful export, which had a document. + let _ = store_settings(s, settings); +} + +/// Make `settings` the document's export settings. Not an undo step (like the settings of any +/// export); the document has changed only if they differ. +pub(crate) fn store_settings(s: &mut Session, settings: Map) -> Result<()> { + let st = s.doc_mut()?; if st.doc.export_settings != settings { Arc::make_mut(&mut st.doc).export_settings = settings; st.revision += 1; } + Ok(()) } /// `document.exportSettings`: the Export for Screens settings the document last exported with. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 45378e66..f81e023e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,6 +1,7 @@ //! The command registry. Ids follow Illustrator's menu structure. pub(crate) mod appearance; +mod assets; mod attributes; mod brushsym; mod buildcmds; @@ -235,6 +236,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(recovery::specs()); v.extend(fileio::dxfimport::specs()); v.extend(webexport::specs()); + v.extend(assets::specs()); v }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 29103d00..ed569292 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -957,6 +957,10 @@ impl Session { if !st.doc.text_threads.is_empty() { cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc)); } + // Asset Export: assets let go of deleted art. + if !st.doc.assets.is_empty() { + Arc::make_mut(&mut st.doc).prune_assets(); + } if doc_sane(&st.doc, &st.selection) { Ok(v) } else { @@ -1078,6 +1082,8 @@ mod tests; #[cfg(test)] mod tests_appearance; #[cfg(test)] +mod tests_assets; +#[cfg(test)] mod tests_attributes; #[cfg(test)] mod tests_brushsym; diff --git a/crates/engine/src/tests_assets.rs b/crates/engine/src/tests_assets.rs new file mode 100644 index 00000000..2a674b15 --- /dev/null +++ b/crates/engine/src/tests_assets.rs @@ -0,0 +1,267 @@ +//! Asset Export: collecting art as assets (single and multiple), assets following their art +//! (moves, deletions), names, undo, the native round trip, the shared export settings and +//! exporting assets alone, cropped to their art. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + // Unstroked art: visual bounds are the geometry. + s.paint.stroke = Paint::None; + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> u64 { + run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap() +} + +fn assets(s: &mut Session) -> Vec { + run(s, "assets.list", json!({}))["assets"].as_array().unwrap().clone() +} + +fn names(s: &mut Session) -> Vec { + assets(s).iter().map(|a| a["name"].as_str().unwrap().to_string()).collect() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// `(name, bytes)` of each file an export returned. +fn files(r: &Value) -> Vec<(String, Vec)> { + r["files"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["name"].as_str().unwrap().to_string(), vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap())) + .collect() +} + +#[test] +fn multiple_gives_an_asset_per_object_and_single_one_of_them_all() { + let mut s = session(); + let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0)); + run(&mut s, "select.set", json!({"ids": [a, b]})); + let r = run(&mut s, "assets.add", json!({})); + assert_eq!(r["added"], 2); + let list = assets(&mut s); + assert_eq!(list.len(), 2); + assert_eq!((list[0]["nodes"].clone(), list[1]["nodes"].clone()), (json!([a]), json!([b]))); + assert_eq!(r["assets"], json!([list[0]["id"], list[1]["id"]])); + let single = run(&mut s, "assets.add", json!({"multiple": false})); + assert_eq!(single["added"], 1); + let list = assets(&mut s); + assert_eq!(list.len(), 3); + assert_eq!(list[2]["nodes"], json!([a, b]), "one asset of both, back to front"); + assert_eq!(list[2]["bounds"], json!([10.0, 10.0, 70.0, 20.0]), "the crop is the art's bounds"); + // Collecting the same art again keeps its assets (and isn't an undo step). + let steps = undo_len(&s); + let again = run(&mut s, "assets.add", json!({"ids": [b, a]})); + assert_eq!((again["added"].clone(), again["assets"].clone()), (json!(0), json!([list[0]["id"], list[1]["id"]]))); + assert_eq!(undo_len(&s), steps); + // Layers aren't art; nothing to collect is an error. + let layer = s.doc().unwrap().doc.layers[0].id.0; + assert!(s.execute("assets.add", &json!({"ids": [layer]})).is_err()); + run(&mut s, "select.set", json!({"ids": []})); + assert!(s.execute("assets.add", &json!({})).is_err()); + assert!(s.execute("assets.add", &json!({"ids": [a, b], "name": "Two"})).is_err(), "a name names one asset"); +} + +#[test] +fn deleting_the_object_prunes_it_and_undo_brings_it_back() { + let mut s = session(); + let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0)); + run(&mut s, "assets.add", json!({"ids": [a]})); + run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false})); + run(&mut s, "select.set", json!({"ids": [a]})); + run(&mut s, "edit.clear", json!({})); + let list = assets(&mut s); + assert_eq!(list.len(), 1, "the asset of the deleted object goes"); + assert_eq!(list[0]["nodes"], json!([b]), "the other keeps the art that is left"); + run(&mut s, "edit.undo", json!({})); + let list = assets(&mut s); + assert_eq!((list.len(), list[1]["nodes"].clone()), (2, json!([a, b]))); +} + +#[test] +fn moving_the_art_moves_the_crop() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + run(&mut s, "assets.add", json!({"ids": [a]})); + let before = files(&run(&mut s, "assets.export", json!({}))); + run(&mut s, "select.set", json!({"ids": [a]})); + run(&mut s, "object.move", json!({"dx": 40, "dy": 25})); + assert_eq!(assets(&mut s)[0]["bounds"], json!([50.0, 35.0, 20.0, 20.0])); + let after = files(&run(&mut s, "assets.export", json!({}))); + assert_eq!(before, after, "the same art, cropped the same way"); + assert_eq!(after[0].0, "Asset-1.png"); +} + +#[test] +fn an_asset_exports_its_art_alone() { + let mut s = session(); + s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let red = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + // Blue art over it isn't part of the asset. + s.paint.fill = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + rect(&mut s, 0.0, 0.0, 25.0, 25.0); + run(&mut s, "assets.add", json!({"ids": [red]})); + let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "2x"}]}))); + assert_eq!(out[0].0, "Asset-1@2x.png"); + let img = image::load_from_memory(&out[0].1).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (40, 40)); + assert!(img.pixels().all(|p| p.0 == [255, 0, 0, 255]), "only the red square"); + // Vector formats crop to it too. + let svg = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "svg"}]}))); + let text = String::from_utf8(svg[0].1.clone()).unwrap(); + assert_eq!(svg[0].0, "Asset-1.svg"); + assert!(text.contains("viewBox=\"10 10 20 20\"") || text.contains("viewBox=\"0 0 20 20\""), "{text}"); + assert!(!text.to_lowercase().contains("#0000ff"), "no blue: {text}"); +} + +#[test] +fn names_follow_the_object_or_count_up_and_can_be_changed() { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 20.0, 20.0), rect(&mut s, 90.0, 10.0, 20.0, 20.0)); + run(&mut s, "layer.setProps", json!({"id": b, "name": "Icon"})); + run(&mut s, "assets.add", json!({"ids": [a, b]})); + assert_eq!(names(&mut s), ["Asset 1", "Icon"]); + let first = assets(&mut s)[0]["id"].as_u64().unwrap(); + run(&mut s, "assets.rename", json!({"asset": first, "name": " Asset 7 "})); + run(&mut s, "assets.add", json!({"ids": [c]})); + assert_eq!(names(&mut s), ["Asset 7", "Icon", "Asset 8"], "trimmed; numbering goes on from the highest"); + run(&mut s, "assets.add", json!({"ids": [a, c], "multiple": false, "name": "Pair"})); + assert_eq!(names(&mut s)[3], "Pair"); + assert!(s.execute("assets.rename", &json!({"asset": first, "name": " "})).is_err()); + assert!(s.execute("assets.rename", &json!({"asset": 999_999, "name": "X"})).is_err()); + // Assets that share a name (in any case) get -2, -3… instead of overwriting. + let ids: Vec = assets(&mut s).iter().map(|a| a["id"].as_u64().unwrap()).collect(); + run(&mut s, "assets.rename", json!({"asset": ids[0], "name": "icon"})); + let out = files(&run(&mut s, "assets.export", json!({"assets": [ids[0], ids[1]]}))); + assert_eq!(out.iter().map(|f| f.0.as_str()).collect::>(), ["icon.png", "Icon-2.png"]); +} + +#[test] +fn add_remove_and_rename_are_undo_steps() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let steps = undo_len(&s); + let id = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + run(&mut s, "assets.rename", json!({"asset": id, "name": "Logo"})); + run(&mut s, "assets.remove", json!({"assets": [id]})); + assert_eq!(undo_len(&s), steps + 3); + assert!(assets(&mut s).is_empty()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Logo"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Asset 1"]); + run(&mut s, "edit.undo", json!({})); + assert!(assets(&mut s).is_empty()); + run(&mut s, "edit.redo", json!({})); + assert_eq!(names(&mut s), ["Asset 1"]); + assert!(s.execute("assets.remove", &json!({"assets": [12345]})).is_err()); + assert!(s.execute("assets.remove", &json!({})).is_err()); +} + +#[test] +fn assets_round_trip_through_the_native_format() { + let mut s = session(); + let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0)); + run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false, "name": "Both"})); + let saved = s.doc().unwrap().doc.assets.clone(); + let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "assets.vectorcraft", "dataBase64": b64})); + assert_eq!(s.doc().unwrap().doc.assets, saved); + // A new asset never takes an id already used. + let c = rect(&mut s, 90.0, 10.0, 20.0, 20.0); + let id = run(&mut s, "assets.add", json!({"ids": [c]}))["assets"][0].as_u64().unwrap(); + assert!(saved.iter().all(|x| x.id != id)); + // Documents without assets don't write the key, and files without it load. + let mut d = vectorcraft_doc::Document::new(10.0, 10.0); + let text = serde_json::to_string(&d).unwrap(); + assert!(!text.contains("\"assets\"")); + d = serde_json::from_str(&text).unwrap(); + assert!(d.assets.is_empty()); +} + +#[test] +fn the_shared_settings_drive_the_export_and_arent_an_undo_step() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + run(&mut s, "assets.add", json!({"ids": [a]})); + let steps = undo_len(&s); + let r = run(&mut s, "assets.settings.set", json!({"formats": [{"format": "png", "scale": "1x"}, {"format": "svg"}], "prefix": "ic_"})); + assert_eq!(r["settings"]["prefix"], "ic_"); + assert_eq!(undo_len(&s), steps); + assert!(s.doc().unwrap().is_dirty()); + // Export for Screens reads them too (they are its remembered settings). + assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][1]["format"], "svg"); + let out = files(&run(&mut s, "assets.export", json!({}))); + assert_eq!(out.iter().map(|f| f.0.as_str()).collect::>(), ["ic_Asset-1.png", "ic_Asset-1.svg"]); + // A preset replaces the rows (and turns on sub-folders); rows replace a preset. + run(&mut s, "assets.settings.set", json!({"preset": "mobile"})); + let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone(); + assert!(settings.get("formats").is_none() && settings["preset"] == "mobile"); + let out = files(&run(&mut s, "assets.export", json!({}))); + assert_eq!(out.iter().map(|f| f.0.as_str()).collect::>(), ["1x/ic_Asset-1.png", "2x/ic_Asset-1@2x.png", "3x/ic_Asset-1@3x.png"]); + run(&mut s, "assets.settings.set", json!({"formats": [{"format": "jpg", "quality": 80}], "preset": ""})); + let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone(); + assert!(settings.get("preset").is_none() && settings["formats"][0]["format"] == "jpg"); + // Params given to one export win without changing the settings. + let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "webp"}], "prefix": null}))); + assert_eq!(out[0].0, "Asset-1.webp"); + assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][0]["format"], "jpg"); + // Bad settings are refused and change nothing. + for bad in [json!({"formats": [{"format": "dxf"}]}), json!({"preset": "tiny"}), json!({"folder": "/tmp"}), json!([])] { + assert!(s.execute("assets.settings.set", &bad).is_err(), "{bad}"); + } + assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][0]["format"], "jpg"); +} + +#[test] +fn export_for_screens_writes_chosen_assets_and_keeps_its_rules() { + let mut s = session(); + let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0)); + let ids = run(&mut s, "assets.add", json!({"ids": [a, b]}))["assets"].clone(); + let r = run(&mut s, "document.exportForScreens", json!({"assets": [ids[1]], "formats": [{"format": "png", "scale": "60w"}]})); + let out = files(&r); + assert_eq!(out.len(), 1); + assert_eq!(out[0].0, "Asset-2@60w.png"); + assert_eq!(image::load_from_memory(&out[0].1).unwrap().to_rgba8().dimensions(), (60, 40), "the width of the asset, not the artboard"); + // The choice of art isn't remembered; the formats are. + let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone(); + assert!(settings.get("assets").is_none() && settings["formats"][0]["scale"] == "60w"); + // A zip of every asset. + let zip = run(&mut s, "document.exportForScreens", json!({"assets": ids, "zip": true})); + assert_eq!(zip["files"], json!(["Asset-1.png", "Asset-2.png"])); + for bad in [json!({"assets": [ids[0]], "fullDocument": true}), json!({"assets": [424242]}), json!({"assets": []}), json!({"assets": "all"})] { + assert!(s.execute("document.exportForScreens", &bad).is_err(), "{bad}"); + } + // No assets: nothing for assets.export to do. + run(&mut s, "assets.remove", json!({"assets": ids})); + assert!(s.execute("assets.export", &json!({})).is_err()); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn assets_export_into_a_folder() { + let dir = std::env::temp_dir().join(format!("vc-assets-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let folder = dir.to_string_lossy().replace('\\', "/"); + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + run(&mut s, "assets.add", json!({"ids": [a], "name": "Logo"})); + let r = + run(&mut s, "assets.export", json!({"folder": folder, "subfolders": true, "formats": [{"format": "png", "scale": "2x"}, {"format": "pdf"}]})); + assert_eq!(r["files"], json!([format!("{folder}/2x/Logo@2x.png"), format!("{folder}/PDF/Logo.pdf")])); + assert!(dir.join("2x/Logo@2x.png").is_file() && dir.join("PDF/Logo.pdf").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 104bbe51..1bd0b46f 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -35,7 +35,7 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[ ( "file.exportForScreens", "Export for Screens…", - "{folder?, zip?, artboards? | range? | fullDocument?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)", + "{folder?, zip?, artboards? | range? | fullDocument? | assets?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)", ), ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), ]; diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 6db7141b..a0beef9f 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -1,14 +1,16 @@ //! File → Export → Export for Screens: the Artboards tab (All, a range or Full Document, Include -//! Bleed, thumbnails with checkboxes) and the Assets tab, the destination (folder picker, Open -//! Location after Export, Create Sub-folders; the web downloads instead), the format rows (scale -//! as `2x`, `100w`, `100h` or `72ppi`, suffix, format), the presets, Format Settings (the gear) -//! and the prefix. It opens on the settings the document last exported with. +//! Bleed, thumbnails with checkboxes) and the Assets tab (the Asset Export panel's assets, with +//! checkboxes), the destination (folder picker, Open Location after Export, Create Sub-folders; +//! the web downloads instead), the format rows (scale as `2x`, `100w`, `100h` or `72ppi`, suffix, +//! format), the presets, Format Settings (the gear) and the prefix. It opens on the settings the +//! document last exported with; the format rows are the Asset Export panel's too ([`formats`]). //! //! Fields (`ui.dialog.set`): `tab` (artboards|assets), `select` (all|range|full), `range`, -//! `boards` ([bool] per artboard), `includeBleed`, `folder`, `openLocation`, `subfolders`, -//! `prefix`, `preset` (""|mobile|density: where the rows came from), `formats` (rows of -//! `document.exportForScreens`, `scale` as text), `settings` ({png|png8|jpg|webp|gif|svg|pdf: {…}}); -//! `__settings` names the format whose Format Settings show ("" when closed). +//! `boards` ([bool] per artboard), `assets` ([asset id] checked on the Assets tab), `includeBleed`, +//! `folder`, `openLocation`, `subfolders`, `prefix`, `preset` (""|mobile|density: where the rows +//! came from), `formats` (rows of `document.exportForScreens`, `scale` as text), `settings` +//! ({png|png8|jpg|webp|gif|svg|pdf: {…}}); `__settings` names the format whose Format Settings +//! show ("" when closed). use serde::Deserialize; use serde_json::{Map, Value, json}; @@ -24,7 +26,11 @@ pub(super) const SPEC: DialogSpec = DialogSpec { body, confirm, ok: Some("Export Artboard"), - ok_label: Some(|app| if io::is_web(app) { "Download" } else { "Export Artboard" }), + ok_label: Some(|app| match (io::is_web(app), app.ui.dialog.as_ref().is_some_and(|d| d.str("tab") == "assets")) { + (true, _) => "Download", + (false, true) => "Export Asset", + (false, false) => "Export Artboard", + }), min_width: 660.0, ..DialogSpec::FORM }; @@ -54,10 +60,38 @@ const SETTINGS_LABELS: [&str; 7] = ["PNG", "PNG 8", "JPG", "WebP", "GIF", "SVG", /// The sizes a row's scale list offers (any can be typed). const SIZES: [&str; 12] = ["0.5x", "0.75x", "1x", "1.5x", "2x", "3x", "4x", "512w", "512h", "72ppi", "150ppi", "300ppi"]; +/// The format rows of export settings `saved` (`document.exportSettings`), sizes shown as text: +/// the saved rows, else the saved preset's, else one PNG at 1x; and that preset. +pub(crate) fn saved_rows(saved: &Map) -> (Vec, Option<&str>) { + let get = |k: &str| saved.get(k).filter(|v| !v.is_null()); + let preset = get("preset").and_then(Value::as_str).filter(|p| fileio::screen_preset_rows(p).is_some()); + let rows = match (get("formats").and_then(Value::as_array), preset) { + (Some(rows), _) if rows.iter().any(Value::is_object) => rows.iter().filter(|r| r.is_object()).map(editable_row).collect(), + (_, Some(p)) => fileio::screen_preset_rows(p).unwrap_or_default(), + _ => vec![json!({"format": "png", "scale": "1x", "suffix": ""})], + }; + (rows, preset) +} + +/// Open the dialog on its Assets tab with `checked` assets checked (every asset when `None`). +pub(crate) fn open_assets(app: &mut VectorcraftApp, checked: Option<&[u64]>) { + open(app); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("tab".into(), json!("assets")); + if let Some(c) = checked { + d.fields.insert("assets".into(), json!(c)); + } + } +} + /// Open the dialog on the settings the active document last exported with (else one PNG row at -/// 1x, into the Desktop). +/// 1x, into the Desktop), every asset checked. pub fn open(app: &mut VectorcraftApp) { - let (n, saved) = app.session.active().map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone())).unwrap_or_default(); + let (n, saved, assets) = app + .session + .active() + .map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone(), st.doc.assets.iter().map(|a| a.id).collect::>())) + .unwrap_or_default(); let get = |k: &str| saved.get(k).filter(|v| !v.is_null()); let flag = |k: &str, default: bool| get(k).and_then(Value::as_bool).unwrap_or(default); // The artboards: Full Document, a range (or a list of them), else all. @@ -68,13 +102,7 @@ pub fn open(app: &mut VectorcraftApp) { _ => "all", }; let boards: Vec = (0..n).map(|i| chosen.as_ref().is_none_or(|c| c.contains(&i))).collect(); - // The rows: the saved ones (or the saved preset's), sizes shown as text. - let preset = get("preset").and_then(Value::as_str).filter(|p| fileio::screen_preset_rows(p).is_some()); - let rows = match (get("formats").and_then(Value::as_array), preset) { - (Some(rows), _) if rows.iter().any(Value::is_object) => rows.iter().filter(|r| r.is_object()).map(editable_row).collect(), - (_, Some(p)) => fileio::screen_preset_rows(p).unwrap_or_default(), - _ => vec![json!({"format": "png", "scale": "1x", "suffix": ""})], - }; + let (rows, preset) = saved_rows(&saved); // Every format's settings: its defaults, then the saved ones. let mut settings = Map::new(); for (id, _) in SETTINGS { @@ -90,6 +118,7 @@ pub fn open(app: &mut VectorcraftApp) { "select": select, "range": range_text(&boards), "boards": boards, + "assets": assets, "includeBleed": flag("includeBleed", false), "folder": folder.unwrap_or_default(), "openLocation": flag("openLocation", true), @@ -169,20 +198,32 @@ fn params(app: &VectorcraftApp, d: &Dialog) -> Result { return Err("add a format to export".into()); } let mut p = Map::new(); - let files = match d.str("select").as_str() { - "full" => { - p.insert("fullDocument".into(), json!(true)); - rows.len() + // What each row writes a file of: the checked assets on the Assets tab, else the artboards. + let pieces = if d.str("tab") == "assets" { + let checked = checked_assets(app, d); + if checked.is_empty() { + return Err("check an asset to export".into()); } - "range" => { - let range = d.str("range"); - // A bad range keeps the dialog open. - let chosen = ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?.unwrap_or_default(); - p.insert("range".into(), json!(range)); - chosen.len() * rows.len() + let count = checked.len(); + p.insert("assets".into(), json!(checked)); + count + } else { + match d.str("select").as_str() { + "full" => { + p.insert("fullDocument".into(), json!(true)); + 1 + } + "range" => { + let range = d.str("range"); + // A bad range keeps the dialog open. + let chosen = ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?.unwrap_or_default(); + p.insert("range".into(), json!(range)); + chosen.len() + } + _ => n, } - _ => n * rows.len(), }; + let files = pieces * rows.len(); for k in ["includeBleed", "subfolders"] { p.insert(k.into(), json!(d.bool(k))); } @@ -223,8 +264,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.vertical(|ui| { ui.set_width(270.0); if tab == 1 { - ui.add_space(24.0); - widgets::dim_label(ui, "No assets to export yet: artwork collected for export shows here."); + assets(app, ui, d); } else { artboards(app, ui, d); } @@ -240,6 +280,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { destination(app, ui, d); ui.add_space(10.0); formats(ui, d); + ui.add_space(6.0); + ui.horizontal(|ui| { + widgets::dim_label(ui, "Prefix:"); + form::text(ui, d, "prefix", 140.0); + }); } }); }); @@ -325,6 +370,50 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { } } +/// The checked assets that are still in the document, in panel order. +fn checked_assets(app: &VectorcraftApp, d: &Dialog) -> Vec { + let on: Vec = d.fields.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + app.session.active().map(|st| st.doc.assets.iter().map(|a| a.id).filter(|id| on.contains(id)).collect()).unwrap_or_default() +} + +/// The Assets tab: each asset's thumbnail and checkbox, Select All and Clear. +fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let Some(st) = app.session.active() else { return }; + if st.doc.assets.is_empty() { + ui.add_space(24.0); + widgets::dim_label(ui, "No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel."); + return; + } + let mut on = checked_assets(app, d); + let before = on.clone(); + ui.horizontal(|ui| { + if ui.small_button("Select All").clicked() { + on = st.doc.assets.iter().map(|a| a.id).collect(); + } + if ui.small_button("Clear").clicked() { + on.clear(); + } + }); + egui::ScrollArea::vertical().max_height(330.0).show(ui, |ui| { + for a in &st.doc.assets { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); + crate::panels::asset_export::paint_thumb(ui, st, a, r); + let mut checked = on.contains(&a.id); + if ui.checkbox(&mut checked, a.name.as_str()).changed() { + on.retain(|id| *id != a.id); + if checked { + on.push(a.id); + } + } + }); + } + }); + if on != before { + d.fields.insert("assets".into(), json!(on)); + } +} + /// Export to: the folder (typed or picked), Open Location after Export and Create Sub-folders; the /// web downloads instead. fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -347,9 +436,9 @@ fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { form::check(ui, d, "subfolders", "Create Sub-folders"); } -/// The preset, the Format Settings gear, the format rows (scale, suffix, format), Add Scale and the -/// prefix. -fn formats(ui: &mut egui::Ui, d: &mut Dialog) { +/// The preset, the Format Settings gear (which sets `__settings`), the format rows (scale, suffix, +/// format) and Add Scale, as wide as `ui` allows: the dialog's and the Asset Export panel's rows. +pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) { let mut rows: Vec = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); let mut edited = false; ui.horizontal(|ui| { @@ -362,7 +451,7 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) { let preset = d.str("preset"); let labels: Vec<&str> = std::iter::once("Custom").chain(SCREEN_PRESETS.iter().map(|(_, l)| *l)).collect(); let at = SCREEN_PRESETS.iter().position(|(id, _)| *id == preset).map_or(0, |i| i + 1); - if let Some(i) = widgets::dropdown(ui, "efs-preset", labels[at], &labels, 180.0) { + if let Some(i) = widgets::dropdown(ui, "efs-preset", labels[at], &labels, (ui.available_width() - 8.0).clamp(90.0, 180.0)) { let id = i.checked_sub(1).and_then(|i| SCREEN_PRESETS.get(i)).map_or("", |(id, _)| *id); if let Some(preset_rows) = fileio::screen_preset_rows(id) { rows = preset_rows; @@ -375,6 +464,10 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) { }); let mut remove = None; let one = rows.len() == 1; + // The columns at the dialog's widths, narrower in a narrower column (the panel): the three + // fields share what the remove buttons, the spacing and the fields' frames leave. + let k = ((ui.available_width() - 66.0) / 262.0).clamp(0.6, 1.0); + let (scale_w, suffix_w, format_w) = (96.0 * k, 70.0 * k, 96.0 * k); egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { for head in ["Scale", "Suffix", "Format", ""] { widgets::dim_label(ui, head); @@ -384,7 +477,7 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) { let raster = row["format"].as_str().and_then(fileio::format).is_none_or(|f| f.raster); let scale = row["scale"].as_str().unwrap_or("1x").to_string(); ui.add_enabled_ui(raster, |ui| { - if let Some(s) = widgets::text_presets(ui, ("efs-scale", i), &scale, &SIZES, 96.0) { + if let Some(s) = widgets::text_presets(ui, ("efs-scale", i), &scale, &SIZES, scale_w) { // The suffix follows the size while it is the automatic one. let auto = |text: &str| ScreenSize::parse(text).map(ScreenSize::suffix); if row["suffix"].as_str().is_none_or(|x| Some(x.to_string()) == auto(&scale)) @@ -396,14 +489,14 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) { edited = true; } }); - if let Some(s) = widgets::text_field(ui, ("efs-suffix", i), row["suffix"].as_str(), 70.0, 1) { + if let Some(s) = widgets::text_field(ui, ("efs-suffix", i), row["suffix"].as_str(), suffix_w, 1) { row["suffix"] = json!(s); edited = true; } let (format, quality) = (row["format"].as_str().unwrap_or("png"), row["quality"].as_u64()); let current = FORMATS.iter().position(|(_, f, q)| *f == format && q.is_none_or(|q| Some(u64::from(q)) == quality)); let label = current.map_or_else(|| format.to_uppercase(), |i| FORMAT_LABELS[i].to_string()); - if let Some((_, f, q)) = widgets::dropdown(ui, ("efs-fmt", i), &label, &FORMAT_LABELS, 96.0).and_then(|i| FORMATS.get(i)) { + if let Some((_, f, q)) = widgets::dropdown(ui, ("efs-fmt", i), &label, &FORMAT_LABELS, format_w).and_then(|i| FORMATS.get(i)) { row["format"] = json!(f); if let Some(o) = row.as_object_mut() { match q { @@ -443,11 +536,6 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert("formats".into(), json!(rows)); d.fields.insert("preset".into(), json!("")); } - ui.add_space(6.0); - ui.horizontal(|ui| { - widgets::dim_label(ui, "Prefix:"); - form::text(ui, d, "prefix", 140.0); - }); } /// Format Settings: the options of format `id` that apply to every row of it, then Done. diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 0cbadb69..539d8724 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -73,6 +73,9 @@ pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; pub use export_as::open as open_export_as; pub use export_for_screens::open as open_export_for_screens; +pub(crate) use export_for_screens::{ + KIND as EXPORT_FOR_SCREENS, formats as screen_formats, open_assets as open_export_for_screens_assets, saved_rows as screen_saved_rows, +}; pub use new_document::{open as open_new_document, preset_card}; pub use png_options::open as open_raster_options; pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset}; @@ -344,6 +347,8 @@ mod tests_raster_formats; #[cfg(test)] mod tests_dxf; #[cfg(test)] +mod tests_screen_assets; +#[cfg(test)] mod tests_screens; #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/tests_screen_assets.rs b/crates/ui-egui/src/dialogs/tests_screen_assets.rs new file mode 100644 index 00000000..b052d165 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_screen_assets.rs @@ -0,0 +1,141 @@ +//! Export for Screens' Assets tab and File → Export Selection…, drawn headlessly: collecting the +//! selection, the checked assets, the OK label, desktop folders and web downloads. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use super::tests_export::{frame, kind, set}; +use super::*; +use crate::Services; +use crate::tests_labels::painted_text; + +type Log = Rc)>>>; + +/// An app with two unstroked squares (selected); `web` gives it a download service (recorded in +/// the log) instead of a file manager. +fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) { + let log = Log::default(); + let l = log.clone(); + let mut services = Services::default(); + if web { + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + } else { + services.reveal = Some(Box::new(move |path: &str| { + l.borrow_mut().push((path.to_string(), vec![])); + Ok(()) + })); + } + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.session.paint.stroke = vectorcraft_color::Paint::None; + let mut rect = |x: f64| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let ids = [rect(10.0), rect(100.0)]; + app.run("select.set", json!({"ids": ids})).unwrap(); + (app, log, ids) +} + +fn field(app: &VectorcraftApp, k: &str) -> Value { + app.ui.dialog.as_ref().expect("a dialog is open").fields.get(k).cloned().unwrap_or(Value::Null) +} + +fn assets(app: &mut VectorcraftApp) -> Vec { + app.run("assets.list", json!({})).unwrap()["assets"].as_array().unwrap().clone() +} + +#[test] +fn export_selection_collects_the_selection_and_opens_the_assets_tab() { + let (mut app, log, _) = app(true); + assert!(crate::menus::enabled(&app, "file.exportSelection")); + let r = app.run("file.exportSelection", json!({})).unwrap(); + let list = assets(&mut app); + assert_eq!(list.len(), 2, "one asset per object"); + let ids: Vec = list.iter().map(|a| a["id"].clone()).collect(); + assert_eq!(r["assets"], json!(ids)); + assert_eq!(kind(&app), Some(export_for_screens::KIND)); + assert_eq!((field(&app, "tab"), field(&app, "assets")), (json!("assets"), json!(ids))); + // Collecting the same art again opens on the same assets. + super::cancel(&mut app); + app.run("file.exportSelection", json!({})).unwrap(); + assert_eq!(assets(&mut app).len(), 2); + assert_eq!(field(&app, "assets"), json!(ids)); + let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); + assert!(text.contains("Asset 1") && text.contains("Asset 2") && text.contains("Select All"), "{text}"); + // The web downloads both as one zip. + let r = confirm(&mut app).unwrap(); + assert_eq!(r["files"], json!(["Asset-1.png", "Asset-2.png"])); + assert_eq!(log.borrow().len(), 1); + assert!(app.ui.dialog.is_none()); + // Nothing selected: Export Selection is off; document.exportSelection still exports in one go. + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(!crate::menus::enabled(&app, "file.exportSelection")); + app.run("select.set", json!({"ids": [list[0]["nodes"][0]]})).unwrap(); + assert!(app.session.execute("document.exportSelection", &json!({"format": "png"})).unwrap()["dataBase64"].is_string()); +} + +#[test] +fn export_selection_as_one_asset() { + let (mut app, _, ids) = app(true); + app.run("file.exportSelection", json!({"multiple": false})).unwrap(); + let list = assets(&mut app); + assert_eq!(list.len(), 1); + assert_eq!(list[0]["nodes"], json!(ids)); + assert_eq!(field(&app, "assets"), json!([list[0]["id"]])); +} + +#[test] +fn the_assets_tab_exports_the_checked_assets_into_the_folder() { + let (mut app, log, _) = app(false); + app.run("assets.add", json!({})).unwrap(); + let ids: Vec = assets(&mut app).iter().map(|a| a["id"].as_u64().unwrap()).collect(); + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!((field(&app, "tab"), field(&app, "assets")), (json!("artboards"), json!(ids)), "every asset checked"); + assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard"); + set(&mut app, "tab", json!("assets")); + assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Asset"); + frame(&mut app); + // Nothing checked keeps the dialog open. + set(&mut app, "assets", json!([])); + assert!(confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some(export_for_screens::KIND)); + let dir = std::env::temp_dir().join(format!("vc-efs-assets-{}", std::process::id())); + let folder = dir.to_string_lossy().replace('\\', "/"); + set(&mut app, "folder", json!(folder)); + set(&mut app, "assets", json!([ids[1], 999])); + set(&mut app, "formats", json!([{"format": "png", "scale": "2x", "suffix": "@2x"}])); + let r = confirm(&mut app).unwrap(); + assert_eq!(r["files"], json!([format!("{folder}/Asset-2@2x.png")])); + let png = image::load_from_memory(&std::fs::read(dir.join("Asset-2@2x.png")).unwrap()).unwrap(); + assert_eq!((png.width(), png.height()), (60, 40), "the asset's art at 2x"); + assert_eq!(log.borrow().len(), 1, "Open Location shows the file"); + // The rows are remembered for the dialog and the Asset Export panel alike. + assert_eq!(app.run("document.exportSettings", json!({})).unwrap()["settings"]["formats"][0]["scale"], "2x"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_empty_assets_tab_says_how_to_collect_art() { + let (mut app, _, _) = app(false); + app.run("file.exportForScreens", json!({})).unwrap(); + set(&mut app, "tab", json!("assets")); + let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); + assert!(text.contains("Collect for Export"), "{text}"); +} + +#[test] +fn the_menus_reach_asset_export() { + let (mut app, _, _) = app(false); + let entries = crate::menus::menu_entries(&app); + let find = |cmd: &str, label: &str| entries.iter().find(|e| e.command.as_deref() == Some(cmd) && e.label == label).map(|e| e.path.clone()); + assert_eq!(find("file.exportSelection", "Export Selection…"), Some(vec!["File".to_string()])); + assert_eq!(find("assets.add", "As Single Asset"), Some(vec!["Object".to_string(), "Collect for Export".to_string()])); + assert_eq!(find("assets.add", "As Multiple Assets"), Some(vec!["Object".to_string(), "Collect for Export".to_string()])); + let window = entries.iter().find(|e| e.label == "Asset Export").unwrap(); + assert_eq!(window.params, json!({"panel": crate::panels::asset_export::ID})); + app.run("window.panel", window.params.clone()).unwrap(); + assert_eq!(app.ui.open_panel.as_deref(), Some(crate::panels::asset_export::ID)); + app.run("assets.add", json!({"multiple": false})).unwrap(); + assert_eq!(assets(&mut app).len(), 1); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 5dcc0c8d..f7ec064e 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -523,9 +523,14 @@ pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), St /// without one the web downloads them (the ZIP, or each file) and the other frontends get them /// back as base64. With `openLocation`, the file manager shows the first file written. pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { + export_files(app, "document.exportForScreens", params) +} + +/// [`export_for_screens`] through command `id` (`document.exportForScreens` or `assets.export`). +pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> Result { let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); - let r = app.run("document.exportForScreens", params)?; + let r = app.run(id, params)?; let count = r["files"].as_array().map_or(0, Vec::len); // Files that came back as bytes: (name, base64). let returned: Vec<(&str, &str)> = match r["dataBase64"].as_str() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25a6ff7f..0691d3e0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -485,6 +485,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}", ), + ( + "file.exportSelection", + "Export Selection…", + "", + "{multiple?: true} collect the selected objects as assets (assets.add: one per object; false: one of them all) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -871,6 +877,13 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::save_for_web::open(app), "file.saveForWeb" => crate::dialogs::save_for_web::save(app, p.clone()), "file.saveForWeb.browser" => crate::dialogs::save_for_web::browser_preview(app, p), + // File → Export Selection…: the selection becomes assets, shown checked on Export for + // Screens' Assets tab. + "file.exportSelection" => app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(true) })).map(|r| { + let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + crate::dialogs::open_export_for_screens_assets(app, Some(&ids)); + json!({ "assets": ids }) + }), _ => return None, }; Some(r) @@ -1106,6 +1119,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), "ui.epsOptionsDialog" => app.session.active().is_some(), "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), + "file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()), _ => true, } } @@ -1169,7 +1183,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Save for Web (Legacy)…", "file.saveForWeb"), ], ), - c("Export Selection…", "document.exportSelection"), + c("Export Selection…", "file.exportSelection"), Sep, c("Package…", "ui.packageDialog"), sub("Scripts", vec![todos("Other Script…", "Cmd+F12")]), @@ -1458,6 +1472,13 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ], ), sub("Graph", vec![c("Type…", "graph.setType"), c("Data…", "graph.setData"), todo("Design…"), todo("Column…"), todo("Marker…")]), + sub( + "Collect for Export", + vec![ + cp("As Single Asset", "assets.add", json!({"multiple": false})), + cp("As Multiple Assets", "assets.add", json!({"multiple": true})), + ], + ), ], ), ( @@ -1690,7 +1711,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { panel("Align", "align"), panel("Appearance", "appearance"), panel("Artboards", "artboards"), - todo("Asset Export"), + panel("Asset Export", crate::panels::asset_export::ID), panel("Attributes", "attributes"), panel("Brushes", "brushes"), panel("Color", "color"), diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs new file mode 100644 index 00000000..64700d84 --- /dev/null +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -0,0 +1,366 @@ +//! Asset Export panel (Window → Asset Export): the document's assets as thumbnails with their +//! names. Art dragged in off the canvas becomes assets, one per object (with Alt held, one asset +//! of them all), and so does the selection with the + button. Clicking selects assets (Shift or +//! Cmd adds), double-clicking one renames it and the bin removes the selected ones. Below are the +//! export settings, the format rows Export for Screens shows too (one set per document), and +//! Export, which writes the selected assets (else all of them) into a picked folder; the web +//! downloads them. The screen button opens Export for Screens on its Assets tab. +//! +//! Everything runs through the `assets.*` commands. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::Arc; + +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2}; +use serde_json::{Value, json}; +use vectorcraft_doc::{ExportAsset, Node, NodeId}; +use vectorcraft_engine::DocState; + +use super::{pstate, set_pstate}; +use crate::dialogs::{EXPORT_FOR_SCREENS, open_export_for_screens_assets, screen_formats, screen_saved_rows}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::widgets::{self, PanelDrag, menu_item}; +use crate::{VectorcraftApp, io}; + +pub const ID: &str = "assetExport"; +/// A tile: the thumbnail over the name. +const TILE: egui::Vec2 = vec2(70.0, 84.0); +const THUMB: f32 = 58.0; +const SELECTED: &str = "asset-export-selected"; + +/// The assets selected in the panel (the panel's own state) that are still in the document. +fn selected(ctx: &egui::Context, st: &DocState) -> Vec { + let mut sel: Vec = pstate(ctx, SELECTED); + sel.retain(|id| st.doc.asset(*id).is_some()); + sel +} + +/// The addresses of an asset's objects, back to front: the identity of its look. +type Look = Vec; +/// A document (uid) at one revision. +type Revision = (u64, u64); + +/// Asset `a`'s art fitted into `r` on white. Rendered once per look of its objects (an edited +/// object is a new allocation, so its address is a free change detector, as in the Layers panel). +pub(crate) fn paint_thumb(ui: &Ui, st: &DocState, a: &ExportAsset, r: Rect) { + thread_local! { + static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); + /// The addresses of each asset's objects in one document revision (no tree walks per frame). + static KEYS: RefCell<(Revision, HashMap)> = RefCell::new(Default::default()); + static CACHE: RefCell> = RefCell::new(HashMap::new()); + } + ui.painter().rect_filled(r, 0.0, Color32::WHITE); + if !ui.is_rect_visible(r) { + return; + } + let ids = || st.doc.paint_order(a.nodes.iter().copied()); + let nodes = KEYS.with(|k| { + let mut k = k.borrow_mut(); + if k.0 != (st.uid, st.revision) { + *k = ((st.uid, st.revision), HashMap::new()); + } + k.1.entry(a.id).or_insert_with(|| ids().iter().filter_map(|id| st.doc.node(*id)).map(|n| std::ptr::from_ref(n) as usize).collect()).clone() + }); + let px = (r.width() * ui.ctx().pixels_per_point()).round().max(8.0) as u32; + let key = (nodes, px); + let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).or_else(|| { + let art: Vec<&Node> = ids().iter().filter_map(|id| st.doc.node(*id)).collect(); + let img = RENDERER.with(|rr| match art.as_slice() { + [one] => rr.borrow_mut().render_node_thumbnail(&st.doc, one, px, None), + many => { + let group = Node::group(NodeId(0), many.iter().map(|n| Arc::new((*n).clone())).collect()); + rr.borrow_mut().render_node_thumbnail(&st.doc, &group, px, None) + } + })?; + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let tex = ui.ctx().load_texture(format!("asset-thumb-{}", a.id), color, egui::TextureOptions::LINEAR); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 512 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + Some(tex) + }); + if let Some(t) = tex { + ui.painter().image(t.id(), r, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + } +} + +/// What the tiles saw this frame (acted on after drawing). +#[derive(Default)] +struct Events { + /// An asset clicked: (id, Shift/Cmd held). + click: Option<(u64, bool)>, + /// An asset renamed. + rename: Option<(u64, String)>, + /// Art dropped on the panel: (ids, as one asset). + drop: Option<(Vec, bool)>, +} + +/// The asset tiles: thumbnail, name (a text field while renaming), selection. +fn tiles(ui: &mut Ui, st: &DocState, sel: &[u64], ev: &mut Events) { + let t = Tokens::get(ui.ctx()); + let rename_id = egui::Id::new("asset-export-rename"); + let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + for a in &st.doc.assets { + let (r, resp) = ui.allocate_exact_size(TILE, Sense::click()); + let on = sel.contains(&a.id); + if on { + ui.painter().rect_filled(r, 2.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + let th = Rect::from_min_size(egui::pos2(r.center().x - THUMB / 2.0, r.top() + 4.0), vec2(THUMB, THUMB)); + paint_thumb(ui, st, a, th); + ui.painter().rect_stroke(th, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, if on { t.accent } else { t.border }), StrokeKind::Outside); + let name_rect = Rect::from_min_max(egui::pos2(r.left() + 2.0, th.bottom() + 3.0), egui::pos2(r.right() - 2.0, r.bottom() - 1.0)); + match &renaming { + Some((id, buf)) if *id == a.id => { + let mut buf = buf.clone(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); + let te = child.add(egui::TextEdit::singleline(&mut buf).desired_width(name_rect.width()).font(egui::FontId::proportional(11.5))); + // Enter or a click elsewhere commits, Escape cancels. + if te.lost_focus() { + ui.data_mut(|d| d.remove::<(u64, String)>(rename_id)); + if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf.trim() != a.name && !buf.trim().is_empty() { + ev.rename = Some((a.id, buf.trim().to_string())); + } + } else { + te.request_focus(); + ui.data_mut(|d| d.insert_temp(rename_id, (a.id, buf))); + } + } + _ => { + ui.painter().with_clip_rect(name_rect).text( + egui::pos2(name_rect.center().x, name_rect.center().y), + egui::Align2::CENTER_CENTER, + &a.name, + egui::FontId::proportional(11.5), + t.text, + ); + } + } + let resp = resp.on_hover_text(format!("{}\nDouble-click to rename", a.name)); + if resp.double_clicked() { + ui.data_mut(|d| d.insert_temp(rename_id, (a.id, a.name.clone()))); + } else if resp.clicked() { + ev.click = Some((a.id, ui.input(|i| i.modifiers.shift || i.modifiers.command))); + } + } + }); +} + +/// Art dragged off the canvas over the list: outline it; dropped, it becomes assets. +fn drop_zone(ui: &Ui, zone: &egui::Response, ev: &mut Events) { + let Some(drag) = zone.dnd_hover_payload::() else { return }; + let PanelDrag::Art(ids) = &*drag else { return }; + ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + if zone.dnd_release_payload::().is_some() { + ev.drop = Some((ids.clone(), ui.input(|i| i.modifiers.alt))); + } +} + +/// Run `cmd`, reporting a failure in the status bar. +fn run(app: &mut VectorcraftApp, cmd: &str, params: Value) -> Option { + app.run(cmd, params).map_err(|e| app.status(e)).ok() +} + +/// Collect `ids` (else the selection) as assets (one of them all with `single`), and select them +/// in the panel. +fn collect(app: &mut VectorcraftApp, ctx: &egui::Context, ids: Option>, single: bool) { + let mut p = json!({ "multiple": !single }); + if let Some(ids) = ids { + p["ids"] = json!(ids.iter().map(|id| id.0).collect::>()); + } + if let Some(r) = run(app, "assets.add", p) { + let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + set_pstate(ctx, SELECTED, ids); + } +} + +/// Export assets `ids` (`files_each`: the files one asset makes) into a picked folder (without a +/// folder picker: the last folder Export for Screens used, else the Desktop); the web downloads +/// them, several as one zip. +fn export(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { + let mut p = json!({ "assets": ids }); + if io::is_web(app) { + p["zip"] = json!(ids.len() * files_each > 1); + } else { + let saved = app.session.active().map(|st| st.doc.export_settings.clone()).unwrap_or_default(); + let folder = match app.services.pick_folder.as_mut() { + Some(pick) => match pick() { + Some(f) => f, + None => return, + }, + None => match saved + .get("folder") + .and_then(Value::as_str) + .filter(|f| !f.is_empty()) + .map(str::to_string) + .or_else(vectorcraft_engine::cmd::fileio::export_folder) + { + Some(f) => f, + None => return app.status("choose a folder to export to".to_string()), + }, + }; + p["folder"] = json!(folder); + p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true)); + } + if let Err(e) = io::export_files(app, "assets.export", p) { + app.status(e); + } +} + +/// What the export settings rows asked for. +enum Settings { + /// Change the shared settings (`assets.settings.set`). + Set(Value), + /// Show a format's Format Settings (in Export for Screens). + Format(String), +} + +/// The export settings: Export for Screens' format rows on the document's shared settings. +/// Returns the change asked for and how many files one asset makes. +fn settings(ui: &mut Ui, st: &DocState) -> (Option, usize) { + let (rows, preset) = screen_saved_rows(&st.doc.export_settings); + let preset = preset.unwrap_or_default().to_string(); + let n = rows.len(); + let mut d = Dialog::new(EXPORT_FOR_SCREENS, json!({ "formats": &rows, "preset": preset, "__settings": "" })); + screen_formats(ui, &mut d); + let (shown, picked) = (d.str("__settings"), d.str("preset")); + let formats = d.fields.remove("formats").unwrap_or(Value::Null); + let change = if !shown.is_empty() { + Some(Settings::Format(shown)) + } else if picked != preset && !picked.is_empty() { + Some(Settings::Set(json!({ "preset": picked, "subfolders": true }))) + } else if picked != preset || formats.as_array() != Some(&rows) { + // Rows edited, or Custom picked: the rows as they are, without the preset. + Some(Settings::Set(json!({ "formats": formats, "preset": "" }))) + } else { + None + }; + (change, n) +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + let Some(st) = app.session.active() else { + widgets::dim_label(ui, "No document"); + return; + }; + let sel = selected(&ctx, st); + let mut ev = Events::default(); + widgets::list_box(ui, |ui| { + let out = egui::ScrollArea::vertical().id_salt("asset-export-scroll").max_height(270.0).auto_shrink([false, true]).show(ui, |ui| { + ui.set_min_height(110.0); + ui.set_width(ui.available_width()); + if st.doc.assets.is_empty() { + super::empty_state(ui, "share-2", "No assets", "Drag art here, or select it and click +. Hold Alt for one asset of several objects."); + } else { + tiles(ui, st, &sel, &mut ev); + } + }); + let zone = ui.interact(out.inner_rect, ui.id().with("asset-export-drop"), Sense::hover()); + drop_zone(ui, &zone, &mut ev); + }); + ui.add_space(6.0); + widgets::section_header(ui, "Export Settings"); + let (change, files_each) = settings(ui, st); + let all: Vec = st.doc.assets.iter().map(|a| a.id).collect(); + let has_art = !st.selection.is_empty(); + let alt = ui.input(|i| i.modifiers.alt); + ui.add_space(6.0); + let mut export_clicked = false; + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_enabled_ui(!all.is_empty(), |ui| { + let label = if sel.is_empty() || sel.len() == all.len() { "Export".to_string() } else { format!("Export {} Selected", sel.len()) }; + export_clicked = widgets::primary_button(ui, &label).on_hover_text("Export the selected assets (else all of them)").clicked(); + }); + }); + }); + let (mut screens, mut add, mut remove) = (false, false, false); + widgets::bottom_bar(ui, |ui| { + screens = widgets::icon_button(ui, "monitor", "Export for Screens…", false, 24.0).clicked(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + remove = widgets::icon_button_enabled(ui, "trash-2", "Remove Selected Assets", false, !sel.is_empty(), 24.0).clicked(); + add = widgets::icon_button_enabled(ui, "plus", "Add Selected Artwork (Alt: as one asset)", false, has_art, 24.0).clicked(); + }); + }); + // Act on what the panel saw (the document is no longer borrowed). + if let Some((id, toggle)) = ev.click { + let mut s = sel.clone(); + match (toggle, s.iter().position(|x| *x == id)) { + (true, Some(i)) => _ = s.remove(i), + (true, None) => s.push(id), + (false, _) => s = vec![id], + } + set_pstate(&ctx, SELECTED, s); + } + if let Some((id, name)) = ev.rename { + run(app, "assets.rename", json!({ "asset": id, "name": name })); + } + if let Some((ids, single)) = ev.drop { + collect(app, &ctx, Some(ids), single); + } + match change { + Some(Settings::Set(p)) => _ = run(app, "assets.settings.set", p), + Some(Settings::Format(f)) => { + open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("__settings".into(), json!(f)); + } + } + None => {} + } + if add { + collect(app, &ctx, None, alt); + } + if remove { + run(app, "assets.remove", json!({ "assets": sel })); + set_pstate(&ctx, SELECTED, Vec::::new()); + } + if screens { + open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all))); + } + if export_clicked { + export(app, sel_or_all(&sel, &all), files_each); + } +} + +/// The selected assets, else all of them. +fn sel_or_all(sel: &[u64], all: &[u64]) -> Vec { + if sel.is_empty() { all.to_vec() } else { sel.to_vec() } +} + +/// The panel (≡) menu. +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + let Some(st) = app.session.active() else { return }; + let (has_art, sel) = (!st.selection.is_empty(), selected(&ctx, st)); + let all: Vec = st.doc.assets.iter().map(|a| a.id).collect(); + if menu_item(ui, "Add Selected Artwork as One Asset", has_art, false) { + collect(app, &ctx, None, true); + } + if menu_item(ui, "Add Selected Artwork as Multiple Assets", has_art, false) { + collect(app, &ctx, None, false); + } + ui.separator(); + if menu_item(ui, "Select All Assets", !all.is_empty(), false) { + set_pstate(&ctx, SELECTED, all.clone()); + } + if menu_item(ui, "Remove Selected Assets", !sel.is_empty(), false) { + run(app, "assets.remove", json!({ "assets": sel })); + set_pstate(&ctx, SELECTED, Vec::::new()); + } + ui.separator(); + if menu_item(ui, "Export for Screens…", true, false) { + open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all))); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2cc7a194..3f6aa3d7 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -7,6 +7,7 @@ pub mod actions; pub mod align; pub mod appearance; pub mod artboards; +pub mod asset_export; pub mod attributes; pub mod brushes; pub mod character; @@ -96,6 +97,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "attributes" => attributes::show(app, ui), "colorThemes" => color_themes::show(app, ui), links::ID => links::show(app, ui), + asset_export::ID => asset_export::show(app, ui), _ => { dim_label(ui, "This panel is on the roadmap (see the parity plan)."); } @@ -139,6 +141,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "colorThemes" => color_themes::menu(app, ui), "layers" => layers::menu(app, ui), links::ID => links::menu(app, ui), + asset_export::ID => asset_export::menu(app, ui), _ => { ui.add_enabled(false, egui::Button::new("No options").frame(false)); } @@ -445,6 +448,8 @@ mod tests { #[cfg(test)] mod tests_appearance; #[cfg(test)] +mod tests_asset_export; +#[cfg(test)] mod tests_effectedit; #[cfg(test)] mod tests_freeform; diff --git a/crates/ui-egui/src/panels/tests_asset_export.rs b/crates/ui-egui/src/panels/tests_asset_export.rs new file mode 100644 index 00000000..59e732d3 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_asset_export.rs @@ -0,0 +1,206 @@ +//! The Asset Export panel drawn headless: its empty state, art dropped in (one asset per object, +//! or one with Alt), selecting and renaming assets, the shared export settings rows and Export. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Pos2, Rect, pos2, vec2}; +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +use super::asset_export::{ID, menu, show}; +use crate::widgets::PanelDrag; +use crate::{Services, VectorcraftApp}; + +type Log = Rc)>>>; + +/// An app with two unstroked squares; `web` gives it a download service (recorded in the log). +fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) { + let log = Log::default(); + let l = log.clone(); + let mut services = Services::default(); + if web { + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + } + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.session.paint.stroke = vectorcraft_color::Paint::None; + let mut rect = |x: f64| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let ids = [rect(10.0), rect(100.0)]; + (app, log, ids) +} + +/// One frame of the panel, 236 pt wide as in the dock → its texts and where they are. +fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, alt: bool) -> Vec<(String, Rect)> { + fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut all = vec![egui::Event::ModifiersChanged(egui::Modifiers { alt, ..Default::default() })]; + all.extend(events); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 700.0))), events: all, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v +} + +fn click(at: Pos2) -> Vec { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + vec![egui::Event::PointerMoved(at), button(true), button(false)] +} + +fn release(at: Pos2) -> Vec { + vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ] +} + +/// Where text `label` was drawn. +fn at(texts: &[(String, Rect)], label: &str) -> Pos2 { + texts + .iter() + .find(|(t, _)| t == label) + .unwrap_or_else(|| panic!("no `{label}` in {:?}", texts.iter().map(|t| &t.0).collect::>())) + .1 + .center() +} + +fn assets(app: &mut VectorcraftApp) -> Vec { + app.run("assets.list", json!({})).unwrap()["assets"].as_array().unwrap().clone() +} + +fn has(texts: &[(String, Rect)], label: &str) -> bool { + texts.iter().any(|(t, _)| t == label) +} + +#[test] +fn an_empty_panel_says_how_to_add_assets_and_shows_the_export_settings() { + let (mut app, _, _) = app(false); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + for label in ["No assets", "Export Settings", "Scale", "Suffix", "Format", "+ Add Scale", "Export"] { + assert!(has(&texts, label), "{label}"); + } + // The menu draws. + let menu = crate::tests_labels::painted_text(&mut app, menu); + for item in ["Add Selected Artwork as One Asset", "Remove Selected Assets", "Export for Screens…"] { + assert!(menu.contains(item), "{item}: {menu}"); + } + assert!(crate::state::ICON_PANELS.iter().any(|(id, label, _)| *id == ID && *label == "Asset Export")); +} + +#[test] +fn art_dropped_on_the_panel_becomes_assets() { + let (mut app, _, [a, b]) = app(false); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(a), NodeId(b)])); + frame(&ctx, &mut app, release(pos2(100.0, 50.0)), false); + let list = assets(&mut app); + assert_eq!(list.len(), 2, "one asset per object"); + // With Alt, one asset of them all. + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(a), NodeId(b)])); + frame(&ctx, &mut app, release(pos2(100.0, 50.0)), true); + let list = assets(&mut app); + assert_eq!(list.len(), 3); + assert_eq!(list[2]["nodes"], json!([a, b])); + let texts = frame(&ctx, &mut app, vec![], false); + assert!(has(&texts, "Asset 1") && has(&texts, "Asset 3")); + // The new asset is the panel's selection. + assert!(has(&texts, "Export 1 Selected")); +} + +#[test] +fn the_plus_button_collects_the_selection_and_the_bin_removes_selected_assets() { + let (mut app, _, [a, b]) = app(false); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + // The + is the bottom bar's second icon from the right. + let bar_y = at(&texts, "Export").y + 34.0; + frame(&ctx, &mut app, click(pos2(236.0 - 40.0, bar_y)), false); + assert_eq!(assets(&mut app).len(), 2, "+ adds one asset per object"); + let texts = frame(&ctx, &mut app, vec![], false); + // Click one: it alone is selected; the bin removes it (the art stays). + frame(&ctx, &mut app, click(at(&texts, "Asset 2")), false); + let texts = frame(&ctx, &mut app, vec![], false); + // The bin is the bottom bar's last icon. + frame(&ctx, &mut app, click(pos2(236.0 - 13.0, at(&texts, "Export 1 Selected").y + 34.0)), false); + let names: Vec = assets(&mut app).iter().map(|x| x["name"].clone()).collect(); + assert_eq!(names, [json!("Asset 1")]); + assert!(app.session.active().unwrap().doc.node(NodeId(b)).is_some()); +} + +#[test] +fn renaming_an_asset_runs_assets_rename() { + let (mut app, _, [a, _]) = app(false); + let id = app.run("assets.add", json!({"ids": [a]})).unwrap()["assets"][0].as_u64().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + // Double-click starts editing the name (as typed here); Enter commits it. + ctx.data_mut(|d| d.insert_temp(egui::Id::new("asset-export-rename"), (id, "Logo".to_string()))); + frame(&ctx, &mut app, vec![], false); + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(&ctx, &mut app, vec![enter], false); + assert_eq!(assets(&mut app)[0]["name"], "Logo"); +} + +#[test] +fn the_rows_are_the_shared_export_settings() { + let (mut app, log, [a, b]) = app(true); + app.run("assets.add", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + // + Add Scale adds a 2x row to the document's export settings (Export for Screens' too). + frame(&ctx, &mut app, click(at(&texts, "+ Add Scale")), false); + let settings = app.run("document.exportSettings", json!({})).unwrap()["settings"].clone(); + assert_eq!(settings["formats"].as_array().unwrap().len(), 2); + assert_eq!(settings["formats"][1]["scale"], "2x"); + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["formats"][1]["scale"], "2x"); + crate::dialogs::cancel(&mut app); + // A preset set by an agent shows as its rows. + app.run("assets.settings.set", json!({"preset": "mobile"})).unwrap(); + let texts = frame(&ctx, &mut app, vec![], false); + assert!(has(&texts, "Mobile 1x/2x/3x") && has(&texts, "@3x")); + // Export (nothing selected: every asset) downloads one zip on the web. + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + let log = log.borrow(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].0, "Untitled-1.zip"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_writes_into_the_picked_folder() { + let dir = std::env::temp_dir().join(format!("vc-asset-panel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let folder = dir.to_string_lossy().replace('\\', "/"); + let (mut app, _, [a, _]) = app(false); + let picked = folder.clone(); + app.services.pick_folder = Some(Box::new(move || Some(picked.clone()))); + app.run("assets.add", json!({"ids": [a], "name": "Badge"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + assert!(dir.join("Badge.png").is_file(), "{:?}", std::fs::read_dir(&dir).map(|d| d.count())); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e0c33b9d..4ddb0462 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -107,6 +107,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("attributes", "Attributes", "settings"), ("colorThemes", "Color Themes", "sun"), (crate::panels::links::ID, "Links", "link"), + (crate::panels::asset_export::ID, "Asset Export", "share-2"), ]; /// Groups of icon panels separated by dividers in the collapsed column. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index eee5bef4..3e0494e4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -32,6 +32,8 @@ | `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`. Headless it returns `{dataBase64, width, height}` | | Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once, `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). Fields: `tab` (`artboards` \| `assets`), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one store-only zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager | +| Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`) | +| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as assets (`assets.add`; one per object unless `multiple` is false) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | diff --git a/docs/mcp.md b/docs/mcp.md index 9f1daee1..3b797f00 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1412,3 +1412,25 @@ defaults); presets and remembered settings are preferences. {"name":"run_command","arguments":{"command":"document.exportForWeb","params":{"path":"/tmp/web/page.html","format":"png8","output":"html"}}} {"name":"run_command","arguments":{"command":"webExport.presets.save","params":{"name":"Banner","format":"jpg","quality":70}}} ``` + +## Asset Export + +Assets are pieces of art collected for export on their own (Window › Asset Export, Object › Collect for Export, File › +Export Selection…). +`assets.add {ids?, multiple?}` collects the selected objects (or `ids`): one asset per object, or with +`multiple: false` one asset of them all, named after the object or `Asset 1`, `Asset 2`…; art already collected keeps +its asset. Assets name their objects, so they follow edits: moving the art moves the crop, and deleting it +removes it from its asset (an asset left with nothing goes). `assets.list` gives each asset's id, name, objects and +crop (`bounds`); `assets.rename {asset, name}` and `assets.remove {assets}` are undo steps. + +`assets.export {assets?, folder?, zip?}` writes each asset's art alone, cropped to it and named after it, in every +format row of the document's export settings, the ones Export for Screens remembers (`document.exportSettings`); any +`formats`, `preset`, `settings`, `prefix` or `subfolders` given win for that export. `assets.settings.set` changes +those shared settings (not an undo step). `document.exportForScreens {assets: [id…]}` does the same with its own params +(the dialog's Assets tab). + +```json +{"name":"run_command","arguments":{"command":"assets.add","params":{"multiple":true}}} +{"name":"run_command","arguments":{"command":"assets.settings.set","params":{"formats":[{"format":"png","scale":"1x"},{"format":"png","scale":"2x"},{"format":"svg"}]}}} +{"name":"run_command","arguments":{"command":"assets.export","params":{"zip":true}}} +```