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}}} +```