From 6afcacbd918a46f0ace2ee6510286591b2ad5d79 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:34:09 +0700 Subject: [PATCH] M4.21: New Document with preset categories, details column and More Settings File > New opens a New Document dialog in the reference app's layout: category tabs (Recent, Saved, Mobile, Web, Print, Film & Video, Art & Illustration, Branding, Social) over cards of presets generated in code (page-proportioned thumbnail, neutral name, size with units), and a Preset Details column: name with Save Preset, width with the units dropdown, height, portrait/landscape toggles, artboards, linked bleed, Background Contents with its chip, collapsible Advanced Options (colour mode, raster effects, preview mode), More Settings, Close and Create. More Settings is the classic form of the same fields (profile and size lists, artboard layout buttons, spacing and columns, Templates...). Choosing a preset fills the fields; editing one makes them custom. file.new takes every setting (preset, name, orientation, artboards with artboardLayout {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode; sizes may carry units). file.newPresets lists the categories; file.newPresets.save and .delete keep user presets in the preferences; Recent remembers the last sizes. Print presets and plain sizes start in Preferences > Units > General, screen presets in pixels. Rearrange All Artboards shares the grid layout code. The Home screen shows the same preset cards. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 38 +- crates/engine/src/cmd/menucmds.rs | 24 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/newdoc.rs | 621 ++++++++++++++++++ crates/engine/src/lib.rs | 15 + crates/engine/src/tests_newdoc.rs | 225 +++++++ crates/ui-egui/src/canvas.rs | 29 +- crates/ui-egui/src/dialogs/form.rs | 6 + crates/ui-egui/src/dialogs/mod.rs | 5 +- crates/ui-egui/src/dialogs/new_document.rs | 709 +++++++++++++++++++-- crates/ui-egui/src/lib.rs | 6 +- crates/ui-egui/src/widgets.rs | 21 + docs/control-protocol.md | 9 + docs/mcp.md | 14 + 14 files changed, 1576 insertions(+), 148 deletions(-) create mode 100644 crates/engine/src/cmd/newdoc.rs create mode 100644 crates/engine/src/tests_newdoc.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e3050a08..2d4d18cd 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, Unit}; -use vectorcraft_geom::{Affine, Vec2}; +use vectorcraft_geom::Affine; use super::*; use crate::{EngineError, inspect}; @@ -16,9 +16,9 @@ pub fn specs() -> Vec { "New…", ["File"], Some("Cmd+N"), - "{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"… (default: prefs unitsGeneral), title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK)}", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, - file_new + super::newdoc::file_new ), cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close), cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate), @@ -52,37 +52,6 @@ pub fn specs() -> Vec { ] } -fn file_new(s: &mut Session, p: &Value) -> Result { - let w = f64_or(p, "width", 612.0); - let h = f64_or(p, "height", 792.0); - if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 { - return Err(bad("file.new", "width/height must be positive and within the canvas")); - } - let mode = match str_param(p, "colorMode") { - Some(m) if m.eq_ignore_ascii_case("cmyk") => vectorcraft_doc::ColorMode::Cmyk, - _ => vectorcraft_doc::ColorMode::Rgb, - }; - let mut d = Document::new_with_mode(w, h, mode); - d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled()); - d.units = match str_param(p, "units") { - Some(u) => Unit::named(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?, - None => s.default_units(), - }; - let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; - for i in 1..n { - let r = vectorcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0); - d.artboards.push(vectorcraft_doc::Artboard { - id: (i + 1) as u32, - name: format!("Artboard {}", i + 1), - rect: r, - show_center_mark: false, - show_cross_hairs: false, - }); - } - let i = s.add_document(d, None); - Ok(json!({ "index": i })) -} - fn file_close(s: &mut Session, p: &Value) -> Result { let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; if !s.close_document(i) { @@ -102,6 +71,7 @@ fn doc_node(s: &mut Session, p: &Value) -> Result { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) } +/// Document Setup's units (also `document.setup {units}`). fn set_units(s: &mut Session, p: &Value) -> Result { let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?; s.set_document_units(u)?; diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index d3fc727e..cd2a21cf 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -679,27 +679,9 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { s.edit("Rearrange Artboards", |d, _| { let rects: Vec = d.artboards.iter().map(|a| a.rect).collect(); let Some(first) = rects.first().copied() else { return Ok(()) }; - let n = rects.len(); - let rows = n.div_ceil(cols); - // Grid cell (row, col) of artboard i. - let cell = |i: usize| if by_col { (i % rows, i / rows) } else { (i / cols, i % cols) }; - let mut col_w = vec![0.0f64; cols]; - let mut row_h = vec![0.0f64; rows]; - for (i, r) in rects.iter().enumerate() { - let (ro, co) = cell(i); - col_w[co] = col_w[co].max(r.width()); - row_h[ro] = row_h[ro].max(r.height()); - } - let x_of = |c: usize| first.x0 + col_w[..c].iter().map(|w| w + spacing).sum::(); - let y_of = |r: usize| first.y0 + row_h[..r].iter().map(|h| h + spacing).sum::(); - let deltas: Vec = rects - .iter() - .enumerate() - .map(|(i, r)| { - let (ro, co) = cell(i); - Point::new(x_of(co), y_of(ro)) - r.origin() - }) - .collect(); + let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect(); + let origins = super::newdoc::grid_origins(&sizes, first.origin(), cols, spacing, by_col, false); + let deltas: Vec = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect(); if move_art { let tops: Vec<(NodeId, Point)> = d .layers diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3c2cb376..2657064c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -29,6 +29,7 @@ mod live; pub(crate) mod maskedit; pub(crate) mod menucmds; pub(crate) mod newart; +pub mod newdoc; mod object; mod opacitymask; mod overprint; @@ -213,6 +214,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(colortheme::specs()); v.extend(fileio::pdf::specs()); v.extend(docsetup::specs()); + v.extend(newdoc::specs()); v }) } diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs new file mode 100644 index 00000000..1428670a --- /dev/null +++ b/crates/engine/src/cmd/newdoc.rs @@ -0,0 +1,621 @@ +//! File → New: the settings `file.new` takes, the New Document presets (generated in code, by +//! category), the user's saved presets and the recently used sizes (both kept in the +//! preferences), and the artboard grid layout shared with Rearrange All Artboards. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_doc::{Artboard, Background, ColorMode, Document, Unit}; +use vectorcraft_geom::{Point, Rect}; + +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "file.newPresets", + "New Document Presets", + [], + None, + "{category?: \"Recent\"|\"Saved\"|\"Mobile\"|\"Web\"|\"Print\"|\"Film & Video\"|\"Art & Illustration\"|\"Branding\"|\"Social\"} the New Document presets by category (Recent: the last sizes used; Saved: the user's presets) → {categories: [{name, presets: [{name, size: \"1920 × 1080 px\", width: pt, height: pt, units, orientation, artboards, artboardLayout: {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode}]}]}; file.new {preset: name} starts from one", + always, + list + ), + cmd!( + "file.newPresets.save", + "Save Preset", + [], + None, + "{name, preset?: start from this preset, …settings as file.new (default: Letter in prefs unitsGeneral)} save New Document settings as a user preset (the Saved category; same name = replace) in the preferences → {name, count}", + always, + save + ), + cmd!( + "file.newPresets.delete", + "Delete Preset", + [], + None, + "{name} delete a saved New Document preset → {deleted}", + |s| if s.prefs.new_doc_presets.is_empty() { Err("no saved presets".into()) } else { Ok(()) }, + delete + ), + ] +} + +/// How New Document lays out several artboards. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ArtboardLayout { + /// Rows of `columns` artboards, filled left to right then down. + #[default] + GridByRow, + /// `columns` columns, filled top to bottom then across. + GridByColumn, + /// One row. + Row, + /// One column. + Column, +} + +impl ArtboardLayout { + pub const ALL: [Self; 4] = [Self::GridByRow, Self::GridByColumn, Self::Row, Self::Column]; + pub fn id(self) -> &'static str { + match self { + Self::GridByRow => "gridByRow", + Self::GridByColumn => "gridByColumn", + Self::Row => "row", + Self::Column => "column", + } + } + pub fn label(self) -> &'static str { + match self { + Self::GridByRow => "Grid by Row", + Self::GridByColumn => "Grid by Column", + Self::Row => "Arrange by Row", + Self::Column => "Arrange by Column", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s)) + } +} + +/// How a new document previews: normally, as pixels (Pixel Preview) or with Overprint Preview. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreviewMode { + #[default] + Default, + Pixel, + Overprint, +} + +impl PreviewMode { + pub const ALL: [Self; 3] = [Self::Default, Self::Pixel, Self::Overprint]; + pub fn id(self) -> &'static str { + match self { + Self::Default => "default", + Self::Pixel => "pixel", + Self::Overprint => "overprint", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s)) + } +} + +/// Everything New Document sets: a preset (built-in, saved or recent) or `file.new`'s params. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DocSettings { + /// The preset's name. + pub name: String, + /// Artboard size in points. + pub width: f64, + pub height: f64, + pub units: Unit, + pub artboards: u32, + pub layout: ArtboardLayout, + /// Columns of a grid layout. + pub columns: u32, + /// Space between artboards, points. + pub spacing: f64, + /// Change to Right-to-Left Layout: the first artboard at the right. + pub right_to_left: bool, + /// Top, bottom, left, right, points. + pub bleed: [f64; 4], + pub background: Background, + pub color_mode: ColorMode, + pub raster_effects_ppi: f64, + pub preview_mode: PreviewMode, +} + +impl Default for DocSettings { + fn default() -> Self { + Self { + name: "Letter".into(), + width: 612.0, + height: 792.0, + units: Unit::Points, + artboards: 1, + layout: ArtboardLayout::GridByRow, + columns: 0, + spacing: 20.0, + right_to_left: false, + bleed: [0.0; 4], + background: Background::Transparent, + color_mode: ColorMode::Rgb, + raster_effects_ppi: 72.0, + preview_mode: PreviewMode::Default, + } + } +} + +/// Most artboards a new document can have (as the reference app). +pub const MAX_ARTBOARDS: u32 = 1000; +/// Raster effects resolutions New Document offers: (ppi, label). +pub const RASTER_PPI: [(f64, &str); 3] = [(72.0, "Screen (72 ppi)"), (150.0, "Medium (150 ppi)"), (300.0, "High (300 ppi)")]; + +impl DocSettings { + /// The columns a grid actually uses (0 or more than the artboards: one row). + pub fn grid_columns(&self) -> usize { + let n = self.artboards.max(1) as usize; + match self.layout { + ArtboardLayout::Row => n, + ArtboardLayout::Column => 1, + _ if self.columns == 0 => n, + _ => (self.columns as usize).min(n), + } + } + + /// "portrait" or "landscape" (a square is portrait). + pub fn orientation(&self) -> &'static str { + if self.width > self.height { "landscape" } else { "portrait" } + } + + /// The size as New Document shows it, e.g. "1920 × 1080 px". + pub fn size_label(&self) -> String { + let u = self.units; + format!("{} × {} {}", u.number(self.width), u.number(self.height), u.suffix()) + } + + /// As `file.newPresets` lists it (and `file.new` takes it back). + pub fn to_json(&self) -> Value { + json!({ + "name": self.name, + "size": self.size_label(), + "width": self.width, + "height": self.height, + "units": self.units.label(), + "orientation": self.orientation(), + "artboards": self.artboards, + "artboardLayout": {"layout": self.layout.id(), "columns": self.grid_columns(), "spacing": self.spacing, "rightToLeft": self.right_to_left}, + "bleed": self.bleed, + "backgroundContents": self.background.id(), + "colorMode": color_mode_id(self.color_mode), + "rasterEffectsPpi": self.raster_effects_ppi, + "previewMode": self.preview_mode.id(), + }) + } + + /// These settings with `p`'s changes (the params of `file.new`) → validated. + pub fn with_params(mut self, p: &Value, cmd: &str) -> Result { + if let Some(u) = p.get("units").filter(|v| !v.is_null()) { + let u = u.as_str().unwrap_or(""); + self.units = Unit::named(u).ok_or_else(|| bad(cmd, format!("unknown units `{u}`")))?; + } + // Numbers are points; strings may carry their unit ("210 mm"), else the document's. + let length = |k: &str, units: Unit| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(n)) => Ok(n.as_f64()), + Some(Value::String(s)) => units.parse(s).map(Some).ok_or_else(|| bad(cmd, format!("{k}: can't read `{s}` as a length"))), + Some(_) => Err(bad(cmd, format!("{k} must be a number (pt) or a length such as \"210 mm\""))), + } + }; + if let Some(w) = length("width", self.units)? { + self.width = w; + } + if let Some(h) = length("height", self.units)? { + self.height = h; + } + if !(self.width > 0.0 && self.height > 0.0) || self.width > MAX_SIDE || self.height > MAX_SIDE { + return Err(bad(cmd, "width/height must be positive and within the canvas")); + } + match p.get("orientation").and_then(Value::as_str) { + None => {} + Some(o) if o.eq_ignore_ascii_case("portrait") => (self.width, self.height) = (self.width.min(self.height), self.width.max(self.height)), + Some(o) if o.eq_ignore_ascii_case("landscape") => (self.width, self.height) = (self.width.max(self.height), self.width.min(self.height)), + Some(o) => return Err(bad(cmd, format!("orientation must be portrait or landscape, not `{o}`"))), + } + if let Some(n) = p.get("artboards").filter(|v| !v.is_null()) { + let n = n.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, "artboards must be a number"))?; + self.artboards = n.round().clamp(1.0, MAX_ARTBOARDS as f64) as u32; + } + if let Some(l) = p.get("artboardLayout").filter(|v| !v.is_null()) { + self.layout_params(l, cmd)?; + } + if let Some(b) = p.get("bleed").filter(|v| !v.is_null()) { + self.bleed = super::docsetup::bleed_param(b, self.bleed, cmd)?; + } + if let Some(b) = p.get("backgroundContents").filter(|v| !v.is_null()) { + self.background = super::docsetup::background_param(b, cmd)?; + } + if let Some(m) = str_param(p, "colorMode") { + self.color_mode = match m.to_ascii_lowercase().as_str() { + "rgb" | "rgb color" => ColorMode::Rgb, + "cmyk" | "cmyk color" => ColorMode::Cmyk, + _ => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))), + }; + } + if let Some(v) = p.get("rasterEffectsPpi").filter(|v| !v.is_null()) { + self.raster_effects_ppi = v + .as_f64() + .filter(|r| (1.0..=2400.0).contains(r)) + .ok_or_else(|| bad(cmd, "rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high)"))?; + } + if let Some(m) = p.get("previewMode").filter(|v| !v.is_null()) { + self.preview_mode = m.as_str().and_then(PreviewMode::parse).ok_or_else(|| bad(cmd, "previewMode must be default, pixel or overprint"))?; + } + let extent = self.boards().iter().fold(Rect::ZERO, |a, r| a.union(*r)); + if extent.width().max(extent.height()) + self.bleed.iter().fold(0.0f64, |a, b| a.max(*b)) * 2.0 > crate::MAX_COORD { + return Err(bad(cmd, "the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size")); + } + Ok(self) + } + + /// `artboardLayout: {layout?, columns?, spacing?, rightToLeft?}` or just a layout id. + fn layout_params(&mut self, l: &Value, cmd: &str) -> Result<()> { + let bad_layout = || bad(cmd, "artboardLayout.layout must be gridByRow, gridByColumn, row or column"); + if let Some(id) = l.as_str() { + self.layout = ArtboardLayout::parse(id).ok_or_else(bad_layout)?; + return Ok(()); + } + let o = l.as_object().ok_or_else(|| bad(cmd, "artboardLayout must be {layout?, columns?, spacing?, rightToLeft?}"))?; + for (k, v) in o { + match k.as_str() { + "layout" => self.layout = v.as_str().and_then(ArtboardLayout::parse).ok_or_else(bad_layout)?, + "columns" => { + self.columns = v + .as_f64() + .filter(|c| c.is_finite() && *c >= 1.0) + .ok_or_else(|| bad(cmd, "artboardLayout.columns must be 1 or more"))? + .round() + .min(MAX_ARTBOARDS as f64) as u32 + } + "spacing" => { + self.spacing = v + .as_f64() + .filter(|s| (0.0..=MAX_SPACING).contains(s)) + .ok_or_else(|| bad(cmd, format!("artboardLayout.spacing must be 0–{MAX_SPACING} pt")))? + } + "rightToLeft" => self.right_to_left = v.as_bool().ok_or_else(|| bad(cmd, "artboardLayout.rightToLeft must be true or false"))?, + _ => return Err(bad(cmd, format!("unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft)"))), + } + } + Ok(()) + } + + /// The artboard rectangles, the first at the origin's side. + pub fn boards(&self) -> Vec { + let n = self.artboards.max(1) as usize; + let sizes = vec![(self.width, self.height); n]; + let by_column = self.layout == ArtboardLayout::GridByColumn; + grid_origins(&sizes, Point::ZERO, self.grid_columns(), self.spacing, by_column, self.right_to_left) + .into_iter() + .map(|o| Rect::from_origin_size(o, (self.width, self.height))) + .collect() + } + + /// The new document (title and swatches set by `file.new`). + pub fn document(&self) -> Document { + let mut d = Document::new_with_mode(self.width, self.height, self.color_mode); + d.units = self.units; + d.raster_effects_ppi = self.raster_effects_ppi; + d.setup.bleed = self.bleed; + d.setup.background = self.background; + d.artboards = self + .boards() + .into_iter() + .enumerate() + .map(|(i, rect)| Artboard { + id: i as u32 + 1, + name: format!("Artboard {}", i + 1), + rect, + show_center_mark: false, + show_cross_hairs: false, + }) + .collect(); + d + } +} + +/// Largest artboard side `file.new` accepts, points. +const MAX_SIDE: f64 = 16383.0 * 10.0; +/// Largest artboard spacing, points. +const MAX_SPACING: f64 = 1.0e5; + +pub fn color_mode_id(m: ColorMode) -> &'static str { + match m { + ColorMode::Rgb => "rgb", + ColorMode::Cmyk => "cmyk", + } +} + +/// Top-left corners for artboards of `sizes` from `origin`: `columns` per row, `spacing` apart, +/// each column as wide and each row as tall as its largest artboard; `by_column` fills columns +/// top to bottom first, `rtl` puts the first column at the right. +pub(crate) fn grid_origins(sizes: &[(f64, f64)], origin: Point, columns: usize, spacing: f64, by_column: bool, rtl: bool) -> Vec { + let n = sizes.len(); + let cols = columns.clamp(1, n.max(1)); + let rows = n.div_ceil(cols).max(1); + // Grid cell (row, column) of artboard i. + let cell = |i: usize| { + let (r, c) = if by_column { (i % rows, i / rows) } else { (i / cols, i % cols) }; + (r, if rtl { cols - 1 - c.min(cols - 1) } else { c }) + }; + let (mut col_w, mut row_h) = (vec![0.0f64; cols], vec![0.0f64; rows]); + for (i, (w, h)) in sizes.iter().enumerate() { + let (r, c) = cell(i); + if let (Some(cw), Some(rh)) = (col_w.get_mut(c), row_h.get_mut(r)) { + *cw = cw.max(*w); + *rh = rh.max(*h); + } + } + let offset = |spans: &[f64], k: usize| spans.iter().take(k).map(|s| s + spacing).sum::(); + (0..n) + .map(|i| { + let (r, c) = cell(i); + Point::new(origin.x + offset(&col_w, c), origin.y + offset(&row_h, r)) + }) + .collect() +} + +/// The built-in presets: (category, presets as (name, width, height, units)), with the category's +/// colour mode and raster effects resolution. Sizes in `units` (converted to points on use). +type Category = (&'static str, ColorMode, f64, &'static [(&'static str, f64, f64, Unit)]); + +const PX: Unit = Unit::Pixels; +const PT: Unit = Unit::Points; +const MM: Unit = Unit::Millimeters; +const IN: Unit = Unit::Inches; + +pub const CATEGORIES: &[Category] = &[ + ( + "Mobile", + ColorMode::Rgb, + 72.0, + &[ + ("Phone 390×844", 390.0, 844.0, PX), + ("Phone 393×852", 393.0, 852.0, PX), + ("Phone 430×932", 430.0, 932.0, PX), + ("Phone 360×800", 360.0, 800.0, PX), + ("Phone 412×915", 412.0, 915.0, PX), + ("Phone 375×667", 375.0, 667.0, PX), + ("Tablet 768×1024", 768.0, 1024.0, PX), + ("Tablet 820×1180", 820.0, 1180.0, PX), + ("Tablet 834×1194", 834.0, 1194.0, PX), + ("Tablet 1024×1366", 1024.0, 1366.0, PX), + ("Tablet 800×1280", 800.0, 1280.0, PX), + ("Watch 184×224", 184.0, 224.0, PX), + ("Watch 198×242", 198.0, 242.0, PX), + ], + ), + ( + "Web", + ColorMode::Rgb, + 72.0, + &[ + ("Web 1920×1080", 1920.0, 1080.0, PX), + ("Web 1600×900", 1600.0, 900.0, PX), + ("Web 1440×900", 1440.0, 900.0, PX), + ("Web 1366×768", 1366.0, 768.0, PX), + ("Web 1280×800", 1280.0, 800.0, PX), + ("Web 1024×768", 1024.0, 768.0, PX), + ], + ), + ( + "Print", + ColorMode::Cmyk, + 300.0, + &[ + ("Letter", 612.0, 792.0, PT), + ("Legal", 612.0, 1008.0, PT), + ("Tabloid", 792.0, 1224.0, PT), + ("A3", 297.0, 420.0, MM), + ("A4", 210.0, 297.0, MM), + ("A5", 148.0, 210.0, MM), + ("B4", 250.0, 353.0, MM), + ("B5", 176.0, 250.0, MM), + ], + ), + ( + "Film & Video", + ColorMode::Rgb, + 72.0, + &[ + ("HDTV 1080p", 1920.0, 1080.0, PX), + ("HDTV 720p", 1280.0, 720.0, PX), + ("4K UHD", 3840.0, 2160.0, PX), + ("8K UHD", 7680.0, 4320.0, PX), + ("2K Cinema", 2048.0, 1080.0, PX), + ("4K Cinema", 4096.0, 2160.0, PX), + ], + ), + ( + "Art & Illustration", + ColorMode::Rgb, + 300.0, + &[ + ("Postcard", 4.0, 6.0, IN), + ("Greeting Card", 5.0, 7.0, IN), + ("Square 8 × 8 in", 8.0, 8.0, IN), + ("Poster 18 × 24 in", 18.0, 24.0, IN), + ("Poster 24 × 36 in", 24.0, 36.0, IN), + ], + ), + ( + "Branding", + ColorMode::Cmyk, + 300.0, + &[ + ("Logo 1000×1000", 1000.0, 1000.0, PX), + ("Business Card 3.5 × 2 in", 3.5, 2.0, IN), + ("Business Card 85 × 55 mm", 85.0, 55.0, MM), + ("Letterhead Letter", 8.5, 11.0, IN), + ("Letterhead A4", 210.0, 297.0, MM), + ("Envelope DL", 220.0, 110.0, MM), + ("Envelope #10", 9.5, 4.125, IN), + ], + ), + ( + "Social", + ColorMode::Rgb, + 72.0, + &[ + ("Social Square Post 1080×1080", 1080.0, 1080.0, PX), + ("Social Portrait Post 1080×1350", 1080.0, 1350.0, PX), + ("Social Landscape Post 1200×630", 1200.0, 630.0, PX), + ("Social Story 1080×1920", 1080.0, 1920.0, PX), + ("Social Tall Post 1000×1500", 1000.0, 1500.0, PX), + ("Profile Cover 1500×500", 1500.0, 500.0, PX), + ("Page Cover 1640×624", 1640.0, 624.0, PX), + ("Video Banner 2560×1440", 2560.0, 1440.0, PX), + ("Video Thumbnail 1280×720", 1280.0, 720.0, PX), + ], + ), +]; + +/// The category tabs in order: Recent, Saved, then the built-in categories. +pub fn category_names() -> impl Iterator { + ["Recent", "Saved"].into_iter().chain(CATEGORIES.iter().map(|c| c.0)) +} + +/// The built-in presets of one category: screen sizes in pixels, the others in `general` +/// (Preferences ▸ Units ▸ General). +fn builtin(c: &Category, general: Unit) -> impl Iterator + '_ { + let (_, color_mode, ppi, presets) = *c; + presets.iter().map(move |&(name, w, h, units)| DocSettings { + name: name.into(), + width: units.to_pt(w), + height: units.to_pt(h), + units: if units == Unit::Pixels { units } else { general }, + color_mode, + raster_effects_ppi: ppi, + ..DocSettings::default() + }) +} + +/// The presets of the category `name` (any case), or None for no such category. +pub fn category(s: &Session, name: &str) -> Option> { + if name.eq_ignore_ascii_case("Recent") { + return Some(s.prefs.recent_new_docs.clone()); + } + if name.eq_ignore_ascii_case("Saved") { + return Some(s.prefs.new_doc_presets.clone()); + } + CATEGORIES.iter().find(|c| c.0.eq_ignore_ascii_case(name)).map(|c| builtin(c, s.default_units()).collect()) +} + +/// A preset by name (any case): a saved one first, then the built-in ones. +pub fn find(s: &Session, name: &str) -> Option { + let name = name.trim(); + s.prefs + .new_doc_presets + .iter() + .find(|p| p.name.eq_ignore_ascii_case(name)) + .cloned() + .or_else(|| CATEGORIES.iter().flat_map(|c| builtin(c, s.default_units())).find(|p| p.name.eq_ignore_ascii_case(name))) +} + +impl Session { + /// New Document's starting settings: Letter in Preferences ▸ Units ▸ General. + fn default_settings(&self) -> DocSettings { + DocSettings { units: self.default_units(), ..DocSettings::default() } + } +} + +/// Recent keeps this many sizes. +const RECENT: usize = 12; + +/// `file.new`: a new document from a preset and/or explicit settings. +pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.new"; + let base = match str_param(p, "preset") { + Some(name) => find(s, name).ok_or_else(|| bad(C, format!("no preset called `{name}` (see file.newPresets)")))?, + None => s.default_settings(), + }; + // A preset keeps its name in Recent unless its size changed (turning it doesn't count). + let sorted = |d: &DocSettings| (d.width.min(d.height), d.width.max(d.height), d.units); + let size = str_param(p, "preset").map(|_| sorted(&base)); + let mut settings = base.with_params(p, C)?; + if size != Some(sorted(&settings)) { + settings.name = "Custom".into(); + } + let mut d = settings.document(); + d.title = match str_param(p, "name").or(str_param(p, "title")).map(str::trim).filter(|t| !t.is_empty()) { + // New Document offers the next untitled name: taking it uses it up. + Some(t) if t == s.peek_untitled() => s.next_untitled(), + Some(t) => t.to_string(), + None => s.next_untitled(), + }; + let i = s.add_document(d, None); + // Overprint Preview is the engine's (the UI turns Pixel Preview on from `previewMode`). Both + // are views of the whole app, so Default leaves them as they are. + if settings.preview_mode == PreviewMode::Overprint { + s.execute("view.overprintPreview", &json!({ "on": true }))?; + } + remember(s, &settings); + Ok(json!({ "index": i, "previewMode": settings.preview_mode.id() })) +} + +/// Put `settings` first in Recent (one entry per size and units). +fn remember(s: &mut Session, settings: &DocSettings) { + let recent = &mut s.prefs.recent_new_docs; + recent.retain(|r| (r.width, r.height, r.units) != (settings.width, settings.height, settings.units)); + recent.insert(0, settings.clone()); + recent.truncate(RECENT); +} + +fn list(s: &mut Session, p: &Value) -> Result { + let names: Vec<&str> = match str_param(p, "category") { + Some(c) => vec![ + category_names() + .find(|n| n.eq_ignore_ascii_case(c)) + .ok_or_else(|| bad("file.newPresets", format!("unknown category `{c}` ({})", category_names().collect::>().join(", "))))?, + ], + None => category_names().collect(), + }; + let categories: Vec = names + .into_iter() + .map(|n| json!({ "name": n, "presets": category(s, n).unwrap_or_default().iter().map(DocSettings::to_json).collect::>() })) + .collect(); + Ok(json!({ "categories": categories })) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.newPresets.save"; + let name = str_param(p, "name") + .map(str::trim) + .filter(|n| !n.is_empty() && n.chars().count() <= 64) + .ok_or_else(|| bad(C, "give the preset a name (1–64 characters)"))?; + let base = match str_param(p, "preset") { + Some(from) => find(s, from).ok_or_else(|| bad(C, format!("no preset called `{from}` (see file.newPresets)")))?, + None => s.default_settings(), + }; + let mut preset = base.with_params(p, C)?; + preset.name = name.to_string(); + let saved = &mut s.prefs.new_doc_presets; + match saved.iter_mut().find(|q| q.name.eq_ignore_ascii_case(name)) { + Some(q) => *q = preset, + None => saved.push(preset), + } + Ok(json!({ "name": name, "count": saved.len() })) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.newPresets.delete"; + let name = str_param(p, "name").unwrap_or("").trim(); + let saved = &mut s.prefs.new_doc_presets; + let i = saved.iter().position(|q| q.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no saved preset called `{name}`")))?; + Ok(json!({ "deleted": saved.remove(i).name })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 02063d0d..25a934c3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -356,6 +356,13 @@ pub struct Prefs { /// dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting keeps it. #[serde(skip_serializing_if = "Vec::is_empty")] pub color_themes: Vec, + /// New Document → Saved: the user's document presets (`file.newPresets.save`). A local + /// library, not a Preferences dialog field: resetting the preferences keeps it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub new_doc_presets: Vec, + /// New Document → Recent: the settings of the last documents made (newest first). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub recent_new_docs: Vec, } impl Default for Prefs { @@ -479,6 +486,8 @@ impl Default for Prefs { japanese_crop_marks: false, new_art_basic: true, color_themes: vec![], + new_doc_presets: vec![], + recent_new_docs: vec![], } } } @@ -616,6 +625,10 @@ impl Session { false } } + /// The name [`Session::next_untitled`] gives next (New Document's Name field). + pub fn peek_untitled(&self) -> String { + format!("Untitled-{}", self.untitled_counter + 1) + } pub fn next_untitled(&mut self) -> String { self.untitled_counter += 1; format!("Untitled-{}", self.untitled_counter) @@ -953,6 +966,8 @@ mod tests_menucmds; #[cfg(test)] mod tests_newart; #[cfg(test)] +mod tests_newdoc; +#[cfg(test)] mod tests_objexpand; #[cfg(test)] mod tests_opacitymask; diff --git a/crates/engine/src/tests_newdoc.rs b/crates/engine/src/tests_newdoc.rs new file mode 100644 index 00000000..3581caab --- /dev/null +++ b/crates/engine/src/tests_newdoc.rs @@ -0,0 +1,225 @@ +//! File → New (`file.new`, `file.newPresets*`): artboard layouts, orientation, bleed, colour mode, +//! raster effects resolution, background, presets, Recent and Saved. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Background, ColorMode, Unit}; +use vectorcraft_geom::Rect; + +use super::*; + +fn new_doc(p: Value) -> Session { + let mut s = Session::new(); + s.execute("file.new", &p).unwrap(); + s +} + +fn boards(s: &Session) -> Vec { + s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect() +} + +fn presets(s: &mut Session, category: &str) -> Vec { + let r = s.execute("file.newPresets", &json!({ "category": category })).unwrap(); + r["categories"][0]["presets"].as_array().unwrap().clone() +} + +#[test] +fn four_artboards_in_two_columns_make_a_two_by_two_grid() { + let s = new_doc(json!({"width": 100, "height": 50, "artboards": 4, "artboardLayout": {"columns": 2, "spacing": 10}})); + let origins: Vec<(f64, f64)> = boards(&s).iter().map(|r| (r.x0, r.y0)).collect(); + assert_eq!(origins, [(0.0, 0.0), (110.0, 0.0), (0.0, 60.0), (110.0, 60.0)]); + assert!(boards(&s).iter().all(|r| r.width() == 100.0 && r.height() == 50.0)); + let names: Vec = s.doc().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect(); + assert_eq!(names, ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 4"]); + // Grid by Column fills down first; Right-to-Left starts at the right. + let s = new_doc(json!({"width": 100, "height": 50, "artboards": 4, "artboardLayout": {"layout": "gridByColumn", "columns": 2, "spacing": 10}})); + assert_eq!(boards(&s).iter().map(|r| (r.x0, r.y0)).collect::>(), [(0.0, 0.0), (0.0, 60.0), (110.0, 0.0), (110.0, 60.0)]); + let s = new_doc(json!({"width": 100, "height": 50, "artboards": 3, "artboardLayout": {"layout": "row", "rightToLeft": true}})); + assert_eq!(boards(&s).iter().map(|r| r.x0).collect::>(), [240.0, 120.0, 0.0]); + let s = new_doc(json!({"width": 100, "height": 50, "artboards": 3, "artboardLayout": "column"})); + assert_eq!(boards(&s).iter().map(|r| r.y0).collect::>(), [0.0, 70.0, 140.0]); + // Without a layout: one row, 20 pt apart (as before). + let s = new_doc(json!({"width": 100, "height": 50, "artboards": 2})); + assert_eq!(boards(&s)[1].x0, 120.0); +} + +#[test] +fn orientation_swaps_width_and_height() { + let s = new_doc(json!({"width": 612, "height": 792, "orientation": "landscape"})); + assert_eq!((boards(&s)[0].width(), boards(&s)[0].height()), (792.0, 612.0)); + let s = new_doc(json!({"width": 792, "height": 612, "orientation": "Portrait"})); + assert_eq!((boards(&s)[0].width(), boards(&s)[0].height()), (612.0, 792.0)); + let s = new_doc(json!({"preset": "HDTV 1080p", "orientation": "portrait"})); + assert_eq!((boards(&s)[0].width(), boards(&s)[0].height()), (1080.0, 1920.0)); +} + +#[test] +fn every_setting_reaches_the_document() { + let mut s = Session::new(); + let r = s + .execute( + "file.new", + &json!({"name": "Poster", "width": "210 mm", "height": "297mm", "units": "Millimeters", "bleed": [9, 9, 4.5, 4.5], + "backgroundContents": "white", "colorMode": "cmyk", "rasterEffectsPpi": 150}), + ) + .unwrap(); + assert_eq!(r["previewMode"], "default"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.title, "Poster"); + assert_eq!(d.units, Unit::Millimeters); + assert!((d.artboards[0].rect.width() - 595.2756).abs() < 1e-3); + assert_eq!(d.setup.bleed, [9.0, 9.0, 4.5, 4.5]); + assert_eq!(d.setup.background, Background::White); + assert_eq!(d.color_mode, ColorMode::Cmyk); + assert_eq!(d.raster_effects_ppi, 150.0); + // Saved and reopened, the settings stay. + let back = vectorcraft_format::load(&vectorcraft_format::save_file(d)).unwrap(); + assert_eq!((back.raster_effects_ppi, back.setup.bleed, back.setup.background), (150.0, d.setup.bleed, Background::White)); +} + +#[test] +fn a_white_background_exports_opaque() { + let alpha = |bg: &str| { + let mut s = new_doc(json!({"width": 20, "height": 20, "backgroundContents": bg})); + let r = s.execute("document.export", &json!({"format": "png"})).unwrap(); + let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(10, 10).0 + }; + assert_eq!(alpha("white"), [255, 255, 255, 255]); + assert_eq!(alpha("transparent")[3], 0); +} + +#[test] +fn a_preset_starts_the_document_and_params_change_it() { + let s = new_doc(json!({"preset": "a4"})); + let d = &s.doc().unwrap().doc; + assert_eq!((d.units, d.color_mode, d.raster_effects_ppi), (Unit::Points, ColorMode::Cmyk, 300.0), "print sizes in prefs unitsGeneral"); + assert!((d.artboards[0].rect.height() - 841.89).abs() < 0.01); + let s = new_doc(json!({"preset": "Web 1920×1080", "colorMode": "cmyk", "artboards": 2})); + let d = &s.doc().unwrap().doc; + assert_eq!((d.units, d.color_mode, d.artboards.len()), (Unit::Pixels, ColorMode::Cmyk, 2)); + assert!(Session::new().execute("file.new", &json!({"preset": "Napkin"})).is_err()); + // Print presets and plain sizes start in Preferences ▸ Units ▸ General; screen presets in pixels. + let mut s = Session::new(); + s.execute("prefs.set", &json!({"key": "unitsGeneral", "value": "millimeters"})).unwrap(); + s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.units, Unit::Millimeters); + assert_eq!(presets(&mut s, "Print")[4]["size"], "210 × 297 mm"); + s.execute("file.new", &json!({"width": 100})).unwrap(); + assert_eq!(s.doc().unwrap().doc.units, Unit::Millimeters); + s.execute("file.new", &json!({"preset": "HDTV 720p"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.units, Unit::Pixels); +} + +#[test] +fn ppi_overprint_preview_and_bad_params() { + // Overprint Preview is process-wide. + let _view = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = new_doc(json!({"rasterEffectsPpi": 300, "previewMode": "overprint"})); + assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 300.0); + let overprint = |s: &mut Session| s.execute("view.proofSetup", &json!({})).unwrap()["overprintPreview"].clone(); + assert_eq!(overprint(&mut s), true); + s.execute("view.overprintPreview", &json!({"on": false})).unwrap(); + let r = s.execute("file.new", &json!({"previewMode": "pixel"})).unwrap(); + assert_eq!(r["previewMode"], "pixel", "for the app's Pixel Preview"); + assert_eq!(overprint(&mut s), false); + for p in [ + json!({"orientation": "sideways"}), + json!({"width": -5}), + json!({"width": "wide"}), + json!({"bleed": 100}), + json!({"backgroundContents": "plaid"}), + json!({"colorMode": "lab"}), + json!({"rasterEffectsPpi": 0}), + json!({"previewMode": "xray"}), + json!({"artboardLayout": {"layout": "spiral"}}), + json!({"artboardLayout": {"columns": 0}}), + json!({"artboardLayout": {"rows": 2}}), + json!({"units": "furlongs"}), + json!({"width": 160000, "artboards": 1000}), + ] { + let mut s = Session::new(); + assert!(s.execute("file.new", &p).is_err(), "{p}"); + assert!(s.active().is_none(), "{p}: no document on an error"); + } +} + +#[test] +fn categories_list_generated_presets_with_neutral_names() { + let mut s = Session::new(); + let r = s.execute("file.newPresets", &json!({})).unwrap(); + let names: Vec<&str> = r["categories"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Recent", "Saved", "Mobile", "Web", "Print", "Film & Video", "Art & Illustration", "Branding", "Social"]); + let print = presets(&mut s, "print"); + let letter = print.iter().find(|p| p["name"] == "Letter").unwrap(); + assert_eq!( + (letter["size"].as_str(), letter["colorMode"].as_str(), letter["orientation"].as_str()), + (Some("612 × 792 pt"), Some("cmyk"), Some("portrait")) + ); + assert_eq!(presets(&mut s, "Film & Video").iter().find(|p| p["name"] == "4K UHD").unwrap()["size"], "3840 × 2160 px"); + for c in ["Mobile", "Web", "Print", "Film & Video", "Art & Illustration", "Branding", "Social"] { + assert!(!presets(&mut s, c).is_empty(), "{c}"); + } + // A listed preset can be passed straight back to file.new. + let story = presets(&mut s, "Social").into_iter().find(|p| p["name"] == "Social Story 1080×1920").unwrap(); + s.execute("file.new", &story).unwrap(); + assert_eq!(boards(&s)[0], Rect::new(0.0, 0.0, 1080.0, 1920.0)); + assert!(s.execute("file.newPresets", &json!({"category": "Furniture"})).is_err()); +} + +#[test] +fn recent_remembers_the_last_sizes() { + let mut s = Session::new(); + assert!(presets(&mut s, "Recent").is_empty()); + s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + s.execute("file.new", &json!({"preset": "A4", "orientation": "landscape"})).unwrap(); + let recent = presets(&mut s, "Recent"); + let names: Vec<&str> = recent.iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["A4", "Custom", "A4"], "newest first; a turned preset keeps its name"); + // The same size again moves to the front instead of repeating. + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + assert_eq!(presets(&mut s, "Recent").len(), 3); + assert_eq!(presets(&mut s, "Recent")[0]["size"], "300 × 200 pt"); + for i in 0..20 { + s.execute("file.new", &json!({"width": 100 + i, "height": 100})).unwrap(); + } + assert_eq!(presets(&mut s, "Recent").len(), 12); +} + +#[test] +fn saved_presets_live_in_the_preferences() { + let mut s = Session::new(); + assert!(s.execute("file.newPresets.delete", &json!({"name": "Mine"})).is_err()); + let r = s + .execute( + "file.newPresets.save", + &json!({"name": "Mine", "width": 400, "height": 300, "units": "Pixels", "artboards": 4, "artboardLayout": {"columns": 2}, "bleed": 9}), + ) + .unwrap(); + assert_eq!(r, json!({"name": "Mine", "count": 1})); + let saved = presets(&mut s, "Saved"); + assert_eq!( + (saved[0]["name"].as_str(), saved[0]["size"].as_str(), saved[0]["artboardLayout"]["columns"].as_u64()), + (Some("Mine"), Some("400 × 300 px"), Some(2)) + ); + // The same name replaces it. + s.execute("file.newPresets.save", &json!({"name": "mine", "width": 500, "height": 300})).unwrap(); + assert_eq!(presets(&mut s, "Saved").len(), 1); + s.execute("file.new", &json!({"preset": "Mine"})).unwrap(); + assert_eq!(boards(&s)[0].width(), 500.0); + // Kept with the preferences, and by a reset. + let prefs: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(prefs.new_doc_presets, s.prefs.new_doc_presets); + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.prefs.new_doc_presets.len(), 1); + assert_eq!(s.execute("file.newPresets.delete", &json!({"name": "MINE"})).unwrap(), json!({"deleted": "mine"})); + assert!(presets(&mut s, "Saved").is_empty()); + assert!(s.execute("file.newPresets.save", &json!({"name": " "})).is_err()); +} + +#[test] +fn rearrange_keeps_its_grid() { + let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3})); + s.execute("artboard.rearrange", &json!({"columns": 2, "spacing": 5})).unwrap(); + assert_eq!(boards(&s).iter().map(|r| (r.x0, r.y0)).collect::>(), [(0.0, 0.0), (105.0, 0.0), (0.0, 55.0)]); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8f6574e5..621c0b5f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1053,31 +1053,14 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { ui.add_space(28.0); ui.label(egui::RichText::new("Quickly start a new file").font(theme::semibold(14.0)).color(t.text)); ui.add_space(10.0); - // (name, width, height in points, in pixels); print sizes show in Preferences ▸ Units ▸ General. - let presets: [(&str, f64, f64, bool); 6] = [ - ("Letter", 612.0, 792.0, false), - ("A4", 595.28, 841.89, false), - ("Web 1920", 1920.0, 1080.0, true), - ("Mobile", 390.0, 844.0, true), - ("Postcard", 432.0, 288.0, false), - ("Square", 1080.0, 1080.0, true), - ]; - let print = app.session.default_units(); + // A few of New Document's presets (`file.newPresets`), as its cards. + let presets = ["Letter", "A4", "Web 1920×1080", "Phone 390×844", "Postcard", "Social Square Post 1080×1080"]; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(14.0, 14.0); - for (name, w, h, pixels) in presets { - let unit = if pixels { Unit::Pixels } else { print }; - let size = format!("{} × {}", unit.number(w), unit.format(h)); - let (r, resp) = ui.allocate_exact_size(vec2(120.0, 132.0), Sense::click()); - ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel }); - let s = (70.0 / w.max(h)) as f32; - let pr = egui::Rect::from_center_size(r.center_top() + vec2(0.0, 50.0), vec2(w as f32 * s, h as f32 * s)); - ui.painter().rect_filled(pr.translate(vec2(2.0, 2.0)), 0.0, Color32::from_black_alpha(80)); - ui.painter().rect_filled(pr, 0.0, Color32::WHITE); - ui.painter().text(r.center_bottom() - vec2(0.0, 30.0), egui::Align2::CENTER_CENTER, name, theme::semibold(12.5), t.text); - ui.painter().text(r.center_bottom() - vec2(0.0, 14.0), egui::Align2::CENTER_CENTER, size, egui::FontId::proportional(11.0), t.text_dim); - if resp.clicked() { - app.run("file.new", json!({"width": w, "height": h, "units": unit.label()})).ok(); + let cards: Vec<_> = presets.iter().filter_map(|name| vectorcraft_engine::cmd::newdoc::find(&app.session, name)).collect(); + for s in cards { + if crate::dialogs::preset_card(ui, &s, false).clicked() { + app.run("file.new", json!({ "preset": s.name })).ok(); } } }); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index c4bba538..0fb85a48 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -322,3 +322,9 @@ pub(super) fn bleed(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un d.fields.insert("bleed".into(), json!(b)); } } + +/// A small grey caption above a field (New Document's details column). +pub(super) fn caption(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).size(11.0).color(t.text_dim)); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index d53cfe34..00a97b5e 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -49,7 +49,7 @@ use serde_json::{Value, json}; pub use color_picker::open as open_color_picker; pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; -pub use new_document::open as open_new_document; +pub use new_document::{open as open_new_document, preset_card}; pub use save_pdf::open as open_save_pdf; pub use tools::open_tool_dialog; @@ -138,7 +138,7 @@ macro_rules! registry { } registry! { - NewDocument: ["newDocument"] => new_document::SPEC, + NewDocument: [new_document::KIND] => new_document::SPEC, Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment"] => shapes::SPEC, Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC, PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC, @@ -177,6 +177,7 @@ registry! { WidthPoint: [width_point::KIND] => width_point::SPEC, SavePdf: [save_pdf::KIND] => save_pdf::SPEC, SvgOptions: [svg_options::KIND] => svg_options::SPEC, + NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 8be06d8f..441f2480 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -1,91 +1,666 @@ -//! File → New: presets and the document details (name, size, units, artboards, colour mode). +//! File → New: category tabs (Recent, Saved and the built-in categories of `file.newPresets`) +//! over a grid of preset cards, and a Preset Details column (name and Save Preset, size and +//! units, orientation, artboards, bleed, background contents, Advanced Options, More Settings); +//! Close and Create. More Settings is the classic form of the same fields (artboard layout, +//! spacing and columns, profile and size lists), so both share one set of fields and widgets. //! -//! Fields: `preset`, `name`, `width` and `height` (points, or strings with a unit), `units` (a unit -//! label; print presets start in Preferences ▸ Units ▸ General), `artboards`, `colorMode`. +//! Fields: `file.new`'s params (`preset`, `name`, `width`, `height` (pt), `units`, `artboards`, +//! `artboardLayout`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi`, +//! `previewMode`), plus `category` (the tab, also More Settings' Profile), `advanced` (Advanced +//! Options open), `bleedLinked` and `presetName` (Save Preset's name). Setting `preset` fills the +//! fields from that preset; changing any of them afterwards makes the settings custom. -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use vectorcraft_doc::Unit; +use vectorcraft_engine::cmd::newdoc::{self, ArtboardLayout, DocSettings, MAX_ARTBOARDS, PreviewMode, RASTER_PPI}; use super::{DialogSpec, form}; use crate::state::Dialog; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, widgets}; -pub(super) const SPEC: DialogSpec = - DialogSpec { heading: |_| "New Document".into(), body, confirm, ok: Some("Create"), min_width: 560.0, ..DialogSpec::FORM }; +pub(super) const KIND: &str = "newDocument"; +/// More Settings. +pub(super) const MORE: &str = "newDocumentMore"; -/// The presets: (name, width, height in points, in pixels). -const PRESETS: [(&str, f64, f64, bool); 8] = [ - ("Letter", 612.0, 792.0, false), - ("Legal", 612.0, 1008.0, false), - ("Tabloid", 792.0, 1224.0, false), - ("A4", 595.28, 841.89, false), - ("A3", 841.89, 1190.55, false), - ("Web 1920", 1920.0, 1080.0, true), - ("Phone 390×844", 390.0, 844.0, true), - ("Square Post", 1080.0, 1080.0, true), +pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm); +pub(super) const MORE_SPEC: DialogSpec = DialogSpec::window(show_more, confirm); + +/// The fields `file.new` takes. +const PARAMS: [&str; 12] = [ + "preset", + "name", + "width", + "height", + "units", + "artboards", + "artboardLayout", + "bleed", + "backgroundContents", + "colorMode", + "rasterEffectsPpi", + "previewMode", ]; +/// The preset the fields were last filled from (its fields as `apply` set them). +const APPLIED: &str = "__applied"; +/// Save Preset's name field is showing. +const SAVING: &str = "__saving"; +/// The units New Document offers (all ten). +const UNITS: [Unit; 10] = [ + Unit::Pixels, + Unit::Points, + Unit::Picas, + Unit::Inches, + Unit::Millimeters, + Unit::Centimeters, + Unit::Feet, + Unit::Yards, + Unit::Meters, + Unit::FeetInches, +]; +const BACKGROUNDS: [(&str, &str); 2] = [("transparent", "Transparent (Default)"), ("white", "White")]; +const COLOR_MODES: [(&str, &str); 2] = [("rgb", "RGB Color"), ("cmyk", "CMYK Color")]; +/// Width of the Preset Details column. +const DETAILS: f32 = 292.0; +/// Height of the dialog's content. +const HEIGHT: f32 = 600.0; -/// The units a preset starts in: pixels for screen presets, else the General preference. -fn preset_units(app: &VectorcraftApp, pixels: bool) -> Unit { - if pixels { Unit::Pixels } else { app.session.default_units() } -} - -/// Open the dialog on the Letter preset. +/// Open New Document on the most recent settings (or Letter), named after the next untitled +/// document. pub fn open(app: &mut VectorcraftApp) { - let units = preset_units(app, false).label(); - let fields = json!({"preset": "Letter", "width": 612, "height": 792, "units": units, "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}); - app.ui.dialog = Some(Dialog::new("newDocument", fields)); + let category = if app.session.prefs.recent_new_docs.is_empty() { "Print" } else { "Recent" }; + let first = newdoc::category(&app.session, category).and_then(|v| v.into_iter().next()).unwrap_or_default(); + let mut d = Dialog::new(KIND, json!({ "category": category, "name": app.session.peek_untitled(), "advanced": false })); + apply(&mut d, &first); + app.ui.dialog = Some(d); } -fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.set_width(300.0); - ui.label(egui::RichText::new("Presets").color(t.text_dim)); - ui.horizontal_wrapped(|ui| { - for (name, w, h, pixels) in PRESETS { - let sel = d.str("preset") == name; - if ui.selectable_label(sel, name).clicked() { - d.fields.insert("preset".into(), json!(name)); - d.fields.insert("width".into(), json!(w)); - d.fields.insert("height".into(), json!(h)); - d.fields.insert("units".into(), json!(preset_units(app, pixels).label())); - } - } - }); - }); - ui.separator(); - ui.vertical(|ui| { - ui.label(egui::RichText::new("Preset Details").font(theme::semibold(12.5))); - let unit = Unit::named(&d.str("units")).unwrap_or_default(); - egui::Grid::new("newdoc").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { - form::field(ui, d, "name", "Name:"); - form::length_field(ui, d, "width", "Width:", unit); - form::length_field(ui, d, "height", "Height:", unit); - ui.label(egui::RichText::new("Units:").color(t.text_dim)); - let labels: Vec<&str> = Unit::ALL.iter().map(|u| u.label()).collect(); - if let Some(i) = widgets::dropdown(ui, "newdoc-units", unit.label(), &labels, 132.0) { - d.fields.insert("units".into(), json!(labels[i])); - } - ui.end_row(); - form::field(ui, d, "artboards", "Artboards:"); - form::field(ui, d, "colorMode", "Color Mode:"); - }); - }); - }); - false +/// Fill the fields from a preset. +fn apply(d: &mut Dialog, s: &DocSettings) { + let Value::Object(mut v) = s.to_json() else { return }; + for k in ["size", "orientation"] { + v.remove(k); + } + if let Some(name) = v.remove("name") { + v.insert("preset".into(), name); + } + d.fields.insert("bleedLinked".into(), json!(s.bleed.iter().all(|b| *b == s.bleed[0]))); + for (k, val) in &v { + d.fields.insert(k.clone(), val.clone()); + } + d.fields.insert(APPLIED.into(), Value::Object(v)); +} + +/// Keep the fields and the chosen preset in step: a new `preset` (also from `ui.dialog.set`) fills +/// the fields; a changed field makes the settings custom. +fn sync(app: &VectorcraftApp, d: &mut Dialog) { + let preset = d.str("preset"); + match d.fields.get(APPLIED).and_then(Value::as_object) { + Some(a) if a.get("preset").and_then(Value::as_str) == Some(preset.as_str()) => { + if a.iter().any(|(k, v)| k != "preset" && d.fields.get(k) != Some(v)) { + d.fields.insert("preset".into(), json!("")); + d.fields.remove(APPLIED); + } + } + _ if !preset.is_empty() => match newdoc::find(&app.session, &preset) { + Some(s) => apply(d, &s), + None => { + d.fields.remove(APPLIED); + } + }, + _ => {} + } } /// Create the document; the dialog stays open when that fails (bad size, …). fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let r = app.run( - "file.new", - json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()}), - ); + let mut p: Map = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); + // Recent entries such as "Custom" aren't presets file.new knows. + if newdoc::find(&app.session, &d.str("preset")).is_none() { + p.remove("preset"); + } + let r = app.run("file.new", Value::Object(p)); if r.is_ok() { app.ui.dialog = None; } r } + +/// The units the size fields show. +fn unit(d: &Dialog) -> Unit { + Unit::named(&d.str("units")).unwrap_or_default() +} + +/// The artboard layout fields. +fn layout(d: &Dialog) -> (ArtboardLayout, u64, f64, bool) { + let l = d.fields.get("artboardLayout"); + let get = |k: &str| l.and_then(|l| l.get(k)); + ( + get("layout").and_then(Value::as_str).and_then(ArtboardLayout::parse).unwrap_or_default(), + get("columns").and_then(Value::as_u64).unwrap_or(1), + get("spacing").and_then(Value::as_f64).unwrap_or(20.0), + get("rightToLeft").and_then(Value::as_bool).unwrap_or(false), + ) +} + +fn set_layout(d: &mut Dialog, key: &str, v: Value) { + let mut l = d.fields.get("artboardLayout").cloned().filter(Value::is_object).unwrap_or_else(|| json!({})); + l[key] = v; + d.fields.insert("artboardLayout".into(), l); +} + +/// The dimmed backdrop and a centred dialog window in the shared dialog style. +fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui::Ui)) { + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| { + ui.allocate_rect(ctx.content_rect(), egui::Sense::click()); + }); + egui::Window::new(id) + .id(egui::Id::new(("dialog", id))) + .order(egui::Order::Foreground) + .collapsible(false) + .resizable(false) + .title_bar(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0]) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin))) + .show(ctx, add); +} + +/// What the buttons of a window asked for. +#[derive(Default)] +struct Buttons { + create: bool, + close: bool, +} + +impl Buttons { + /// Act on the buttons (Enter also creates) once the window is drawn. + fn finish(self, app: &mut VectorcraftApp, ctx: &egui::Context, d: Dialog) { + let create = self.create || ctx.input(|i| i.key_pressed(egui::Key::Enter)) && !ctx.memory(|m| m.focused().is_some()); + app.ui.dialog = Some(d); + if self.close { + app.ui.dialog = None; + } else if create && let Err(e) = super::confirm(app) { + app.status(e); + } + } +} + +fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { + let Some(mut d) = app.ui.dialog.clone() else { return }; + sync(app, &mut d); + let t = Tokens::get(ctx); + let mut b = Buttons::default(); + window(ctx, KIND, 0, |ui| { + ui.horizontal_top(|ui| { + egui::Frame::NONE.inner_margin(egui::Margin { left: 22, right: 14, top: 14, bottom: 18 }).show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(640.0); + ui.set_min_height(HEIGHT - 32.0); + presets(app, ui, &mut d); + }); + }); + egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::same(18)).show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(DETAILS); + ui.set_min_height(HEIGHT); + details(app, ui, &mut d, &mut b); + }); + }); + }); + }); + b.finish(app, ctx, d); +} + +/// The category tabs and the chosen category's preset cards. +fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + let names: Vec<&str> = newdoc::category_names().collect(); + let cat = names.iter().position(|n| n.eq_ignore_ascii_case(&d.str("category"))).unwrap_or(0); + if let Some(i) = widgets::tab_bar(ui, &names, cat) { + d.fields.insert("category".into(), json!(names[i])); + } + let list = names.get(cat).and_then(|c| newdoc::category(&app.session, c)).unwrap_or_default(); + ui.add_space(14.0); + let heading = if cat == 0 { + "RECENT" + } else if cat == 1 { + "SAVED" + } else { + "BLANK DOCUMENT PRESETS" + }; + ui.label(egui::RichText::new(format!("{heading} ({})", list.len())).font(theme::semibold(11.0)).color(t.text_dim)); + ui.add_space(10.0); + if list.is_empty() { + let hint = if cat == 1 { "Presets you save with the Save Preset button appear here." } else { "Documents you create appear here." }; + ui.label(egui::RichText::new(hint).color(t.text_dim)); + return; + } + egui::ScrollArea::vertical().id_salt(("newdoc-presets", cat)).max_height(HEIGHT - 110.0).auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0); + let (w, h, preset) = (d.f64("width", 0.0), d.f64("height", 0.0), d.str("preset")); + for s in &list { + let selected = d.fields.contains_key(APPLIED) && s.name == preset && s.width == w && s.height == h; + if preset_card(ui, s, selected).clicked() { + apply(d, s); + } + } + }); + }); +} + +/// A preset card: a page-proportioned thumbnail, the name and the size (New Document, the Home +/// screen). Returns the card's response. +pub fn preset_card(ui: &mut egui::Ui, s: &DocSettings, selected: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(egui::vec2(146.0, 150.0), egui::Sense::click()); + let p = ui.painter(); + p.rect_filled(r, egui::CornerRadius::same(6), if resp.hovered() || selected { t.hover } else { t.panel_darker }); + if selected { + p.rect_stroke(r, egui::CornerRadius::same(6), egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + } + let k = (60.0 / s.width.max(s.height).max(1e-9)) as f32; + let page = egui::Rect::from_center_size( + r.center_top() + egui::vec2(0.0, 48.0), + egui::vec2((s.width as f32 * k).max(4.0), (s.height as f32 * k).max(4.0)), + ); + p.rect_filled(page.translate(egui::vec2(2.0, 2.0)), 0.0, egui::Color32::from_black_alpha(80)); + p.rect_filled(page, 0.0, egui::Color32::WHITE); + // The name on up to two lines, then the size. + let mut job = egui::text::LayoutJob::single_section( + s.name.clone(), + egui::TextFormat { font_id: theme::semibold(11.5), color: t.text_strong, ..Default::default() }, + ); + job.halign = egui::Align::Center; + job.wrap = egui::text::TextWrapping { max_width: r.width() - 14.0, max_rows: 2, break_anywhere: false, overflow_character: Some('…') }; + let name = ui.fonts_mut(|f| f.layout_job(job)); + let name_h = name.size().y; + p.galley(egui::pos2(r.center().x, r.top() + 94.0), name, t.text_strong); + p.text(egui::pos2(r.center().x, r.top() + 98.0 + name_h), egui::Align2::CENTER_TOP, s.size_label(), egui::FontId::proportional(11.0), t.text_dim); + resp.on_hover_text(&s.name) +} + +/// The Preset Details column. +fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut Buttons) { + let t = Tokens::get(ui.ctx()); + ui.spacing_mut().item_spacing = egui::vec2(6.0, 4.0); + let top = ui.cursor().top(); + ui.label(egui::RichText::new("PRESET DETAILS").font(theme::semibold(11.0)).color(t.text_dim)); + ui.add_space(8.0); + ui.horizontal(|ui| { + form::text(ui, d, "name", DETAILS - 50.0); + if widgets::icon_button(ui, "save", "Save Preset", d.bool(SAVING), 26.0).clicked() { + let saving = !d.bool(SAVING); + d.fields.insert(SAVING.into(), json!(saving)); + if saving && d.str("presetName").is_empty() { + d.fields.insert("presetName".into(), json!(format!("Preset {}", app.session.prefs.new_doc_presets.len() + 1))); + } + } + }); + if d.bool(SAVING) { + save_row(app, ui, d); + } + ui.add_space(8.0); + let unit = unit(d); + form::caption(ui, "Width"); + ui.horizontal(|ui| { + form::length(ui, d, "width", unit, DETAILS - 124.0); + units_dropdown(ui, d, 112.0); + }); + ui.add_space(4.0); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + form::caption(ui, "Height"); + form::length(ui, d, "height", unit, 104.0); + }); + ui.add_space(6.0); + ui.vertical(|ui| { + form::caption(ui, "Orientation"); + orientation(ui, d); + }); + ui.add_space(6.0); + ui.vertical(|ui| { + form::caption(ui, "Artboards"); + artboards(ui, d, 76.0); + }); + }); + ui.add_space(8.0); + form::caption(ui, "Bleed"); + form::bleed(ui, d, unit, 52.0); + ui.add_space(8.0); + form::caption(ui, "Background Contents"); + background(ui, d, DETAILS - 34.0); + ui.add_space(8.0); + advanced(ui, d, 110.0, DETAILS - 116.0); + ui.add_space(12.0); + if widgets::flat_button(ui, "More Settings", DETAILS).clicked() { + d.kind = MORE.into(); + } + // Close and Create at the bottom right. + ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0)); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + b.create = widgets::primary_button(ui, "Create").clicked(); + ui.add_space(8.0); + b.close = widgets::secondary_button(ui, "Close").clicked(); + }); +} + +/// Save Preset: the preset's name, Save and Cancel. +fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + ui.add_space(4.0); + form::caption(ui, "Preset Name"); + ui.horizontal(|ui| { + let enter = form::text_edit(ui, d, "presetName", DETAILS - 140.0).lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if widgets::flat_button(ui, "Save", 54.0).clicked() || enter { + let mut p: Map = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); + p.insert("name".into(), json!(d.str("presetName"))); + p.remove("preset"); + match app.run("file.newPresets.save", Value::Object(p)) { + Ok(r) => { + d.fields.insert(SAVING.into(), json!(false)); + d.fields.insert("category".into(), json!("Saved")); + d.fields.insert("preset".into(), r["name"].clone()); + d.fields.remove(APPLIED); + } + Err(e) => app.status(e), + } + } + if widgets::flat_button(ui, "Cancel", 60.0).clicked() { + d.fields.insert(SAVING.into(), json!(false)); + } + }); +} + +fn units_dropdown(ui: &mut egui::Ui, d: &mut Dialog, width: f32) { + let labels = UNITS.map(Unit::label); + if let Some(u) = widgets::dropdown(ui, "newdoc-units", unit(d).label(), &labels, width).and_then(|i| UNITS.get(i)) { + d.fields.insert("units".into(), json!(u.label())); + } +} + +/// Portrait and landscape: choosing the other one swaps width and height. +fn orientation(ui: &mut egui::Ui, d: &mut Dialog) { + let (w, h) = (d.f64("width", 612.0), d.f64("height", 792.0)); + let landscape = w > h; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (is_landscape, tip) in [(false, "Portrait"), (true, "Landscape")] { + if widgets::orientation_button(ui, is_landscape, landscape == is_landscape, tip) && landscape != is_landscape { + d.fields.insert("width".into(), json!(h)); + d.fields.insert("height".into(), json!(w)); + } + } + }); +} + +fn artboards(ui: &mut egui::Ui, d: &mut Dialog, width: f32) { + let n = d.f64("artboards", 1.0); + ui.horizontal(|ui| { + if let Some(v) = widgets::spin_plain(ui, "newdoc-artboards", n, "", 0, width, 1.0, 1.0, &[]) { + d.fields.insert("artboards".into(), json!(v.round().clamp(1.0, MAX_ARTBOARDS as f64) as u64)); + } + }); +} + +/// Background Contents with its chip: white, or white slashed in red for transparent. +fn background(ui: &mut egui::Ui, d: &mut Dialog, width: f32) { + let t = Tokens::get(ui.ctx()); + let cur = d.str("backgroundContents"); + let shown = BACKGROUNDS.iter().find(|(v, _)| *v == cur).map_or(BACKGROUNDS[0].1, |(_, l)| l); + ui.horizontal(|ui| { + if let Some((v, _)) = widgets::dropdown(ui, "newdoc-background", shown, &BACKGROUNDS.map(|b| b.1), width).and_then(|i| BACKGROUNDS.get(i)) { + d.fields.insert("backgroundContents".into(), json!(v)); + } + let (chip, _) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover()); + ui.painter().rect_filled(chip, 2.0, egui::Color32::WHITE); + if cur != "white" { + ui.painter().line_segment([chip.left_bottom(), chip.right_top()], egui::Stroke::new(1.5, t.bleed)); + } + ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + }); +} + +/// A dropdown bound to `d.fields[key]` over (value, label) pairs. +fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(Value, String)], width: f32) { + let cur = d.fields.get(key).cloned().unwrap_or_default(); + let shown = options + .iter() + .find(|(v, _)| *v == cur || v.as_f64().is_some_and(|x| cur.as_f64() == Some(x))) + .map_or_else(|| cur.to_string(), |(_, l)| l.clone()); + let labels: Vec<&str> = options.iter().map(|(_, l)| l.as_str()).collect(); + if let Some((v, _)) = widgets::dropdown(ui, ("newdoc", key), &shown, &labels, width).and_then(|i| options.get(i)) { + d.fields.insert(key.into(), v.clone()); + } +} + +/// The collapsible Advanced Options: Color Mode, Raster Effects and Preview Mode. +fn advanced(ui: &mut egui::Ui, d: &mut Dialog, label: f32, field: f32) { + let t = Tokens::get(ui.ctx()); + let open = d.bool("advanced"); + let resp = ui + .horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 18.0), egui::Sense::hover()); + crate::icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, r, t.icon); + ui.label(egui::RichText::new("Advanced Options").color(t.text)); + }) + .response + .interact(egui::Sense::click()); + if resp.clicked() { + d.fields.insert("advanced".into(), json!(!open)); + } + if !open { + return; + } + let colors: Vec<(Value, String)> = COLOR_MODES.iter().map(|(v, l)| (json!(v), l.to_string())).collect(); + let ppi: Vec<(Value, String)> = RASTER_PPI.iter().map(|(v, l)| (json!(v), l.to_string())).collect(); + let previews: Vec<(Value, String)> = + PreviewMode::ALL.iter().map(|m| (json!(m.id()), form::humanize(m.id()).trim_end_matches(':').to_string())).collect(); + for (key, name, options) in + [("colorMode", "Color Mode", &colors), ("rasterEffectsPpi", "Raster Effects", &ppi), ("previewMode", "Preview Mode", &previews)] + { + widgets::label_row(ui, name, label, |ui| choice(ui, d, key, options, field)); + } +} + +/// More Settings: the classic form of the same fields. +fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) { + let Some(mut d) = app.ui.dialog.clone() else { return }; + sync(app, &mut d); + let t = Tokens::get(ctx); + let mut b = Buttons::default(); + let mut templates = false; + const L: f32 = 150.0; + window(ctx, MORE, 22, |ui| { + ui.set_width(560.0); + ui.label(egui::RichText::new("More Settings").font(theme::semibold(16.0)).color(t.text)); + ui.add_space(12.0); + widgets::label_row(ui, "Name:", L, |ui| { + form::text(ui, &mut d, "name", 300.0); + }); + // Profile: the preset categories (the tabs); Size: the profile's presets. + let profiles: Vec<&str> = newdoc::CATEGORIES.iter().map(|c| c.0).collect(); + let profile = profiles.iter().find(|p| p.eq_ignore_ascii_case(&d.str("category"))).copied().unwrap_or("[Custom]"); + widgets::label_row(ui, "Profile:", L, |ui| { + if let Some(p) = widgets::dropdown(ui, "newdoc-profile", profile, &profiles, 220.0).and_then(|i| profiles.get(i)) { + d.fields.insert("category".into(), json!(p)); + if let Some(first) = newdoc::category(&app.session, p).and_then(|v| v.into_iter().next()) { + apply(&mut d, &first); + } + } + }); + ui.add_space(6.0); + let (lay, cols, spacing, rtl) = layout(&d); + let n = d.f64("artboards", 1.0); + let several = n > 1.0; + widgets::label_row(ui, "Number of Artboards:", L, |ui| { + artboards(ui, &mut d, 76.0); + ui.add_space(10.0); + for (l, icon) in [ + (ArtboardLayout::GridByRow, "dc-grid-view"), + (ArtboardLayout::GridByColumn, "grid-3x3"), + (ArtboardLayout::Row, "arrow-left-right"), + (ArtboardLayout::Column, "arrow-up-down"), + ] { + if widgets::icon_button_enabled(ui, icon, l.label(), lay == l, several, 26.0).clicked() { + set_layout(&mut d, "layout", json!(l.id())); + } + } + ui.add_space(6.0); + if widgets::icon_button_enabled(ui, "chevrons-left", "Change to Right-to-Left Layout", rtl, several, 26.0).clicked() { + set_layout(&mut d, "rightToLeft", json!(!rtl)); + } + }); + ui.add_enabled_ui(several, |ui| { + widgets::label_row(ui, "Spacing:", L, |ui| { + if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(&d), 100.0) { + set_layout(&mut d, "spacing", json!(v.max(0.0))); + } + ui.add_space(20.0); + ui.label(egui::RichText::new("Columns:").color(t.text)); + let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn); + ui.add_enabled_ui(grid, |ui| { + if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) { + set_layout(&mut d, "columns", json!(v.round().clamp(1.0, n) as u64)); + } + }); + }); + }); + ui.add_space(6.0); + let sizes = newdoc::category(&app.session, profile).unwrap_or_default(); + let size_names: Vec<&str> = sizes.iter().map(|s| s.name.as_str()).collect(); + let preset = d.str("preset"); + widgets::label_row(ui, "Size:", L, |ui| { + let shown = if preset.is_empty() { "Custom" } else { preset.as_str() }; + if let Some(s) = widgets::dropdown(ui, "newdoc-size", shown, &size_names, 220.0).and_then(|i| sizes.get(i)) { + apply(&mut d, s); + } + }); + let unit = unit(&d); + widgets::label_row(ui, "Width:", L, |ui| { + form::length(ui, &mut d, "width", unit, 120.0); + ui.add_space(20.0); + ui.label(egui::RichText::new("Units:").color(t.text)); + units_dropdown(ui, &mut d, 120.0); + }); + widgets::label_row(ui, "Height:", L, |ui| { + form::length(ui, &mut d, "height", unit, 120.0); + ui.add_space(20.0); + ui.label(egui::RichText::new("Orientation:").color(t.text)); + orientation(ui, &mut d); + }); + ui.add_space(4.0); + widgets::label_row(ui, "Bleed:", L, |ui| form::bleed(ui, &mut d, unit, 64.0)); + ui.add_space(4.0); + widgets::label_row(ui, "Background Contents:", L, |ui| background(ui, &mut d, 220.0)); + ui.add_space(8.0); + advanced(ui, &mut d, L, 220.0); + ui.add_space(16.0); + ui.horizontal(|ui| { + templates = widgets::secondary_button(ui, "Templates…").on_hover_text("New from Template").clicked(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + b.create = widgets::primary_button(ui, "Create Document").clicked(); + ui.add_space(8.0); + // Cancel goes back to New Document. + if widgets::secondary_button(ui, "Cancel").clicked() { + d.kind = KIND.into(); + } + }); + }); + }); + if templates { + app.ui.dialog = None; + if let Err(e) = app.run("file.newFromTemplate", json!({})) { + app.status(e); + } + return; + } + b.finish(app, ctx, d); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + + use crate::VectorcraftApp; + + fn frame(app: &mut VectorcraftApp) -> egui::FullOutput { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + out + } + + fn set(app: &mut VectorcraftApp, k: &str, v: serde_json::Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + + fn field(app: &VectorcraftApp, k: &str) -> serde_json::Value { + app.ui.dialog.as_ref().unwrap().fields.get(k).cloned().unwrap_or_default() + } + + #[test] + fn a_preset_fills_the_details_and_create_makes_the_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.newDialog", json!({})).unwrap(); + frame(&mut app); + assert_eq!((field(&app, "category"), field(&app, "preset"), field(&app, "name")), (json!("Print"), json!("Letter"), json!("Untitled-1"))); + // Choosing a preset (as `ui.dialog.set` does) fills the fields. + set(&mut app, "category", json!("Film & Video")); + set(&mut app, "preset", json!("4K UHD")); + frame(&mut app); + assert_eq!((field(&app, "width"), field(&app, "units"), field(&app, "colorMode")), (json!(3840.0), json!("Pixels"), json!("rgb"))); + set(&mut app, "artboards", json!(4)); + set(&mut app, "artboardLayout", json!({"layout": "gridByRow", "columns": 2, "spacing": 10})); + set(&mut app, "bleed", json!([0, 0, 0, 0])); + set(&mut app, "previewMode", json!("pixel")); + set(&mut app, "backgroundContents", json!("white")); + frame(&mut app); + assert_eq!(field(&app, "preset"), json!(""), "editing makes the settings custom"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.active().unwrap(); + let rects: Vec<_> = st.doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0)).collect(); + assert_eq!(rects, [(0.0, 0.0), (3850.0, 0.0), (0.0, 2170.0), (3850.0, 2170.0)]); + assert_eq!(st.doc.setup.background, vectorcraft_doc::Background::White); + assert!(app.ui.view.pixel_preview, "Pixel preview mode"); + // Next time Recent opens on it. + app.run("file.newDialog", json!({})).unwrap(); + assert_eq!((field(&app, "category"), field(&app, "name")), (json!("Recent"), json!("Untitled-2"))); + frame(&mut app); + } + + #[test] + fn every_category_and_more_settings_draw_and_save_preset_saves() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.newDialog", json!({})).unwrap(); + set(&mut app, "advanced", json!(true)); + for c in vectorcraft_engine::cmd::newdoc::category_names() { + set(&mut app, "category", json!(c)); + let out = frame(&mut app); + assert!(!out.shapes.is_empty()); + } + // More Settings shares the fields; Cancel goes back. + app.ui.dialog.as_mut().unwrap().kind = super::MORE.into(); + set(&mut app, "artboards", json!(3)); + frame(&mut app); + assert_eq!(field(&app, "artboards"), json!(3)); + // Save Preset. + set(&mut app, "category", json!("Print")); + set(&mut app, "preset", json!("A5")); + frame(&mut app); + let p = app.run("file.newPresets.save", json!({"name": "Zine", "preset": "A5"})); + assert!(p.is_ok()); + app.ui.dialog.as_mut().unwrap().kind = super::KIND.into(); + set(&mut app, "category", json!("Saved")); + set(&mut app, "preset", json!("Zine")); + frame(&mut app); + assert!((field(&app, "width").as_f64().unwrap() - 148.0 * 72.0 / 25.4).abs() < 1e-6, "A5 wide"); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.recent_new_docs[0].name, "Zine"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9ebdafab..ee9853dc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -254,9 +254,13 @@ impl VectorcraftApp { self.sync_views(); match &r { Err(e) => self.ui.status = e.clone(), - Ok(_) => { + Ok(v) => { if id == "file.new" { self.ui.status.clear(); + // New Document's Pixel preview mode (Overprint Preview is the engine's). + if v["previewMode"] == "pixel" { + self.ui.view.pixel_preview = true; + } } if id == "text.setStyle" && let Some(font) = params.get("font").and_then(Value::as_str) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3e092f9d..54532a4f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1226,3 +1226,24 @@ pub fn tab_bar(ui: &mut Ui, tabs: &[&str], current: usize) -> Option { }); clicked } + +/// A page-orientation toggle drawn in code: a portrait or landscape sheet with a folded corner, +/// accent-filled when `selected`. Returns clicked. +pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click()); + if resp.hovered() && !selected { + ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); + } + let size = if landscape { vec2(16.0, 12.0) } else { vec2(12.0, 16.0) }; + let page = Rect::from_center_size(rect.center(), size); + let color = if selected { t.accent } else { t.icon }; + let fold = 4.0; + let outline = + vec![page.left_top(), page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(0.0, fold), page.right_bottom(), page.left_bottom()]; + let fill = if selected { t.accent } else { Color32::TRANSPARENT }; + ui.painter().add(egui::Shape::convex_polygon(outline.clone(), fill, Stroke::new(1.2, color))); + let corner = [page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(-fold, fold), page.right_top() + vec2(0.0, fold)]; + ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color }))); + resp.on_hover_text(tip).clicked() +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 020145b1..0f6d016a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -212,3 +212,12 @@ are what `document.setup` reports (`units`, `bleed` [top, bottom, left, right] i `exportText`, `backgroundContents`) plus `tab` (`General`, `Transparency` or `Type`) and `bleedLinked`. `ui.dialog.confirm` runs `document.setup` with them as one undo step; a bad value answers with an error and the dialog stays open. + +New Document: File → New… (`file.newDialog`, Cmd+N) opens the `newDocument` dialog. Its fields are `file.new`'s params +(`preset`, `name`, `width` and `height` in pt, `units`, `artboards`, `artboardLayout`, `bleed`, `backgroundContents`, +`colorMode`, `rasterEffectsPpi`, `previewMode`) plus `category` (the tab: Recent, Saved, Mobile, Web, Print, Film & +Video, Art & Illustration, Branding, Social), `advanced` (Advanced Options open), `bleedLinked` and `presetName` +(Save Preset's name). Setting `preset` to a name from `file.newPresets` fills the other fields from that preset; +changing a field afterwards makes the settings custom (`preset` becomes ""). More Settings is the `newDocumentMore` +dialog over the same fields (its Profile is `category`). `ui.dialog.confirm` runs `file.new` with the fields (with +`previewMode: "pixel"` the app turns Pixel Preview on); on an error the dialog stays open. diff --git a/docs/mcp.md b/docs/mcp.md index d7cab7a5..896334e4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -792,3 +792,17 @@ art). Pass any of these keys to change them in one undo step; `document.setUnits ```json {"name":"run_command","arguments":{"command":"document.setup","params":{"gridColors":"Blue","gridSize":"large","language":"German","bleed":9}}} ``` + +## New Document + +`file.new` takes everything New Document sets: `preset` (a name from `file.newPresets`), `name`, `width`/`height` +(points, or lengths such as `"210 mm"`), `units`, `orientation`, `artboards` with `artboardLayout {layout, columns, +spacing, rightToLeft}`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi` and `previewMode`. +`file.newPresets {category?}` lists the categories and presets (Recent: the last sizes used; Saved: the user's presets, +kept in the preferences by `file.newPresets.save` and removed by `file.newPresets.delete`). A listed preset can be +passed straight back to `file.new`. Print presets and sizes without `units` start in `unitsGeneral`; screen presets +(mobile, web, video, social) in Pixels. + +```json +{"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}} +```