vectorcraft/crates/engine/src/cmd/newdoc.rs

623 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<CommandSpec> {
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> {
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> {
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<Self> {
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<Option<f64>> {
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<Rect> {
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<Point> {
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::<f64>();
(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<Item = &'static str> {
["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<Item = DocSettings> + '_ {
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<Vec<DocSettings>> {
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<DocSettings> {
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<Value> {
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(),
};
d.metadata.created = vectorcraft_doc::metadata::now_unix();
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<Value> {
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::<Vec<_>>().join(", "))))?,
],
None => category_names().collect(),
};
let categories: Vec<Value> = names
.into_iter()
.map(|n| json!({ "name": n, "presets": category(s, n).unwrap_or_default().iter().map(DocSettings::to_json).collect::<Vec<_>>() }))
.collect();
Ok(json!({ "categories": categories }))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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 }))
}