M3.93: Edit > Transparency Flattener Presets… manages built-in and saved flattener presets

Saved presets live in Prefs.flattener_presets (persisted with the preferences) beside the
built-in High, Medium and Low Resolution ones, which stay read-only. New commands:
flattener.presets.list / save / delete / import / export (a .vcflattener JSON file; opening one
imports it). Any saved preset name works as `preset` wherever flattener options are taken
(Session::flatten_options). The manager dialog lists the presets and edits the selected saved
one in place; the Flatten Transparency dialog lists saved presets and gains Save Preset….

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 02:36:28 +07:00
1 parent 936721fbf5
commit 1dc158cafe
15 files changed
+863 -57

No files matched your search

+2 -1
View File
@@ -243,11 +243,12 @@ pub const OPEN_EXTS: &[&str] =
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"];
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel).
/// libraries (which open in the library panel) and flattener presets (imported).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS))
.chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions)))
.chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS)))
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
+1 -1
View File
@@ -148,7 +148,7 @@ fn open_exts_cover_every_readable_format() {
}
let filters: Vec<_> = open_filters().collect();
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(filters.len(), 2 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries");
assert_eq!(filters.len(), 3 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries and flattener presets");
}
#[test]
+258 -13
View File
@@ -11,6 +11,10 @@
//! clipped to them with Clip Complex Regions (else the image is a rectangle that takes in the
//! regions it overlaps). The raster/vector balance rasterizes whole groups that split into more
//! regions than it allows (all of them at 0).
//!
//! Edit → Transparency Flattener Presets: the built-in presets plus the user's, saved with the
//! preferences ([`crate::Prefs::flattener_presets`]); a `preset` named anywhere options are taken
//! finds both.
use std::sync::Arc;
@@ -30,15 +34,62 @@ use super::pathops::{node_path, outline_strokes, outline_strokes_under, shape_no
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"object.flattenTransparency",
"Flatten Transparency…",
["Object"],
None,
"{preset?: \"high\"|\"medium\"|\"low\" (default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?} overlapping transparent objects become one group of flat-colour regions, plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}",
has_doc,
flatten
)]
vec![
cmd!(
"object.flattenTransparency",
"Flatten Transparency…",
["Object"],
None,
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?} overlapping transparent objects become one group of flat-colour regions, plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}",
has_doc,
flatten
),
cmd!(
query "flattener.presets.list",
"Transparency Flattener Presets",
[],
None,
"{} → {presets: [{name, builtIn, options}]} the built-in presets (High, Medium and Low Resolution), then the saved ones; any of these names works as `preset` wherever flattener options are taken",
always,
presets_list
),
cmd!(
query "flattener.presets.save",
"Save Transparency Flattener Preset",
[],
None,
"{name?: (default: a new \"Flattener Preset N\"), newName?: rename it, preset?: the preset to start from (default: the saved preset `name`, else medium), …options (the keys of object.flattenTransparency, at the top level or in `options`)} create or change a saved preset (built-in ones can't change) → {name, options, created}",
always,
presets_save
),
cmd!(
query "flattener.presets.delete",
"Delete Transparency Flattener Preset",
[],
None,
"{name} delete a saved preset (built-in ones stay) → {deleted: name}",
always,
presets_delete
),
cmd!(
query "flattener.presets.import",
"Import Transparency Flattener Presets",
[],
None,
"{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else the imported ones get a number)} add the presets of a .vcflattener file (as flattener.presets.export writes) to the saved ones → {imported: [names]}",
always,
presets_import
),
cmd!(
query "flattener.presets.export",
"Export Transparency Flattener Presets",
[],
None,
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcflattener file (JSON) → {path, count}; without path → {data: the file's text, count}",
always,
presets_export
),
]
}
/// Object → Flatten Transparency settings: a preset's, adjusted by the command's params.
@@ -109,11 +160,30 @@ impl FlattenOptions {
})
}
/// The options `p` asks for: `preset` (default medium) adjusted by the option keys given at the
/// top level or in `options`.
/// The built-in presets, finest first.
pub fn builtin_presets() -> Vec<FlattenerPreset> {
Self::PRESETS
.into_iter()
.filter_map(|id| Some(FlattenerPreset { name: Self::preset_label(id)?.into(), options: Self::preset(id)? }))
.collect()
}
/// A built-in preset (by id or display name) or one of `saved` (by name), any case.
pub fn find_preset(name: &str, saved: &[FlattenerPreset]) -> Option<Self> {
Self::preset(name).or_else(|| saved_preset(saved, name).map(|p| p.options.clone()))
}
/// The options `p` asks for, with the built-in presets only ([`Self::from_params_with`]).
pub fn from_params(p: &Value) -> std::result::Result<Self, String> {
Self::from_params_with(p, &[])
}
/// The options `p` asks for: `preset` (built-in or one of `saved`; default medium) adjusted by
/// the option keys given at the top level or in `options`.
pub fn from_params_with(p: &Value, saved: &[FlattenerPreset]) -> std::result::Result<Self, String> {
let base = match str_param(p, "preset") {
Some(name) => Self::preset(name).ok_or_else(|| format!("unknown preset `{name}` (high, medium or low)"))?,
Some(name) => Self::find_preset(name, saved)
.ok_or_else(|| format!("unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list)"))?,
None => Self::default(),
};
let mut v = serde_json::to_value(&base).map_err(|e| e.to_string())?;
@@ -142,7 +212,7 @@ impl FlattenOptions {
fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.flattenTransparency";
let o = FlattenOptions::from_params(p).map_err(|m| bad(C, m))?;
let o = s.flatten_options(p).map_err(|m| bad(C, m))?;
let roots = target_roots(s, p)?;
if roots.is_empty() {
return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into()));
@@ -157,6 +227,181 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options }))
}
// ---------- presets ----------
/// A named set of flattener options: a built-in preset or a saved one
/// ([`crate::Prefs::flattener_presets`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FlattenerPreset {
pub name: String,
#[serde(default)]
pub options: FlattenOptions,
}
/// What `flattener.presets.export` writes and `flattener.presets.import` reads.
#[derive(Serialize, Deserialize)]
struct PresetFile {
format: String,
#[serde(default)]
presets: Vec<FlattenerPreset>,
}
/// The `format` of a presets file, also its extension.
pub const PRESET_FORMAT: &str = "vcflattener";
/// The extensions of presets files (opening one imports it).
pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT];
fn saved_preset<'a>(saved: &'a [FlattenerPreset], name: &str) -> Option<&'a FlattenerPreset> {
saved.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim()))
}
impl Session {
/// Every flattener preset: the built-in ones, then the saved ones.
pub fn flattener_presets(&self) -> Vec<FlattenerPreset> {
let mut v = FlattenOptions::builtin_presets();
v.extend(self.prefs.flattener_presets.iter().cloned());
v
}
/// The flattener options `p` asks for: a `preset` by name, built-in or saved, adjusted by the
/// option keys ([`FlattenOptions::from_params_with`]).
pub fn flatten_options(&self, p: &Value) -> std::result::Result<FlattenOptions, String> {
FlattenOptions::from_params_with(p, &self.prefs.flattener_presets)
}
/// The first free "Flattener Preset N": the name a new preset gets.
pub fn new_preset_name(&self) -> String {
(1..).map(|i| format!("Flattener Preset {i}")).find(|n| !self.preset_taken(n, None)).unwrap_or_default()
}
/// The index of the saved preset `name`.
fn saved_index(&self, name: &str) -> Option<usize> {
self.prefs.flattener_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim()))
}
/// Whether `name` is taken by a built-in preset or a saved one other than the one at `except`.
fn preset_taken(&self, name: &str, except: Option<usize>) -> bool {
FlattenOptions::preset(name).is_some() || self.saved_index(name).is_some_and(|i| Some(i) != except)
}
}
fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
let rows = FlattenOptions::builtin_presets()
.into_iter()
.map(|p| (p, true))
.chain(s.prefs.flattener_presets.iter().cloned().map(|p| (p, false)))
.map(|(p, builtin)| json!({"name": p.name, "builtIn": builtin, "options": p.options}));
Ok(json!({ "presets": rows.collect::<Vec<_>>() }))
}
fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "flattener.presets.save";
let name = match str_param(p, "name").map(str::trim) {
Some("") => return Err(bad(C, "`name` is empty")),
Some(n) => n.to_string(),
None => s.new_preset_name(),
};
if FlattenOptions::preset(&name).is_some() {
return Err(bad(C, format!("`{name}` is a built-in preset and can't change: save it under another name")));
}
let at = s.saved_index(&name);
// A saved preset changes from its own options unless `preset` says where to start.
let mut q = p.clone();
if let (Some(i), Some(o), None) = (at, q.as_object_mut(), str_param(p, "preset")) {
o.insert("preset".into(), json!(s.prefs.flattener_presets[i].name));
}
let options = s.flatten_options(&q).map_err(|m| bad(C, m))?;
let name = match str_param(p, "newName").map(str::trim) {
Some("") => return Err(bad(C, "`newName` is empty")),
Some(n) if s.preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))),
Some(n) => n.to_string(),
None => at.map_or(name, |i| s.prefs.flattener_presets[i].name.clone()),
};
let preset = FlattenerPreset { name: name.clone(), options: options.clone() };
match at {
Some(i) => s.prefs.flattener_presets[i] = preset,
None => s.prefs.flattener_presets.push(preset),
}
Ok(json!({"name": name, "options": options, "created": at.is_none()}))
}
fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "flattener.presets.delete";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
if FlattenOptions::preset(name).is_some() {
return Err(bad(C, format!("`{name}` is a built-in preset and stays")));
}
let i = s.saved_index(name).ok_or_else(|| bad(C, format!("no saved preset named `{name}`")))?;
Ok(json!({ "deleted": s.prefs.flattener_presets.remove(i).name }))
}
fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "flattener.presets.export";
let presets = match p.get("names").and_then(Value::as_array) {
Some(names) => {
let all = s.flattener_presets();
let find = |n: &Value| {
let n = n.as_str().unwrap_or_default().trim();
// Built-in ones also by id (`high`).
let key = FlattenOptions::preset_label(&n.to_ascii_lowercase()).unwrap_or(n);
all.iter().find(|q| q.name.eq_ignore_ascii_case(key)).cloned().ok_or_else(|| bad(C, format!("no preset named `{n}`")))
};
names.iter().map(find).collect::<Result<Vec<_>>>()?
}
None => s.prefs.flattener_presets.clone(),
};
if presets.is_empty() {
return Err(bad(C, "no saved presets to export (name built-in ones in `names`)"));
}
let count = presets.len();
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?;
match str_param(p, "path") {
Some(path) => {
super::fileio::write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "count": count}))
}
None => Ok(json!({"data": text, "count": count})),
}
}
fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "flattener.presets.import";
let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
(Some(path), ..) => super::fileio::read_file(path)?,
(None, Some(text), _) => text.as_bytes().to_vec(),
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?,
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
};
let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a flattener presets file: {e}")))?;
if file.format != PRESET_FORMAT {
return Err(bad(C, format!("not a flattener presets file (format `{}`)", file.format)));
}
// Values a hand-edited file may carry are rejected as the commands reject them.
for q in &file.presets {
FlattenOptions::from_params(&json!({ "options": q.options })).map_err(|m| bad(C, format!("`{}`: {m}", q.name)))?;
}
let replace = bool_or(p, "replace", false);
let mut imported = vec![];
for mut preset in file.presets {
let base = Some(preset.name.trim()).filter(|n| !n.is_empty()).unwrap_or("Flattener Preset").to_string();
match s.saved_index(&base) {
Some(i) if replace => {
preset.name = s.prefs.flattener_presets[i].name.clone();
imported.push(preset.name.clone());
s.prefs.flattener_presets[i] = preset;
}
_ => {
preset.name = unique_name(&base, |n| s.preset_taken(n, None));
imported.push(preset.name.clone());
s.prefs.flattener_presets.push(preset);
}
}
}
Ok(json!({ "imported": imported }))
}
/// The top-level objects among `ids` (or the selection), in paint order: layers stand for their
/// contents; hidden and locked objects and clipping paths are left alone.
fn target_roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
+4 -1
View File
@@ -14,7 +14,7 @@ mod draw2;
mod edit;
mod effectcmd;
pub mod fileio;
pub(crate) mod flatten;
pub mod flatten;
mod fonts;
pub(crate) mod freeform;
pub(crate) mod gradient;
@@ -363,3 +363,6 @@ pub use overprint::AttributesInfo;
/// Object → Flatten Transparency settings (`object.flattenTransparency`).
pub use flatten::FlattenOptions;
/// A built-in or saved flattener preset ([`crate::Prefs::flattener_presets`]).
pub use flatten::FlattenerPreset;
+8
View File
@@ -317,6 +317,11 @@ pub struct Prefs {
/// Eyedropper Options (`eyedropper.setOptions`; a preference group, see
/// [`cmd::prefscmds::PREF_GROUPS`]).
pub eyedropper: EyedropperOptions,
/// Edit → Transparency Flattener Presets: the user's presets (the built-in ones aren't stored).
/// Not a `prefs.set` key or group (resetting the preferences keeps them): `flattener.presets.*`
/// edit it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub flattener_presets: Vec<cmd::FlattenerPreset>,
}
impl Default for Prefs {
@@ -435,6 +440,7 @@ impl Default for Prefs {
Color::rgb8(r, g, b).to_hex()
},
eyedropper: Default::default(),
flattener_presets: vec![],
}
}
}
@@ -793,6 +799,8 @@ mod tests_eyedropper;
#[cfg(test)]
mod tests_file;
#[cfg(test)]
mod tests_flatpresets;
#[cfg(test)]
mod tests_flatten;
#[cfg(test)]
mod tests_freeform;
+136
View File
@@ -0,0 +1,136 @@
//! Transparency Flattener Presets (M3.93): built-in presets stay as they are, saved presets are
//! made, changed, renamed and deleted, travel through export/import and persist with the
//! preferences.
use serde_json::{Value, json};
use super::*;
use crate::cmd::{FlattenOptions, FlattenerPreset};
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap()
}
fn names(s: &mut Session) -> Vec<(String, bool)> {
let list = run(s, "flattener.presets.list", json!({}));
list["presets"].as_array().unwrap().iter().map(|p| (p["name"].as_str().unwrap().to_string(), p["builtIn"].as_bool().unwrap())).collect()
}
#[test]
fn built_in_presets_are_listed_and_protected() {
let mut s = Session::new();
let builtin = [("High Resolution", true), ("Medium Resolution", true), ("Low Resolution", true)].map(|(n, b)| (n.to_string(), b));
assert_eq!(names(&mut s), builtin);
for name in ["High Resolution", "medium", "LOW"] {
assert!(s.execute("flattener.presets.save", &json!({"name": name, "balance": 10})).is_err(), "{name} changed");
assert!(s.execute("flattener.presets.delete", &json!({ "name": name })).is_err(), "{name} deleted");
}
assert!(s.execute("flattener.presets.delete", &json!({"name": "Nope"})).is_err());
assert_eq!(FlattenOptions::preset("high"), FlattenOptions::find_preset("High Resolution", &[]));
assert_eq!(names(&mut s), builtin, "nothing changed");
// A saved preset can't take a built-in name either.
run(&mut s, "flattener.presets.save", json!({"name": "Mine"}));
assert!(s.execute("flattener.presets.save", &json!({"name": "Mine", "newName": "low resolution"})).is_err());
}
#[test]
fn saved_presets_are_made_changed_renamed_and_used() {
let mut s = Session::new();
let r = run(&mut s, "flattener.presets.save", json!({"name": "Press", "preset": "high", "balance": 40}));
assert_eq!((r["name"].as_str(), r["created"].as_bool()), (Some("Press"), Some(true)));
assert_eq!((r["options"]["balance"].as_f64(), r["options"]["lineArtPpi"].as_f64()), (Some(40.0), Some(1200.0)));
// A change starts from the preset's own options.
let r = run(&mut s, "flattener.presets.save", json!({"name": "press", "options": {"antiAlias": false}}));
assert_eq!(
(r["created"].as_bool(), r["options"]["balance"].as_f64(), r["options"]["antiAlias"].as_bool()),
(Some(false), Some(40.0), Some(false))
);
assert_eq!(r["name"], "Press", "the saved name keeps its case");
// Renaming, and names that are taken.
run(&mut s, "flattener.presets.save", json!({"name": "Press", "newName": "Print"}));
run(&mut s, "flattener.presets.save", json!({"name": "Other"}));
assert!(s.execute("flattener.presets.save", &json!({"name": "Print", "newName": "other"})).is_err());
assert!(s.execute("flattener.presets.save", &json!({"name": " "})).is_err());
assert!(s.execute("flattener.presets.save", &json!({"name": "Print", "balance": 101})).is_err(), "options are validated");
// Unnamed presets are numbered.
assert_eq!(run(&mut s, "flattener.presets.save", json!({}))["name"], "Flattener Preset 1");
assert_eq!(run(&mut s, "flattener.presets.save", json!({}))["name"], "Flattener Preset 2");
let saved: Vec<String> = names(&mut s).into_iter().filter(|n| !n.1).map(|n| n.0).collect();
assert_eq!(saved, ["Print", "Other", "Flattener Preset 1", "Flattener Preset 2"]);
// Anywhere options are taken, a saved preset works by name.
let o = s.flatten_options(&json!({"preset": "PRINT"})).unwrap();
assert_eq!((o.balance, o.line_art_ppi, o.anti_alias), (40.0, 1200.0, false));
assert!(s.flatten_options(&json!({"preset": "Gone"})).is_err());
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))["id"].clone();
run(&mut s, "transparency.set", json!({"ids": [id], "opacity": 50}));
let r = run(&mut s, "object.flattenTransparency", json!({"ids": [id], "preset": "Print"}));
assert_eq!(r["options"]["balance"], 40.0);
assert_eq!(run(&mut s, "flattener.presets.delete", json!({"name": "print"}))["deleted"], "Print");
assert!(s.execute("object.flattenTransparency", &json!({"preset": "Print"})).is_err());
}
#[test]
fn presets_round_trip_through_export_and_import() {
let mut s = Session::new();
assert!(s.execute("flattener.presets.export", &json!({})).is_err(), "nothing saved to export");
run(&mut s, "flattener.presets.save", json!({"name": "A", "preset": "low", "clipComplexRegions": true}));
run(&mut s, "flattener.presets.save", json!({"name": "B", "balance": 0}));
let saved = s.prefs.flattener_presets.clone();
let out = run(&mut s, "flattener.presets.export", json!({}));
assert_eq!(out["count"], 2);
let data = out["data"].as_str().unwrap().to_string();
for n in ["A", "B"] {
run(&mut s, "flattener.presets.delete", json!({ "name": n }));
}
assert_eq!(run(&mut s, "flattener.presets.import", json!({ "data": data }))["imported"], json!(["A", "B"]));
assert_eq!(s.prefs.flattener_presets, saved, "the same presets come back");
// Names in use get a number, unless the import replaces them.
assert_eq!(run(&mut s, "flattener.presets.import", json!({ "data": data }))["imported"], json!(["A 2", "B 2"]));
run(&mut s, "flattener.presets.save", json!({"name": "A", "balance": 55}));
run(&mut s, "flattener.presets.import", json!({"data": data, "replace": true}));
assert_eq!(s.prefs.flattener_presets[0], saved[0]);
assert_eq!(s.prefs.flattener_presets.len(), 4);
// Built-in presets export by id or name, and import as saved copies.
let b64 = {
let out = run(&mut s, "flattener.presets.export", json!({"names": ["high", "Medium Resolution"]}));
assert_eq!(out["count"], 2);
vectorcraft_format::base64_encode(out["data"].as_str().unwrap().as_bytes())
};
let r = run(&mut s, "flattener.presets.import", json!({ "dataBase64": b64 }));
assert_eq!(r["imported"], json!(["High Resolution 2", "Medium Resolution 2"]));
assert_eq!(s.flatten_options(&json!({"preset": "High Resolution 2"})).unwrap(), FlattenOptions::preset("high").unwrap());
// Files on disk, and what isn't a presets file.
let path = std::env::temp_dir().join(format!("vc-flattener-{}.{}", std::process::id(), crate::cmd::flatten::PRESET_FORMAT));
let path = path.to_string_lossy().to_string();
assert_eq!(run(&mut s, "flattener.presets.export", json!({"names": ["A"], "path": path}))["count"], 1);
assert_eq!(run(&mut s, "flattener.presets.import", json!({ "path": path }))["imported"], json!(["A 3"]));
let _ = std::fs::remove_file(&path);
assert!(s.execute("flattener.presets.export", &json!({"names": ["Nope"]})).is_err());
for bad in [json!({"data": "{}"}), json!({"data": "not json"}), json!({"data": "{\"format\": \"other\", \"presets\": []}"}), json!({})] {
assert!(s.execute("flattener.presets.import", &bad).is_err(), "{bad}");
}
let wild = json!({"format": "vcflattener", "presets": [{"name": "Wild", "options": {"lineArtPpi": 99999}}]}).to_string();
assert!(s.execute("flattener.presets.import", &json!({ "data": wild })).is_err(), "out-of-range values are rejected");
}
#[test]
fn saved_presets_persist_with_the_preferences() {
let mut s = Session::new();
// Preferences saved before presets existed load without any.
let old = json!({"keyboardIncrement": 2.0});
assert!(serde_json::from_value::<Prefs>(old).unwrap().flattener_presets.is_empty());
assert!(Prefs::default().to_json().get("flattenerPresets").is_none(), "nothing saved while there are none");
run(&mut s, "flattener.presets.save", json!({"name": "Keep", "gradientPpi": 72}));
let saved = serde_json::to_string(&s.prefs).unwrap();
let back: Prefs = serde_json::from_str(&saved).unwrap();
assert_eq!(back.flattener_presets, [FlattenerPreset { name: "Keep".into(), options: s.flatten_options(&json!({"preset": "Keep"})).unwrap() }]);
let mut t = Session::new();
t.apply_prefs(back);
assert_eq!(t.flatten_options(&json!({"preset": "keep"})).unwrap().gradient_ppi, 72.0);
// They aren't a preference key, and resetting the preferences keeps them.
assert!(t.execute("prefs.set", &json!({"key": "flattenerPresets", "value": []})).is_err());
run(&mut t, "prefs.reset", json!({}));
assert_eq!(t.prefs.flattener_presets.len(), 1);
assert_eq!(run(&mut t, "prefs.get", json!({}))["prefs"]["flattenerPresets"][0]["name"], "Keep");
}
+103 -34
View File
@@ -1,14 +1,15 @@
//! Object → Flatten Transparency: a preset, the raster/vector balance, the resolutions and the
//! outline and preserve options, previewed live on the canvas (off at first: flattening complex
//! art takes a while); OK keeps the result as one undo step (`object.flattenTransparency`).
//! art takes a while); OK keeps the result as one undo step (`object.flattenTransparency`). Save
//! Preset… keeps the options as a saved preset (`flattener.presets.save`).
//!
//! Fields: `preset` (a preset's name: setting it loads its options), the option keys of
//! [`FlattenOptions`] (`balance`, `lineArtPpi`, `gradientPpi`, `textToOutlines`,
//! Fields: `preset` (a built-in or saved preset's name: setting it loads its options), the option
//! keys of [`FlattenOptions`] (`balance`, `lineArtPpi`, `gradientPpi`, `textToOutlines`,
//! `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`)
//! and `preview`.
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::FlattenOptions;
use vectorcraft_engine::cmd::{FlattenOptions, FlattenerPreset};
use super::{DialogSpec, form};
use crate::state::Dialog;
@@ -22,6 +23,9 @@ const CMD: &str = "object.flattenTransparency";
/// The preset whose options the fields hold (a `preset` set from outside loads its options).
const APPLIED: &str = "__applied";
/// Save Preset… is open, with the name typed in `__presetName`.
const SAVING: &str = "__saving";
const PRESET_NAME: &str = "__presetName";
/// What the preset dropdown shows once the options differ from the preset's.
const CUSTOM: &str = "[Custom]";
@@ -37,11 +41,6 @@ pub fn open(app: &mut VectorcraftApp) {
app.ui.dialog = Some(d);
}
/// The presets the dropdown offers: (name, options).
fn presets() -> Vec<(String, FlattenOptions)> {
FlattenOptions::PRESETS.iter().filter_map(|id| Some((FlattenOptions::preset_label(id)?.to_string(), FlattenOptions::preset(id)?))).collect()
}
/// Write `o` into the option fields.
pub(crate) fn put_options(fields: &mut Map<String, Value>, o: &FlattenOptions) {
if let Value::Object(m) = serde_json::to_value(o).unwrap_or_default() {
@@ -49,43 +48,63 @@ pub(crate) fn put_options(fields: &mut Map<String, Value>, o: &FlattenOptions) {
}
}
/// The options the fields hold (a `preset` not loaded yet: its options, adjusted by none).
fn options(d: &Dialog) -> Result<FlattenOptions, String> {
/// The options the fields hold (a `preset` not loaded yet: its own).
fn options(d: &Dialog, saved: &[FlattenerPreset]) -> Result<FlattenOptions, String> {
if d.str("preset") != d.str(APPLIED) {
return FlattenOptions::from_params(&json!({ "preset": d.str("preset") }));
return FlattenOptions::from_params_with(&json!({ "preset": d.str("preset") }), saved);
}
FlattenOptions::from_params(&Value::Object(d.fields.clone()))
FlattenOptions::from_params_with(&Value::Object(d.fields.clone()), saved)
}
/// `object.flattenTransparency` parameters: every option.
fn params(d: &Dialog, saved: &[FlattenerPreset]) -> Result<Value, String> {
let mut fields = Map::new();
put_options(&mut fields, &options(d, saved)?);
Ok(Value::Object(fields))
}
/// Load the options of a preset set from outside (`ui.dialog.set`).
fn sync(d: &mut Dialog) {
fn sync(d: &mut Dialog, saved: &[FlattenerPreset]) {
let name = d.str("preset");
if name != d.str(APPLIED) {
if let Ok(o) = options(d) {
if let Ok(o) = options(d, saved) {
put_options(&mut d.fields, &o);
}
d.fields.insert(APPLIED.into(), json!(name));
}
}
/// Make `name` the preset the fields hold.
fn applied(d: &mut Dialog, name: &str) {
d.fields.insert("preset".into(), json!(name));
d.fields.insert(APPLIED.into(), json!(name));
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
sync(d);
let presets = presets();
let current = options(d);
let saved = &app.session.prefs.flattener_presets;
sync(d, saved);
let current = options(d, saved);
let presets = app.session.flattener_presets();
let shown = match &current {
Ok(o) => presets.iter().find(|(n, p)| *n == d.str("preset") && p == o).map_or(CUSTOM, |(n, _)| n.as_str()),
Ok(o) => presets.iter().find(|p| p.name == d.str("preset") && p.options == *o).map_or(CUSTOM, |p| p.name.as_str()),
Err(_) => CUSTOM,
};
ui.horizontal(|ui| {
widgets::dim_label(ui, "Preset:");
let names: Vec<&str> = presets.iter().map(|(n, _)| n.as_str()).collect();
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
if let Some(i) = widgets::dropdown(ui, "flatten-preset", shown, &names, 220.0) {
let (name, o) = &presets[i];
put_options(&mut d.fields, o);
d.fields.insert("preset".into(), json!(name));
d.fields.insert(APPLIED.into(), json!(name));
put_options(&mut d.fields, &presets[i].options);
applied(d, &presets[i].name);
}
if widgets::flat_button(ui, "Save Preset…", 100.0).clicked() {
let open = !d.bool(SAVING);
d.fields.insert(SAVING.into(), json!(open));
d.fields.insert(PRESET_NAME.into(), json!(app.session.new_preset_name()));
}
});
if d.bool(SAVING) {
save_row(app, ui, d);
}
ui.add_space(10.0);
let mut o = current.clone().unwrap_or_default();
if options_editor(ui, "flatten-dialog", &mut o, true) {
@@ -94,23 +113,48 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if let Err(e) = &current {
ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim).size(11.5));
}
let p = params(d);
// Invalid values keep the last preview.
let p = params(d, &app.session.prefs.flattener_presets).unwrap_or_else(|_| d.fields.get(form::PREVIEWED).cloned().unwrap_or_default());
form::preview(app, ui, d, "Flatten Transparency", CMD, p);
false
}
/// `object.flattenTransparency` parameters: every option.
fn params(d: &Dialog) -> Value {
let mut fields = Map::new();
put_options(&mut fields, &options(d).unwrap_or_default());
Value::Object(fields)
/// Save Preset…: the new preset's name, Save and Cancel (Enter in the name saves).
fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Name:");
let r = form::text_edit(ui, d, PRESET_NAME, 180.0);
// Enter saves the preset instead of flattening.
let enter = r.lost_focus() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
if (widgets::flat_button(ui, "Save", 60.0).clicked() || enter)
&& let Err(e) = save_preset(app, d)
{
app.status(e);
}
if widgets::flat_button(ui, "Cancel", 60.0).clicked() {
d.fields.insert(SAVING.into(), json!(false));
}
});
let name = d.str(PRESET_NAME);
if app.session.prefs.flattener_presets.iter().any(|p| p.name.eq_ignore_ascii_case(name.trim())) {
widgets::dim_label(ui, "Replaces the saved preset of that name.");
}
}
/// Keep the dialog's options as the saved preset `__presetName` and select it.
fn save_preset(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
let mut p = params(d, &app.session.prefs.flattener_presets)?;
p["name"] = json!(d.str(PRESET_NAME));
let r = app.run("flattener.presets.save", p)?;
applied(d, r["name"].as_str().unwrap_or_default());
d.fields.insert(SAVING.into(), json!(false));
Ok(())
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let o = options(d)?;
let mut fields = Map::new();
put_options(&mut fields, &o);
form::commit_preview(app, CMD, Value::Object(fields))
let p = params(d, &app.session.prefs.flattener_presets)?;
form::commit_preview(app, CMD, p)
}
/// The width of the option value fields.
@@ -237,6 +281,31 @@ mod tests {
assert!(!transparent_left(&app));
}
#[test]
fn save_preset_keeps_the_options_and_lists_them() {
let mut app = scene();
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
let mut d = app.ui.dialog.clone().unwrap();
d.fields.insert("balance".into(), json!(30));
d.fields.insert(PRESET_NAME.into(), json!("Thirty"));
save_preset(&mut app, &mut d).unwrap();
assert_eq!(d.str("preset"), "Thirty");
let saved = &app.session.prefs.flattener_presets;
assert_eq!((saved.len(), saved[0].name.as_str(), saved[0].options.balance), (1, "Thirty", 30.0));
// A saved preset set from outside loads like a built-in one.
d.fields.insert("preset".into(), json!("Low Resolution"));
app.ui.dialog = Some(d);
frame(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("balance", 0.0), 75.0);
app.ui.dialog.as_mut().unwrap().fields.insert("preset".into(), json!("Thirty"));
frame(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("balance", 0.0), 30.0);
// Built-in presets can't be replaced.
let mut d = app.ui.dialog.clone().unwrap();
d.fields.insert(PRESET_NAME.into(), json!("High Resolution"));
assert!(save_preset(&mut app, &mut d).is_err());
}
#[test]
fn bad_values_report_and_keep_the_dialog_open() {
let mut app = scene();
@@ -0,0 +1,294 @@
//! Edit → Transparency Flattener Presets: every preset in a list (built-in ones in brackets, read
//! only) with the selected one's name and options beside it, edited in place on saved presets
//! (`flattener.presets.save`); New (a copy of the selected preset), Delete, Import… and Export…
//! (the selected preset) under the list. Close keeps everything: each change is saved at once.
//!
//! Fields: `selected` (a preset's name), then the selected preset's `name` and option keys
//! (setting them on a saved preset saves it; a new `name` renames it once the field isn't being
//! edited; on a built-in preset they go back).
use serde_json::{Value, json};
use vectorcraft_engine::cmd::FlattenOptions;
use vectorcraft_engine::cmd::flatten::PRESET_FORMAT;
use super::flatten::{options_editor, put_options};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Transparency Flattener Presets.
pub const KIND: &str = "flattenerPresets";
/// The preset whose name and options the fields hold.
const SHOWN: &str = "__shown";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Transparency Flattener Presets".into(),
body,
confirm: |app, _| {
app.ui.dialog = None;
Ok(Value::Null)
},
ok: None,
min_width: 620.0,
..DialogSpec::FORM
};
/// Open the presets manager on `selected` (default: the first preset).
pub fn open(app: &mut VectorcraftApp, selected: Option<&str>) {
let first = FlattenOptions::preset_label(FlattenOptions::PRESETS[0]).unwrap_or_default();
app.ui.dialog = Some(Dialog::new(KIND, json!({ "selected": selected.unwrap_or(first) })));
}
/// What a button asked for, done once the list and fields are drawn.
enum Action {
Select(String),
New,
Delete,
Import,
Export,
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let presets = app.session.flattener_presets();
let builtins = FlattenOptions::PRESETS.len();
let i = presets.iter().position(|p| p.name.eq_ignore_ascii_case(d.str("selected").trim())).unwrap_or(0);
let (preset, builtin) = (&presets[i], i < builtins);
if d.str(SHOWN) != preset.name {
put_options(&mut d.fields, &preset.options);
for k in ["selected", "name", SHOWN] {
d.fields.insert(k.into(), json!(preset.name));
}
}
let mut act = None;
let mut renaming = false;
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(232.0);
widgets::dim_label(ui, "Presets:");
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("flattener-presets").min_scrolled_height(250.0).max_height(250.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(250.0);
for (k, p) in presets.iter().enumerate() {
let label = if k < builtins { format!("[{}]", p.name) } else { p.name.clone() };
if ui.selectable_label(k == i, label).clicked() {
act = Some(Action::Select(p.name.clone()));
}
}
});
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if widgets::flat_button(ui, "New", 44.0).on_hover_text("A saved copy of the selected preset").clicked() {
act = Some(Action::New);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() {
act = Some(Action::Delete);
}
if widgets::flat_button(ui, "Import…", 62.0).clicked() {
act = Some(Action::Import);
}
if widgets::flat_button(ui, "Export…", 62.0).on_hover_text("Save the selected preset to a file").clicked() {
act = Some(Action::Export);
}
});
});
ui.add_space(18.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
widgets::dim_label(ui, "Name:");
if builtin {
ui.label(egui::RichText::new(&preset.name).color(t.text_strong));
} else {
renaming = form::text_edit(ui, d, "name", 220.0).has_focus();
}
});
ui.add_space(8.0);
let mut o = FlattenOptions::from_params(&Value::Object(d.fields.clone())).unwrap_or_else(|_| preset.options.clone());
if options_editor(ui, "flattener-presets", &mut o, !builtin) {
put_options(&mut d.fields, &o);
}
if builtin {
ui.add_space(6.0);
ui.label(egui::RichText::new("Built-in presets don't change: New makes a copy you can edit.").color(t.text_dim).size(11.5));
}
});
});
// The fields (whether a widget or `ui.dialog.set` changed them) against the stored preset.
let fields = FlattenOptions::from_params(&Value::Object(d.fields.clone()));
if builtin {
if fields.as_ref() != Ok(&preset.options) {
put_options(&mut d.fields, &preset.options);
}
} else {
match fields {
Ok(o) if o != preset.options => {
if let Err(e) = app.run("flattener.presets.save", json!({"name": preset.name, "options": o})) {
app.status(e);
}
}
Ok(_) => {}
Err(e) => app.status(e),
}
let name = d.str("name");
if !renaming && name.trim() != preset.name {
match app.run("flattener.presets.save", json!({"name": preset.name, "newName": name})) {
Ok(r) => select(d, r["name"].as_str().unwrap_or_default()),
Err(e) => {
app.status(e);
d.fields.insert("name".into(), json!(preset.name));
}
}
}
}
if let Some(a) = act
&& let Err(e) = run(app, d, a, &preset.name)
{
app.status(e);
}
false
}
/// Select the preset `name` (its values load on the next frame).
fn select(d: &mut Dialog, name: &str) {
d.fields.insert("selected".into(), json!(name));
d.fields.remove(SHOWN);
}
fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> Result<(), String> {
match act {
Action::Select(name) => select(d, &name),
Action::New => {
let r = app.run("flattener.presets.save", json!({ "preset": current }))?;
select(d, r["name"].as_str().unwrap_or_default());
}
Action::Delete => {
let i = app.session.prefs.flattener_presets.iter().position(|p| p.name == current);
app.run("flattener.presets.delete", json!({ "name": current }))?;
// The one above it takes its place (the last built-in one above the first saved one).
let next =
app.session.flattener_presets().get((i.unwrap_or(0) + FlattenOptions::PRESETS.len()).saturating_sub(1)).map(|p| p.name.clone());
select(d, &next.unwrap_or_default());
}
Action::Import => {
// On the web the picked file arrives later and imports through `io::open_bytes`.
if let Some(f) = app.services.open_async.as_mut() {
f();
return Ok(());
}
let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?;
let r = app.run("flattener.presets.import", json!({ "path": path }))?;
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
select(d, first);
}
}
Action::Export => {
crate::io::save_command_output(app, "flattener.presets.export", PRESET_FORMAT, json!({ "names": [current] }))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
fn set(app: &mut VectorcraftApp, k: &str, v: Value) {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
frame(app);
}
fn field(app: &VectorcraftApp, k: &str) -> Value {
app.ui.dialog.as_ref().unwrap().fields.get(k).cloned().unwrap_or_default()
}
#[test]
fn edits_save_saved_presets_and_leave_built_in_ones() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("ui.flattenerPresetsDialog", json!({})).unwrap();
frame(&mut app);
assert_eq!((field(&app, "selected"), field(&app, "lineArtPpi")), (json!("High Resolution"), json!(1200.0)));
// Built-in presets go back to their values.
set(&mut app, "balance", json!(10));
assert_eq!(field(&app, "balance"), json!(100.0));
frame(&mut app);
assert!(app.session.prefs.flattener_presets.is_empty());
// New makes an editable copy of the selected preset and selects it.
let mut d = app.ui.dialog.clone().unwrap();
run(&mut app, &mut d, Action::New, "High Resolution").unwrap();
app.ui.dialog = Some(d);
frame(&mut app);
assert_eq!(field(&app, "selected"), json!("Flattener Preset 1"));
assert_eq!(app.session.prefs.flattener_presets[0].options, FlattenOptions::preset("high").unwrap());
// Its options and name save as they change.
set(&mut app, "balance", json!(10));
assert_eq!(app.session.prefs.flattener_presets[0].options.balance, 10.0);
set(&mut app, "name", json!("Coarse"));
assert_eq!(app.session.prefs.flattener_presets[0].name, "Coarse");
frame(&mut app);
assert_eq!((field(&app, "selected"), field(&app, "balance")), (json!("Coarse"), json!(10.0)));
// A taken name is refused and the field goes back.
set(&mut app, "name", json!("Low Resolution"));
assert_eq!(field(&app, "name"), json!("Coarse"));
// Delete selects the preset above it; Close keeps what was saved.
let mut d = app.ui.dialog.clone().unwrap();
run(&mut app, &mut d, Action::Delete, "Coarse").unwrap();
assert_eq!(d.str("selected"), "Low Resolution");
assert!(app.session.prefs.flattener_presets.is_empty());
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
}
#[test]
fn saved_presets_persist_with_the_ui_state_and_preferences_still_apply() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("flattener.presets.save", json!({"name": "Kept", "lineArtPpi": 600})).unwrap();
// Preferences OK with saved presets around (they aren't preference fields).
app.run("edit.preferences", json!({})).unwrap();
crate::prefs_dialog::confirm(&mut app).unwrap();
crate::prefs_dialog::snapshot(&mut app);
let saved = serde_json::to_vec(&app.ui).unwrap();
let mut back = VectorcraftApp::new(Session::new(), Default::default());
back.ui = serde_json::from_slice::<crate::UiState>(&saved).unwrap().sanitized();
crate::prefs_dialog::restore(&mut back);
assert_eq!(back.session.prefs.flattener_presets, app.session.prefs.flattener_presets);
assert_eq!(back.session.flatten_options(&json!({"preset": "Kept"})).unwrap().line_art_ppi, 600.0);
}
#[test]
fn import_and_export_go_through_files() {
let dir = std::env::temp_dir().join(format!("vc-flattener-ui-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("mine.vcflattener").to_string_lossy().to_string();
let picked = file.clone();
let services = crate::Services {
pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("flattener.presets.save", json!({"name": "Mine", "balance": 20})).unwrap();
app.run("ui.flattenerPresetsDialog", json!({"selected": "Mine"})).unwrap();
let mut d = app.ui.dialog.clone().unwrap();
run(&mut app, &mut d, Action::Export, "Mine").unwrap();
// Opening the file imports it (also how the web's picked file arrives).
let bytes = std::fs::read(&file).unwrap();
crate::io::open_bytes(&mut app, &file, &bytes, Some(file.clone())).unwrap();
let names: Vec<&str> = app.session.prefs.flattener_presets.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["Mine", "Mine 2"]);
assert_eq!(app.session.prefs.flattener_presets[1].options.balance, 20.0);
let _ = std::fs::remove_dir_all(dir);
}
}
+11 -4
View File
@@ -16,6 +16,11 @@ pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
/// A text field `width` wide bound to `d.fields[key]`. Returns true when it changed.
pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> bool {
text_edit(ui, d, key, width).changed()
}
/// [`text`] returning the field's response (focus, Enter).
pub(super) fn text_edit(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response {
let t = Tokens::get(ui.ctx());
let mut s = d.str(key);
let r = egui::Frame::NONE
@@ -24,11 +29,10 @@ pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) ->
.corner_radius(egui::CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(6, 3))
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(width)));
let changed = r.inner.changed();
if changed {
if r.inner.changed() {
d.fields.insert(key.into(), Value::String(s));
}
changed
r.inner
}
/// A checkbox bound to `d.fields[key]`.
@@ -171,11 +175,14 @@ pub(super) fn slider(
}
}
/// The field where [`preview`] keeps the parameters it last previewed.
pub(super) const PREVIEWED: &str = "__previewed";
/// The Preview checkbox of a dialog that previews `cmd` on the canvas: while it is on, `params` run
/// again on the interaction's snapshot whenever they differ from the last preview (whether a widget
/// or `ui.dialog.set` changed them); turning it off rolls back.
pub(super) fn preview(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, label: &str, cmd: &str, params: Value) {
const LAST: &str = "__previewed";
const LAST: &str = PREVIEWED;
ui.add_space(8.0);
let mut on = d.bool("preview");
if crate::widgets::check(ui, "Preview", on, true) {
+2
View File
@@ -19,6 +19,7 @@ mod effect;
mod export_for_screens;
pub mod eyedropper;
pub mod flatten;
pub mod flattener_presets;
mod form;
mod gradient_stop;
pub mod graphic_style_options;
@@ -158,6 +159,7 @@ registry! {
TileEdgeColor: [tile_edge_color::KIND] => tile_edge_color::SPEC,
EyedropperOptions: [eyedropper::KIND] => eyedropper::SPEC,
FlattenTransparency: [flatten::KIND] => flatten::SPEC,
FlattenerPresets: [flattener_presets::KIND] => flattener_presets::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
+7 -1
View File
@@ -9,8 +9,14 @@ use vectorcraft_geom::Affine;
use crate::VectorcraftApp;
/// Open bytes of any readable format as a new document (templates open untitled); swatch library
/// files open in the library panel.
/// files open in the library panel and flattener presets files are imported.
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> {
if vectorcraft_engine::cmd::flatten::PRESET_EXTS.contains(&fileio::extension(name).as_str()) {
let r = app.run("flattener.presets.import", serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?;
let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
app.status(format!("Imported flattener presets: {}", names.join(", ")));
return Ok(());
}
if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&fileio::extension(name).as_str()) {
let p = match path {
Some(path) => serde_json::json!({ "path": path }),
+11 -1
View File
@@ -265,6 +265,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} open Flatten Transparency for the selection (dialog `flattenTransparency`, fields `preset` (a preset name: setting it loads its options), balance 0..100, lineArtPpi, gradientPpi 1..2400, textToOutlines, strokesToOutlines, clipComplexRegions, antiAlias, preserveAlpha, preserveOverprints, `preview` (off at first)): OK runs object.flattenTransparency with those options as one undo step",
),
(
"ui.flattenerPresetsDialog",
"Transparency Flattener Presets…",
"",
"{selected?: preset name} open the presets manager (dialog `flattenerPresets`, fields `selected`, then the selected preset's `name` and option keys: setting them on a saved preset saves it, a new `name` renames it; built-in presets don't change). New, Delete, Import… and Export… run flattener.presets.save / delete / import / export",
),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@@ -617,6 +623,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::dialogs::flatten::open(app);
Ok(Value::Null)
}
"ui.flattenerPresetsDialog" => {
crate::dialogs::flattener_presets::open(app, s("selected").as_deref());
Ok(Value::Null)
}
_ => return None,
};
Some(r)
@@ -906,7 +916,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
),
todo("Edit Original"),
Sep,
todo("Transparency Flattener Presets…"),
c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"),
todo("Print Presets…"),
todo("PDF Presets…"),
cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})),
+3 -1
View File
@@ -6,7 +6,7 @@
use serde_json::{Map, Value, json};
use vectorcraft_engine::Prefs;
use vectorcraft_engine::cmd::prefscmds::{PREF_CATEGORIES, PREF_SPECS, PrefKind};
use vectorcraft_engine::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, PrefKind};
use crate::state::Dialog;
use crate::theme::{self, Brightness, Tokens};
@@ -20,7 +20,9 @@ const UI_FIELDS: &[(&str, &str, &str)] = &[
];
pub fn open(app: &mut VectorcraftApp, category: Option<&str>) {
// One field per preference key or group (the flattener presets have commands of their own).
let mut fields: Map<String, Value> = app.session.prefs.to_json().as_object().cloned().unwrap_or_default();
fields.retain(|k, _| vectorcraft_engine::cmd::prefscmds::spec(k).is_some() || PREF_GROUPS.contains(&k.as_str()));
let cat = category.and_then(|c| PREF_CATEGORIES.iter().find(|x| x.eq_ignore_ascii_case(c))).copied().unwrap_or(PREF_CATEGORIES[0]);
fields.insert("__category".into(), json!(cat));
fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides));
+8
View File
@@ -114,3 +114,11 @@ Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTranspare
`strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`; `preview`, off at first).
With `preview` on it flattens on the canvas while open; `ui.dialog.confirm` keeps the result as one undo step and
`ui.dialog.cancel` rolls it back. Out-of-range values answer with an error and the dialog stays open.
Flattener presets: `ui.flattenerPresetsDialog {selected?}` opens Edit → Transparency Flattener Presets (dialog
`flattenerPresets`). Fields: `selected` (a preset name), then the selected preset's `name` and option keys. On a saved
preset, `ui.dialog.set` of an option saves it and a new `name` renames it (both through `flattener.presets.save`);
built-in presets don't change. New, Delete, Import… and Export… are `flattener.presets.save {preset}`,
`flattener.presets.delete`, `flattener.presets.import` and `flattener.presets.export`; `ui.dialog.confirm` closes.
Opening a `.vcflattener` file with `app.open` imports its presets. In the Flatten Transparency dialog, Save Preset…
keeps the dialog's options as a saved preset.
+15
View File
@@ -458,3 +458,18 @@ ink with other colours is written in process colours, with a warning).
```json
{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"stops":[{"offset":0,"swatch":"Ink"},{"offset":1,"swatch":"Ink","tint":20}]}}}
```
## Transparency flattener presets
`flattener.presets.list` lists the built-in presets (High, Medium and Low Resolution, `builtIn: true`) and the saved
ones, each with its `options`. `flattener.presets.save {name?, newName?, preset?, …options}` creates or changes a saved
preset (a change starts from the preset's own options; `newName` renames it; built-in presets can't change);
`flattener.presets.delete {name}` deletes one. `flattener.presets.export {names?, path?}` writes them as a
`.vcflattener` JSON file (without `path` it returns `data`), and `flattener.presets.import {path? | data? |
dataBase64?, replace?}` adds a file's presets (names in use get a number unless `replace`). Saved presets live with
the preferences and work as `preset` in `object.flattenTransparency` (and anywhere else flattener options are taken).
```json
{"name":"run_command","arguments":{"command":"flattener.presets.save","params":{"name":"Press","preset":"high","balance":60}}}
{"name":"run_command","arguments":{"command":"object.flattenTransparency","params":{"preset":"Press"}}}
```