Merge wp/A1

This commit is contained in:
bflatastic 2026-10-04 21:46:02 +07:00
commit 1ae0c22c26
23 changed files with 2002 additions and 161 deletions

View File

@ -12,6 +12,7 @@ pub mod live;
pub mod node;
pub mod pattern;
pub mod selection;
pub mod swatches;
pub mod text;
use std::collections::BTreeMap;
@ -594,9 +595,6 @@ impl Document {
pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> {
self.patterns.iter_mut().find(|p| p.name == name)
}
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter())).find(|s| s.name == name)
}
/// Deep-clone `node` with fresh ids for it and all descendants.
pub fn reid(&mut self, node: &Node) -> Node {
let mut n = node.clone();

328
crates/doc/src/swatches.rs Normal file
View File

@ -0,0 +1,328 @@
//! Swatch lookups across colour groups and the walks over the paints that link to swatches.
//!
//! A swatch lives either in `Document::swatches` or in one of `Document::swatch_groups`; every
//! lookup here covers both. Solid paints link to a global swatch by name (`Paint::Solid.swatch`);
//! [`Document::map_solid_paints`] rewrites those links (and colours) wherever art can hold them,
//! copying only the nodes that change.
use std::sync::Arc;
use vectorcraft_color::{Color, Paint, Swatch};
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind};
impl Document {
/// Every swatch: the ungrouped ones first, then each colour group's in order.
pub fn swatches_iter(&self) -> impl Iterator<Item = &Swatch> {
self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter()))
}
pub fn swatches_iter_mut(&mut self) -> impl Iterator<Item = &mut Swatch> {
self.swatches.iter_mut().chain(self.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut()))
}
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
self.swatches_iter().find(|s| s.name == name)
}
pub fn swatch_mut(&mut self, name: &str) -> Option<&mut Swatch> {
self.swatches_iter_mut().find(|s| s.name == name)
}
/// Index of the colour group holding swatch `name` (`None` when ungrouped or missing).
pub fn swatch_group_of(&self, name: &str) -> Option<usize> {
self.swatch_groups.iter().position(|g| g.swatches.iter().any(|s| s.name == name))
}
/// Is `name` used by a swatch or a colour group (they share one namespace)?
pub fn swatch_name_taken(&self, name: &str) -> bool {
self.swatch(name).is_some() || self.swatch_groups.iter().any(|g| g.name == name)
}
/// `base`, or the first of "base 2", "base 3"… no swatch or colour group uses.
pub fn free_swatch_name(&self, base: &str) -> String {
if !self.swatch_name_taken(base) {
return base.to_string();
}
self.numbered_swatch_name(base, 2)
}
/// The first of "base `from`", "base `from + 1`"… no swatch or colour group uses.
fn numbered_swatch_name(&self, base: &str, from: usize) -> String {
(from..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string())
}
/// The name a new swatch of `paint` gets by default, unused in the document: a colour's values
/// ([`color_name`], made free like [`Document::free_swatch_name`]), or "New Gradient Swatch 1",
/// "New Pattern Swatch 1"…
pub fn new_swatch_name(&self, paint: &Paint) -> String {
let base = match paint {
Paint::Solid { color, .. } => return self.free_swatch_name(&color_name(*color)),
Paint::Gradient(_) => "New Gradient Swatch",
_ => "New Pattern Swatch",
};
self.numbered_swatch_name(base, 1)
}
/// Remove swatch `name` from wherever it lives.
pub fn remove_swatch(&mut self, name: &str) -> Option<Swatch> {
if let Some(i) = self.swatches.iter().position(|s| s.name == name) {
return Some(self.swatches.remove(i));
}
let g = self.swatch_group_of(name)?;
let list = &mut self.swatch_groups[g].swatches;
let i = list.iter().position(|s| s.name == name)?;
Some(list.remove(i))
}
/// Visit every solid paint (fills, strokes and text runs) in the art, symbol definitions,
/// pattern tiles and graphic styles. `f(colour, link)` may change both and returns true when it
/// did. Only nodes holding a changed paint (and the paths to them) are copied. Returns the
/// number of changed paints.
pub fn map_solid_paints(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
let mut n = map_trees(&mut self.layers, f);
for s in &mut self.symbols {
n += map_tree(&mut s.art, f);
}
for p in &mut self.patterns {
n += map_trees(&mut p.art, f);
}
for gs in &mut self.graphic_styles {
for it in &mut gs.appearance.items {
n += usize::from(map_solid(item_paint_mut(it), f));
}
}
n
}
/// [`Document::map_solid_paints`] limited to the subtrees of `ids` (each paint visited once even
/// when an id is inside another).
pub fn map_solid_paints_in(&mut self, ids: &[NodeId], f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
let roots: Vec<NodeId> =
ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect();
let mut n = 0;
for id in roots {
let Some(node) = self.node(id) else { continue };
if let Some((new, count)) = map_node(node, f)
&& let Some(slot) = self.node_mut(id)
{
*slot = new;
n += count;
}
}
n
}
}
/// The default name of a solid swatch: its values in its own colour model ("C=10 M=20 Y=30 K=0",
/// "R=255 G=128 B=0", "Gray K=40").
pub fn color_name(c: Color) -> String {
// Clamped first, so a conversion's tiny negative never reads "-0".
let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round();
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round();
match c {
Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)),
Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)),
Color::Gray { k } => format!("Gray K={}", pct(k)),
}
}
/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes and text
/// runs (with their link), then gradient stops and mesh points (unlinked), in paint order.
pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) {
n.walk(&mut |m| {
for p in node_paints(m) {
match p {
Paint::Solid { color, swatch } => f(color, swatch.as_deref()),
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, None)),
_ => {}
}
}
if let NodeKind::Mesh(mesh) = &m.kind {
mesh.points.iter().for_each(|p| f(&p.color, None));
}
});
}
fn item_paint(it: &AppearanceItem) -> &Paint {
match it {
AppearanceItem::Fill(l) => &l.paint,
AppearanceItem::Stroke(l) => &l.paint,
}
}
fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint {
match it {
AppearanceItem::Fill(l) => &mut l.paint,
AppearanceItem::Stroke(l) => &mut l.paint,
}
}
/// The paints a node holds itself (not its children): appearance items, then text runs.
fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
let runs = match &n.kind {
NodeKind::Text(t) => t.runs.as_slice(),
_ => &[],
};
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke]))
}
/// Mutable [`node_paints`], in the same order.
fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
let runs = match &mut n.kind {
NodeKind::Text(t) => t.runs.as_mut_slice(),
_ => &mut [],
};
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke]))
}
/// Apply `f` to a solid paint; true when it changed.
fn map_solid(p: &mut Paint, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> bool {
match p {
Paint::Solid { color, swatch } => f(color, swatch),
_ => false,
}
}
/// `n` with `f` applied to its subtree's solid paints, or `None` when nothing changed (then nothing
/// was copied). Also returns the number of changed paints.
fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> Option<(Node, usize)> {
// Try each own paint on a scratch copy first, so unchanged nodes are never cloned.
let mut changes: Vec<(usize, Color, Option<String>)> = vec![];
for (i, p) in node_paints(n).enumerate() {
if let Paint::Solid { color, swatch } = p {
let (mut c, mut s) = (*color, swatch.clone());
if f(&mut c, &mut s) {
changes.push((i, c, s));
}
}
}
let mut count = changes.len();
let mut out: Option<Node> = None;
if !changes.is_empty() {
let mut m = n.clone();
let mut changes = changes.into_iter().peekable();
for (i, p) in node_paints_mut(&mut m).enumerate() {
if changes.peek().is_some_and(|c| c.0 == i)
&& let (Some((_, c, s)), Paint::Solid { color, swatch }) = (changes.next(), p)
{
*color = c;
*swatch = s;
}
}
out = Some(m);
}
if let Some(ch) = n.children() {
for (i, c) in ch.iter().enumerate() {
if let Some((new, k)) = map_node(c, f) {
let m = out.get_or_insert_with(|| n.clone());
if let Some(slot) = m.children_mut().and_then(|v| v.get_mut(i)) {
*slot = Arc::new(new);
}
count += k;
}
}
}
out.map(|m| (m, count))
}
fn map_tree(n: &mut Arc<Node>, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
match map_node(n, f) {
Some((new, count)) => {
*n = Arc::new(new);
count
}
None => 0,
}
}
fn map_trees(v: &mut [Arc<Node>], f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
v.iter_mut().map(|n| map_tree(n, f)).sum()
}
#[cfg(test)]
mod tests {
use vectorcraft_color::SwatchGroup;
use vectorcraft_geom::{Rect, shapes};
use super::*;
use crate::Appearance;
fn linked(color: Color, name: &str) -> Paint {
Paint::Solid { color, swatch: Some(name.into()) }
}
/// A document with a grouped global swatch "Brand", a rectangle filled with it and an unlinked one.
fn doc() -> (Document, NodeId, NodeId) {
let mut d = Document::new(100.0, 100.0);
let red = Color::rgb(1.0, 0.0, 0.0);
d.swatch_groups.push(SwatchGroup {
name: "Mine".into(),
swatches: vec![Swatch { name: "Brand".into(), paint: Paint::solid(red), global: true, spot: false }],
});
let layer = d.layers[0].id;
let mut ids = vec![];
for paint in [linked(red, "Brand"), Paint::solid(red)] {
let id = d.alloc_id();
let r = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::basic(paint, Paint::None, 0.0))).unwrap();
ids.push(id);
}
(d, ids[0], ids[1])
}
#[test]
fn lookups_cover_colour_groups() {
let (mut d, _, _) = doc();
let total = d.swatches.len() + d.swatch_groups.iter().map(|g| g.swatches.len()).sum::<usize>();
assert_eq!(d.swatches_iter().count(), total);
assert!(d.swatch("Brand").is_some_and(|s| s.global));
assert_eq!(d.swatch_group_of("Brand").map(|g| d.swatch_groups[g].name.as_str()), Some("Mine"));
d.swatch_mut("Brand").unwrap().spot = true;
assert!(d.swatch("Brand").unwrap().spot);
assert!(d.swatch_name_taken("Mine") && d.swatch_name_taken("Brand") && !d.swatch_name_taken("Nope"));
assert_eq!(d.remove_swatch("Brand").map(|s| s.name), Some("Brand".into()));
assert!(d.swatch("Brand").is_none() && d.remove_swatch("Brand").is_none());
}
#[test]
fn default_names_follow_the_colour_model() {
assert_eq!(color_name(Color::cmyk(0.1, 0.2, 0.3, -1e-7)), "C=10 M=20 Y=30 K=0");
assert_eq!(color_name(Color::rgb(1.0, 0.5, -1e-7)), "R=255 G=128 B=0");
assert_eq!(color_name(Color::Gray { k: 0.4 }), "Gray K=40");
let (d, _, _) = doc();
assert_eq!(d.free_swatch_name("Brand"), "Brand 2");
assert_eq!(d.free_swatch_name("Mine"), "Mine 2", "colour groups share the namespace");
assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0");
}
#[test]
fn mapping_copies_only_changed_nodes() {
let (mut d, a, b) = doc();
let before = d.clone();
let blue = Color::rgb(0.0, 0.0, 1.0);
let n = d.map_solid_paints(&mut |c, s| {
if s.as_deref() != Some("Brand") {
return false;
}
*c = blue;
true
});
assert_eq!(n, 1);
assert_eq!(d.node(a).unwrap().appearance.fill_paint(), linked(blue, "Brand"));
assert_eq!(d.node(b).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let unchanged = |d: &Document| d.layers[0].children().unwrap()[1].clone();
assert!(Arc::ptr_eq(&unchanged(&d), &unchanged(&before)), "the unlinked rectangle is shared, not copied");
// Nothing to change: the tree is untouched.
let again = d.clone();
assert_eq!(d.map_solid_paints(&mut |_, _| false), 0);
assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0]));
}
#[test]
fn mapping_within_ids_visits_each_paint_once() {
let (mut d, a, b) = doc();
let layer = d.layers[0].id;
let mut seen = 0;
let n = d.map_solid_paints_in(&[layer, a], &mut |_, s| {
seen += 1;
s.take().is_some()
});
assert_eq!((n, seen), (1, 2), "the layer covers `a`: each fill is visited once");
assert_eq!(d.node(a).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let mut colors = vec![];
node_colors(d.node(b).unwrap(), &mut |c, s| colors.push((*c, s.map(str::to_string))));
assert_eq!(colors, [(Color::rgb(1.0, 0.0, 0.0), None)]);
}
}

View File

@ -257,7 +257,7 @@ fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
}
n
});
for sw in &mut d.swatches {
for sw in d.swatches_iter_mut() {
match &mut sw.paint {
Paint::Solid { color, .. } => *color = conv(color),
Paint::Gradient(g) => {
@ -486,14 +486,14 @@ fn set_spot(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.setSpot";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string();
let spot = bool_or(p, "spot", true);
let sw = s.doc()?.doc.swatches.iter().find(|w| w.name == name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?;
let sw = s.doc()?.doc.swatch(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?;
if !matches!(sw.paint, Paint::Solid { .. }) {
return Err(bad(C, "only solid-colour swatches can be spot colours"));
}
if sw.spot != spot {
let n = name.clone();
s.edit("Swatch Options", |d, _| {
if let Some(w) = d.swatches.iter_mut().find(|w| w.name == n) {
if let Some(w) = d.swatch_mut(&n) {
w.spot = spot;
if spot {
w.global = true;

View File

@ -113,7 +113,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
"images": images,
"symbols": symbols,
"spotColors": spot,
"swatches": d.swatches.len(),
"swatches": d.swatches_iter().count(),
"graphicStyles": d.graphic_styles.len(),
"characterStyles": d.char_styles.len(),
"paragraphStyles": d.para_styles.len(),

View File

@ -469,7 +469,7 @@ fn color_mode(s: &mut Session, p: &Value) -> Result<Value> {
for l in &mut d.layers {
map_node_colors(Arc::make_mut(l), &f, true, true);
}
for sw in &mut d.swatches {
for sw in d.swatches_iter_mut() {
if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint {
*color = f(*color);
}

View File

@ -2,7 +2,9 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup};
use vectorcraft_doc::Document;
use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_doc::swatches::{color_name, node_colors};
use vectorcraft_doc::{Document, NodeId};
use super::paint::paint_from;
use super::*;
@ -10,92 +12,222 @@ use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("swatch.new", "New Swatch", ["Window", "Swatches"], None, "{name?, color?, global?} (default: current fill)", has_doc, swatch_new),
cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete),
cmd!(
"swatch.new",
"New Swatch",
["Window", "Swatches"],
None,
"{name?, color? | swatch? | gradient? | pattern?: name (default: the current fill), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name}",
has_doc,
swatch_new
),
cmd!(
"swatch.delete",
"Delete Swatch",
["Window", "Swatches"],
None,
"{name? | names?: [swatch or colour group names] (a group goes with its swatches), unlink?: true (art using a deleted global swatch keeps its colour, unlinked; false keeps the stale link)} delete in one undo step → {deleted, unlinked: paints unlinked}",
has_doc,
swatch_delete
),
cmd!(
"swatch.newGroup",
"New Color Group",
["Window", "Swatches"],
None,
"{name?, swatches?: [names] (moved into the group), colors?: [colour] (added as new swatches)}",
"{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches), fromArtwork?: false (add the unique colours of the selected art: fills, strokes, text, gradient stops and mesh points; a colour linked to a global swatch moves that swatch into the group), toGlobal?: true (with fromArtwork: the new swatches are global and the selected art's matching unlinked colours link to them), includeTints?: false (with fromArtwork: tints of global swatches also get swatches of their own)} → {name, swatches: [names in the group], linked: paints linked}",
has_doc,
swatch_new_group
),
cmd!("swatch.duplicate", "Duplicate Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_duplicate),
cmd!("swatch.sortByName", "Sort by Name", ["Window", "Swatches"], None, "{}", has_doc, swatch_sort),
cmd!(
"swatch.edit",
"Swatch Options",
["Window", "Swatches"],
None,
"{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global)} edit a swatch in any colour group, as one undo step. Fills, strokes and text linked to a global swatch take its new colour and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only → {name, relinked: paints changed}",
has_doc,
swatch_edit
),
cmd!(
query "swatch.list",
"Swatches",
[],
None,
"{group?: name (only that colour group's swatches)} → {swatches: [{name, kind: \"none\"|\"color\"|\"gradient\"|\"pattern\", group, global, spot, color?, hex?, gradient?, pattern?}], groups: [{name, swatches: [names]}]}",
has_doc,
swatch_list
),
]
}
fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
let paint = match paint_from(s, p)? {
Some(x) => x,
None => s.paint.fill.clone(),
const C: &str = "swatch.new";
let paint = match str_param(p, "pattern") {
Some(name) if s.doc()?.doc.pattern(name).is_some() => pattern_paint(name),
Some(name) => return Err(bad(C, format!("no pattern `{name}`"))),
None => paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()),
};
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => match &paint {
Paint::Solid { color, .. } => rgb_name(*color),
_ => format!("Swatch {}", s.doc()?.doc.swatches.len() + 1),
},
let spot = bool_or(p, "spot", false);
let global = spot || bool_or(p, "global", false);
let mode = str_param(p, "mode");
let group = str_param(p, "group");
let paint = match paint {
// The swatch holds the colour itself, not a link to the swatch it may have come from.
Paint::Solid { color, .. } => Paint::solid(match mode {
Some(m) => convert_to_mode(color, m, C)?,
None => color,
}),
Paint::None => return Err(bad(C, "a swatch needs a colour, gradient or pattern")),
_ if spot || mode.is_some() || group.is_some() => return Err(bad(C, "spot, mode and group apply to solid colours only")),
other => other,
};
let global = bool_or(p, "global", false);
s.edit("New Swatch", |d, _| {
d.swatches.push(Swatch { name: name.clone(), paint, global, spot: false });
Ok(())
let requested = name_param(p, "name");
let name = s.edit("New Swatch", |d, _| {
let name = match requested {
Some(n) => d.free_swatch_name(&n),
None => d.new_swatch_name(&paint),
};
let swatch = Swatch { name: name.clone(), paint, global, spot };
match group {
Some(g) => {
d.swatch_groups.iter_mut().find(|x| x.name == g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?.swatches.push(swatch)
}
None => d.swatches.push(swatch),
}
Ok(name)
})?;
Ok(json!({ "name": name }))
}
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("swatch.delete", "missing name"))?.to_string();
s.edit("Delete Swatch", |d, _| {
d.swatches.retain(|sw| sw.name != name);
for g in &mut d.swatch_groups {
g.swatches.retain(|sw| sw.name != name);
const C: &str = "swatch.delete";
let mut names: Vec<String> = vec![];
for n in str_list(p, "names").into_iter().chain(str_param(p, "name").map(str::to_string)) {
if !names.contains(&n) {
names.push(n);
}
Ok(())
}
if names.is_empty() {
return Err(bad(C, "give `name` or `names`"));
}
let d = &s.doc()?.doc;
// Global swatches going away (named ones and those in named groups): their links are dropped.
let mut global: Vec<String> = vec![];
for n in &names {
let swatches: Vec<&Swatch> = match d.swatch_groups.iter().find(|g| g.name == *n) {
Some(g) => g.swatches.iter().collect(),
None => vec![d.swatch(n).ok_or_else(|| bad(C, format!("no swatch or colour group `{n}`")))?],
};
if swatches.iter().any(|w| w.paint.is_none()) {
return Err(bad(C, format!("`{n}` can't be deleted")));
}
global.extend(swatches.iter().filter(|w| w.global).map(|w| w.name.clone()));
}
if !bool_or(p, "unlink", true) {
global.clear();
}
let mut unlink = |_: &mut Color, link: &mut Option<String>| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some();
let unlinked = s.edit("Delete Swatch", |d, _| {
d.swatch_groups.retain(|g| !names.contains(&g.name));
for n in &names {
d.remove_swatch(n);
}
Ok(d.map_solid_paints(&mut unlink))
})?;
ok()
map_default_paints(s, &mut unlink);
Ok(json!({"deleted": names, "unlinked": unlinked}))
}
/// The default name of a new solid swatch: its 8-bit RGB values.
fn rgb_name(c: Color) -> String {
let [r, g, b] = c.to_rgb();
format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round())
}
/// A swatch or colour-group name not used yet in `d`.
fn free_name(d: &Document, base: &str) -> String {
unique_name(base, |n| d.swatch(n).is_some() || d.swatch_groups.iter().any(|g| g.name == n))
/// The colours of the art `ids` for a new colour group, in paint order: the swatches that linked
/// colours belong to (added to `swatches` once each) and the other colours (added to `colors` once
/// each). A tint (a linked colour that differs from its swatch's) brings its swatch, and with
/// `tints` its own colour too. Links to missing swatches count as unlinked.
fn artwork_colors(d: &Document, ids: &[NodeId], tints: bool, swatches: &mut Vec<String>, colors: &mut Vec<Color>) {
for n in ids.iter().filter_map(|id| d.node(*id)) {
node_colors(n, &mut |c, link| {
let sw = link.and_then(|l| d.swatch(l)).filter(|w| w.paint.color().is_some());
if let Some(w) = sw
&& !swatches.contains(&w.name)
{
swatches.push(w.name.clone());
}
let own = sw.is_none_or(|w| tints && w.paint.color() != Some(*c));
if own && !colors.contains(c) {
colors.push(*c);
}
});
}
}
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
let names: Vec<String> =
p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
const C: &str = "swatch.newGroup";
let mut names = str_list(p, "swatches");
let colors: Vec<Color> = p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default();
let requested = str_param(p, "name").map(str::to_string);
let name = s.edit("New Color Group", |d, _| {
let name = free_name(d, requested.as_deref().unwrap_or("Color Group"));
let from_art = bool_or(p, "fromArtwork", false);
let to_global = from_art && bool_or(p, "toGlobal", true);
let tints = bool_or(p, "includeTints", false);
let requested = name_param(p, "name");
let (name, members, linked) = s.edit("New Color Group", |d, sel| {
// New swatches: (colour, global).
let mut new: Vec<(Color, bool)> = colors.iter().map(|c| (*c, false)).collect();
if from_art {
if sel.objects.is_empty() {
return Err(bad(C, "select artwork to make a group of its colours"));
}
let mut art = vec![];
artwork_colors(d, &sel.objects, tints, &mut names, &mut art);
new.extend(art.into_iter().map(|c| (c, to_global)));
}
let name = d.free_swatch_name(requested.as_deref().unwrap_or("Color Group"));
let mut group = SwatchGroup { name: name.clone(), swatches: vec![] };
// Colour groups hold solid colours only.
for n in &names {
if let Some(pos) = d.swatches.iter().position(|sw| &sw.name == n) {
group.swatches.push(d.swatches.remove(pos));
if d.swatch(n).is_some_and(|sw| matches!(sw.paint, Paint::Solid { .. }))
&& let Some(sw) = d.remove_swatch(n)
{
group.swatches.push(sw);
}
}
for c in &colors {
group.swatches.push(Swatch { name: rgb_name(*c), paint: Paint::solid(*c), global: false, spot: false });
// The global swatches made from the art's colours, which its unlinked paints link to.
let mut links: Vec<(Color, String)> = vec![];
for (c, global) in new {
let nm = unique_name(&color_name(c), |n| n == name || d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n));
if global {
links.push((c, nm.clone()));
}
group.swatches.push(Swatch { name: nm, paint: Paint::solid(c), global, spot: false });
}
let members: Vec<String> = group.swatches.iter().map(|w| w.name.clone()).collect();
d.swatch_groups.push(group);
Ok(name)
let linked = if links.is_empty() {
0
} else {
let live: Vec<String> = d.swatches_iter().map(|w| w.name.clone()).collect();
d.map_solid_paints_in(&sel.objects, &mut |c, link| {
if link.as_ref().is_some_and(|l| live.contains(l)) {
return false;
}
match links.iter().find(|(lc, _)| lc == c) {
Some((_, n)) => {
*link = Some(n.clone());
true
}
None => false,
}
})
};
Ok((name, members, linked))
})?;
Ok(json!({"name": name}))
Ok(json!({"name": name, "swatches": members, "linked": linked}))
}
fn swatch_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string();
let new = s.edit("Duplicate Swatch", |d, _| {
let src = d.swatch(&name).cloned().ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
let nm = free_name(d, &format!("{name} copy"));
let nm = d.free_swatch_name(&format!("{name} copy"));
let copy = Swatch { name: nm.clone(), ..src };
if let Some(pos) = d.swatches.iter().position(|sw| sw.name == name) {
d.swatches.insert(pos + 1, copy);
@ -108,6 +240,159 @@ fn swatch_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"name": new}))
}
/// A list-of-strings parameter (missing or non-string entries are skipped).
fn str_list(p: &Value, key: &str) -> Vec<String> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
}
/// A trimmed, non-empty name parameter.
fn name_param(p: &Value, key: &str) -> Option<String> {
str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string)
}
/// `c` converted to a Swatch Options colour mode through the colour settings (HSB is an RGB view;
/// Web snaps each channel to a multiple of 0x33).
fn convert_to_mode(c: Color, mode: &str, cmd: &str) -> Result<Color> {
use vectorcraft_color::cms::Model;
let cms = vectorcraft_color::cms::active();
let to = |m: Model| cms.convert(&c, m, cms.settings().intent);
Ok(match mode.to_ascii_lowercase().as_str() {
"gray" | "grayscale" => to(Model::Gray),
"rgb" | "hsb" => to(Model::Rgb),
"cmyk" => to(Model::Cmyk),
"web" => {
let snap = |v: f32| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0;
let [r, g, b] = to(Model::Rgb).to_rgb_uncalibrated();
Color::rgb(snap(r), snap(g), snap(b))
}
other => return Err(bad(cmd, format!("unknown mode `{other}` (gray, rgb, hsb, cmyk or web)"))),
})
}
/// How a swatch edit reaches the solid paints linked to swatch `from`.
struct Relink {
from: String,
/// The swatch's name after the edit.
to: String,
/// The new colour of a global swatch (linked paints take it).
color: Option<Color>,
/// false: drop the links (the swatch stopped being global); paints keep their colour.
keep: bool,
}
impl Relink {
/// Apply to one paint; true when it changed.
fn apply(&self, c: &mut Color, link: &mut Option<String>) -> bool {
if link.as_deref() != Some(self.from.as_str()) {
return false;
}
if !self.keep {
*link = None;
return true;
}
let renamed = self.to != self.from;
if renamed {
*link = Some(self.to.clone());
}
let recolored = self.color.is_some_and(|n| n != *c);
if let Some(n) = self.color {
*c = n;
}
renamed || recolored
}
fn defaults(&self, s: &mut Session) {
map_default_paints(s, &mut |c, l| self.apply(c, l));
}
}
/// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live
/// preview (Cancel rolls the document back, not the session).
fn map_default_paints(s: &mut Session, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) {
if s.in_interaction() {
return;
}
for p in [&mut s.paint.fill, &mut s.paint.stroke] {
if let Paint::Solid { color, swatch } = p {
f(color, swatch);
}
}
}
fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.edit";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string();
let d = &s.doc()?.doc;
let sw = d.swatch(&name).cloned().ok_or_else(|| bad(C, format!("no swatch `{name}`")))?;
if sw.paint.is_none() {
return Err(bad(C, format!("`{name}` can't be edited")));
}
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
let solid = sw.paint.color();
if solid.is_none() && (given("color") || given("mode") || bool_or(p, "spot", false)) {
return Err(bad(C, "colour, mode and spot apply to solid-colour swatches only"));
}
let mut color = match p.get("color").filter(|v| !v.is_null()) {
Some(v) => Some(color_value(v).ok_or_else(|| bad(C, format!("bad color {v}")))?),
None => solid,
};
if let (Some(c), Some(m)) = (color, str_param(p, "mode")) {
color = Some(convert_to_mode(c, m, C)?);
}
let spot = bool_or(p, "spot", sw.spot);
let global = spot || bool_or(p, "global", sw.global);
let to = name_param(p, "newName").filter(|n| *n != name).map_or_else(|| name.clone(), |n| d.free_swatch_name(&n));
let relink = Relink { from: name.clone(), to: to.clone(), color: color.filter(|_| global), keep: global };
let relinked = s.edit("Swatch Options", |d, _| {
let w = d.swatch_mut(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?;
w.name = to.clone();
w.spot = spot;
w.global = global;
if let (Some(c), Paint::Solid { color: wc, .. }) = (color, &mut w.paint) {
*wc = c;
}
Ok(d.map_solid_paints(&mut |c, l| relink.apply(c, l)))
})?;
relink.defaults(s);
Ok(json!({"name": to, "relinked": relinked}))
}
fn swatch_json(sw: &Swatch, group: Option<&str>) -> Value {
let mut v = json!({"name": sw.name, "group": group, "global": sw.global, "spot": sw.spot});
match &sw.paint {
Paint::None => v["kind"] = json!("none"),
Paint::Solid { color, .. } => {
v["kind"] = json!("color");
v["color"] = json!(color);
v["hex"] = json!(color.to_hex());
}
Paint::Gradient(g) => {
v["kind"] = json!("gradient");
v["gradient"] = json!(g.gradient.kind.label().to_lowercase());
}
Paint::Pattern { pattern, .. } => {
v["kind"] = json!("pattern");
v["pattern"] = json!(pattern);
}
}
v
}
fn swatch_list(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let only = str_param(p, "group");
let groups: Vec<&SwatchGroup> = match only {
Some(g) => vec![d.swatch_groups.iter().find(|x| x.name == g).ok_or_else(|| bad("swatch.list", format!("no colour group `{g}`")))?],
None => d.swatch_groups.iter().collect(),
};
let ungrouped = d.swatches.iter().filter(|_| only.is_none()).map(|w| swatch_json(w, None));
let grouped = groups.iter().flat_map(|g| g.swatches.iter().map(|w| swatch_json(w, Some(&g.name))));
let swatches: Vec<Value> = ungrouped.chain(grouped).collect();
let groups: Vec<Value> =
groups.iter().map(|g| json!({"name": g.name, "swatches": g.swatches.iter().map(|w| &w.name).collect::<Vec<_>>()})).collect();
Ok(json!({"swatches": swatches, "groups": groups}))
}
fn swatch_sort(s: &mut Session, _: &Value) -> Result<Value> {
s.edit("Sort Swatches", |d, _| {
// [None] and other bracketed specials stay first, like the reference app.

View File

@ -733,6 +733,8 @@ mod tests_pattern;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_swatches;
#[cfg(test)]
mod tests_textedit;
#[cfg(test)]
mod tests_xform;

View File

@ -0,0 +1,350 @@
//! Swatches: colour groups as first-class swatch homes (colour mode, spot plates, PDF, Document
//! Info), naming and kinds, Swatch Options edits reaching linked art, deleting, new swatches in
//! groups and colour groups made from artwork.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn rect(s: &mut Session) -> NodeId {
NodeId(run(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap())
}
fn doc(s: &Session) -> &vectorcraft_doc::Document {
&s.doc().unwrap().doc
}
/// Add a global solid swatch to colour group `group`.
fn grouped_swatch(s: &mut Session, group: &str, name: &str, color: Color) {
assert_eq!(run(s, "swatch.new", json!({"name": name, "color": color, "global": true, "group": group}))["name"], name);
}
#[test]
fn document_color_mode_converts_grouped_swatches() {
let mut s = session();
grouped_swatch(&mut s, "Grays", "Ink", Color::rgb(0.2, 0.4, 0.6));
run(&mut s, "object.convertDocumentColorMode", json!({"mode": "cmyk"}));
for g in &doc(&s).swatch_groups {
for sw in &g.swatches {
assert!(!matches!(sw.paint.color(), Some(Color::Rgb { .. })), "{} in {} is still RGB", sw.name, g.name);
}
}
assert!(matches!(doc(&s).swatch("Ink").unwrap().paint.color(), Some(Color::Cmyk { .. })));
// The File menu's mode switch converts them as well.
let mut s = session();
run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"}));
assert!(matches!(doc(&s).swatch("Bright Red").unwrap().paint.color(), Some(Color::Cmyk { .. })));
}
#[test]
fn grouped_spot_swatches_print_on_their_own_plate() {
let mut s = session();
grouped_swatch(&mut s, "Brights", "Signal Orange", Color::cmyk(0.0, 0.6, 1.0, 0.0));
assert_eq!(run(&mut s, "swatch.setSpot", json!({"name": "Signal Orange"})), json!({"name": "Signal Orange", "spot": true}));
assert!(doc(&s).swatch("Signal Orange").is_some_and(|w| w.spot && w.global));
let plates = run(&mut s, "color.plates", json!({}));
assert!(plates["plates"].as_array().unwrap().iter().any(|p| p["name"] == "Signal Orange" && p["spot"] == true), "{plates}");
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Signal Orange"}));
let bytes = vectorcraft_pdf::export(doc(&s), &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap();
let pdf = String::from_utf8_lossy(&bytes);
assert!(pdf.contains("/Separation") && pdf.contains("Signal#20Orange"), "a grouped spot swatch is a Separation");
let info = run(&mut s, "document.info", json!({}));
assert_eq!(info["spotColors"], json!(["Signal Orange"]));
}
#[test]
fn document_info_counts_grouped_swatches() {
let mut s = session();
let d = doc(&s);
let all = d.swatches.len() + d.swatch_groups.iter().map(|g| g.swatches.len()).sum::<usize>();
assert!(all > d.swatches.len());
assert_eq!(run(&mut s, "document.info", json!({}))["swatches"], json!(all));
}
#[test]
fn new_swatches_are_named_by_their_colour_model() {
let mut s = session();
let name = |s: &mut Session, p: Value| run(s, "swatch.new", p)["name"].as_str().unwrap().to_string();
assert_eq!(name(&mut s, json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})), "C=10 M=20 Y=30 K=0");
assert_eq!(name(&mut s, json!({"color": "#ff8000"})), "R=255 G=128 B=0");
assert_eq!(name(&mut s, json!({"color": {"gray": 40}})), "Gray K=40");
// The same colour again (or an explicit name in use) gets the next free number, document-wide.
assert_eq!(name(&mut s, json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})), "C=10 M=20 Y=30 K=0 2");
assert_eq!(name(&mut s, json!({"name": "Grays", "color": "#000000"})), "Grays 2");
assert_eq!(name(&mut s, json!({"name": "Bright Red", "color": "#000000"})), "Bright Red 2");
// A spot swatch is always global; saving a linked colour stores the colour, not the link.
let spot = name(&mut s, json!({"name": "Ink", "color": "#336699", "spot": true}));
assert!(doc(&s).swatch(&spot).is_some_and(|w| w.spot && w.global));
let copy = name(&mut s, json!({"swatch": "Ink"}));
assert_eq!(doc(&s).swatch(&copy).unwrap().paint, Paint::solid(Color::from_hex("#336699").unwrap()));
assert!(s.execute("swatch.new", &json!({"none": true})).is_err(), "None is not a swatch");
}
#[test]
fn new_swatch_saves_the_active_pattern_or_gradient() {
let mut s = session();
let a = rect(&mut s);
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "object.pattern.make", json!({}));
run(&mut s, "object.pattern.done", json!({}));
let pattern = doc(&s).patterns[0].name.clone();
let name = run(&mut s, "swatch.new", json!({"pattern": pattern}))["name"].as_str().unwrap().to_string();
assert_eq!(name, "New Pattern Swatch 1");
assert!(matches!(&doc(&s).swatch(&name).unwrap().paint, Paint::Pattern { pattern: p, .. } if *p == pattern));
assert!(s.execute("swatch.new", &json!({"pattern": "Nope"})).is_err());
let g = json!({"gradient": {"kind": "radial", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}});
let name = run(&mut s, "swatch.new", g)["name"].as_str().unwrap().to_string();
assert_eq!(name, "New Gradient Swatch 1");
assert!(matches!(doc(&s).swatch(&name).unwrap().paint, Paint::Gradient(_)));
assert!(s.execute("swatch.new", &json!({"pattern": pattern, "spot": true})).is_err(), "only colours are spot");
}
#[test]
fn colour_groups_hold_solid_colours_only() {
let mut s = session();
let g = run(&mut s, "swatch.newGroup", json!({"name": "Mix", "swatches": ["Sunset", "Red", "[None]", "Bright Blue"], "colors": ["#00ff00"]}));
let d = doc(&s);
let group = d.swatch_groups.iter().find(|x| x.name == g["name"]).unwrap();
let names: Vec<&str> = group.swatches.iter().map(|w| w.name.as_str()).collect();
assert_eq!(names, ["Red", "Bright Blue", "R=0 G=255 B=0"]);
assert!(d.swatches.iter().any(|w| w.name == "Sunset") && d.swatches.iter().any(|w| w.name == "[None]"), "gradients and None stay put");
assert!(d.swatch_groups.iter().find(|x| x.name == "Brights").unwrap().swatches.iter().all(|w| w.name != "Bright Blue"), "moved, not copied");
}
fn fill_of(s: &Session, id: NodeId) -> Paint {
doc(s).node(id).unwrap().appearance.fill_paint()
}
fn linked(hex: &str, name: &str) -> Paint {
Paint::Solid { color: Color::from_hex(hex).unwrap(), swatch: Some(name.into()) }
}
#[test]
fn editing_a_global_swatch_recolours_linked_art_in_one_step() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true}));
let (a, b) = (rect(&mut s), rect(&mut s));
run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Brand"}));
run(&mut s, "paint.setFill", json!({"ids": [b.0], "color": "#2a6fb0"}));
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Hi"}))["id"].as_u64().unwrap());
run(&mut s, "paint.setFill", json!({"ids": [t.0], "swatch": "Brand"}));
run(&mut s, "select.set", json!({"ids": []}));
run(&mut s, "paint.setStroke", json!({"swatch": "Brand"}));
let undo_depth = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "swatch.edit", json!({"name": "Brand", "color": "#ff0000"}));
assert_eq!(r, json!({"name": "Brand", "relinked": 2}));
assert_eq!(fill_of(&s, a), linked("#ff0000", "Brand"));
assert_eq!(fill_of(&s, b), Paint::solid(Color::from_hex("#2a6fb0").unwrap()), "unlinked art keeps its colour");
let text_fill = |s: &Session| match &doc(s).node(t).unwrap().kind {
vectorcraft_doc::NodeKind::Text(tx) => tx.runs[0].style.fill.clone(),
_ => unreachable!(),
};
assert_eq!(text_fill(&s), linked("#ff0000", "Brand"), "text runs follow the swatch");
assert_eq!(s.paint.stroke, linked("#ff0000", "Brand"), "so does the default stroke");
assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth + 1, "one undo step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Brand"));
assert_eq!(doc(&s).swatch("Brand").unwrap().paint.color(), Color::from_hex("#2a6fb0"));
}
#[test]
fn renaming_relinks_and_spot_forces_global() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true}));
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Brand"}));
// A name in use gets the next free number.
let r = run(&mut s, "swatch.edit", json!({"name": "Brand", "newName": "Red"}));
assert_eq!(r, json!({"name": "Red 2", "relinked": 1}));
assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Red 2"));
assert!(doc(&s).swatch("Brand").is_none());
run(&mut s, "swatch.edit", json!({"name": "Red 2", "spot": true, "global": false}));
assert!(doc(&s).swatch("Red 2").is_some_and(|w| w.spot && w.global), "spot colours stay global");
// Turning Global off (no longer spot) unlinks the art, which keeps its colour.
let r = run(&mut s, "swatch.edit", json!({"name": "Red 2", "spot": false, "global": false, "color": "#000000"}));
assert_eq!(r["relinked"], 1);
assert_eq!(fill_of(&s, a), Paint::solid(Color::from_hex("#2a6fb0").unwrap()));
assert!(doc(&s).swatch("Red 2").is_some_and(|w| !w.global && w.paint.color() == Some(Color::BLACK)));
}
#[test]
fn grouped_swatches_edit_and_convert_modes() {
let mut s = session();
let r = run(&mut s, "swatch.edit", json!({"name": "Bright Red", "mode": "cmyk", "global": true}));
assert_eq!(r["name"], "Bright Red");
let w = doc(&s).swatch("Bright Red").unwrap();
assert!(w.global && matches!(w.paint.color(), Some(Color::Cmyk { .. })));
assert_eq!(doc(&s).swatch_group_of("Bright Red").map(|g| doc(&s).swatch_groups[g].name.clone()), Some("Brights".into()));
run(&mut s, "swatch.edit", json!({"name": "Bright Red", "color": "#123456", "mode": "web"}));
assert_eq!(doc(&s).swatch("Bright Red").unwrap().paint.color().unwrap().to_hex(), "#003366");
run(&mut s, "swatch.edit", json!({"name": "Bright Red", "mode": "gray"}));
assert!(matches!(doc(&s).swatch("Bright Red").unwrap().paint.color(), Some(Color::Gray { .. })));
// Gradients can be renamed but have no colour, mode or spot; None can't be edited.
assert!(s.execute("swatch.edit", &json!({"name": "Sunset", "color": "#000000"})).is_err());
assert!(s.execute("swatch.edit", &json!({"name": "Sunset", "spot": true})).is_err());
assert!(s.execute("swatch.edit", &json!({"name": "[None]", "newName": "x"})).is_err());
assert!(s.execute("swatch.edit", &json!({"name": "Bright Red", "mode": "lab"})).is_err());
assert_eq!(run(&mut s, "swatch.edit", json!({"name": "Sunset", "newName": "Dusk"}))["name"], "Dusk");
}
#[test]
fn swatch_list_reports_kinds_and_groups() {
let mut s = session();
let all = run(&mut s, "swatch.list", json!({}));
let list = all["swatches"].as_array().unwrap();
assert_eq!(list.len(), doc(&s).swatches_iter().count());
let find = |n: &str| list.iter().find(|w| w["name"] == n).unwrap().clone();
assert_eq!(find("[None]")["kind"], "none");
assert_eq!(find("Red")["kind"], "color");
assert_eq!(find("Red")["hex"], "#ed1c24");
assert_eq!(find("Sunset")["kind"], "gradient");
assert_eq!(find("K=50")["group"], "Grays");
let grays = run(&mut s, "swatch.list", json!({"group": "Grays"}));
assert_eq!(grays["swatches"].as_array().unwrap().len(), 9);
assert_eq!(grays["groups"][0]["name"], "Grays");
assert!(s.execute("swatch.list", &json!({"group": "Nope"})).is_err());
}
#[test]
fn deleting_a_global_swatch_unlinks_art_that_keeps_its_colour() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true}));
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Brand"}));
run(&mut s, "select.set", json!({"ids": []}));
run(&mut s, "paint.setFill", json!({"swatch": "Brand"}));
let before = doc(&s).clone();
assert_eq!(run(&mut s, "swatch.delete", json!({"name": "Brand"})), json!({"deleted": ["Brand"], "unlinked": 1}));
assert!(doc(&s).swatch("Brand").is_none());
assert_eq!(fill_of(&s, a), Paint::solid(Color::from_hex("#2a6fb0").unwrap()));
assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#2a6fb0").unwrap()), "the default fill is unlinked too");
run(&mut s, "edit.undo", json!({}));
assert_eq!(*doc(&s), before, "one undo step restores swatch and link");
// unlink: false keeps the stale link.
assert_eq!(run(&mut s, "swatch.delete", json!({"name": "Brand", "unlink": false}))["unlinked"], 0);
assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Brand"));
}
#[test]
fn deleting_several_swatches_and_groups() {
let mut s = session();
let r = run(&mut s, "swatch.delete", json!({"names": ["Red", "Brights", "Sunset"]}));
assert_eq!(r["deleted"], json!(["Red", "Brights", "Sunset"]));
let d = doc(&s);
assert!(d.swatch("Red").is_none() && d.swatch("Sunset").is_none() && d.swatch("Bright Red").is_none());
assert!(d.swatch_groups.iter().all(|g| g.name != "Brights") && d.swatch_groups.iter().any(|g| g.name == "Grays"));
assert!(s.execute("swatch.delete", &json!({"name": "[None]"})).is_err(), "None stays");
assert!(s.execute("swatch.delete", &json!({"names": ["Orange", "Nope"]})).is_err(), "unknown names fail the whole call");
assert!(doc(&s).swatch("Orange").is_some());
assert!(s.execute("swatch.delete", &json!({})).is_err());
run(&mut s, "edit.undo", json!({}));
assert!(doc(&s).swatch("Bright Red").is_some() && doc(&s).swatch("Red").is_some());
}
#[test]
fn new_swatches_go_into_groups_convert_modes_and_can_be_spot() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Ink", "color": "#ff0000", "spot": true, "mode": "cmyk", "group": "Brights"}));
let d = doc(&s);
let ink = d.swatch("Ink").unwrap();
assert!(ink.spot && ink.global, "spot colours are global");
assert!(matches!(ink.paint.color(), Some(Color::Cmyk { .. })), "mode converts the colour");
assert_eq!(d.swatch_group_of("Ink").map(|g| d.swatch_groups[g].name.as_str()), Some("Brights"));
assert!(s.execute("swatch.new", &json!({"color": "#00ff00", "group": "Nope"})).is_err());
assert!(s.execute("swatch.new", &json!({"swatch": "Sunset", "group": "Brights"})).is_err(), "groups hold solid colours only");
assert!(s.execute("swatch.new", &json!({"color": "#00ff00", "mode": "lab"})).is_err());
}
/// Two rectangles: `a` filled red and stroked green, `b` filled red and stroked with global swatch
/// "Brand"; both selected.
fn art(s: &mut Session) -> (NodeId, NodeId) {
run(s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true}));
let (a, b) = (rect(s), rect(s));
run(s, "paint.setFill", json!({"ids": [a.0, b.0], "color": "#ff0000"}));
run(s, "paint.setStroke", json!({"ids": [a.0], "color": "#00ff00"}));
run(s, "paint.setStroke", json!({"ids": [b.0], "swatch": "Brand"}));
run(s, "select.set", json!({"ids": [a.0, b.0]}));
(a, b)
}
#[test]
fn a_group_from_artwork_holds_its_unique_colours_and_links_the_art() {
let mut s = session();
let (a, b) = art(&mut s);
let undo = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "swatch.newGroup", json!({"name": "Art", "fromArtwork": true}));
assert_eq!(r["swatches"], json!(["Brand", "R=255 G=0 B=0", "R=0 G=255 B=0"]), "the linked swatch moves in, the rest are new");
assert_eq!(r["linked"], 3, "both red fills and the green stroke");
let d = doc(&s);
assert!(d.swatch("R=255 G=0 B=0").unwrap().global);
assert_eq!(d.swatch_group_of("Brand").map(|g| d.swatch_groups[g].name.as_str()), Some("Art"));
assert_eq!(fill_of(&s, a), linked("#ff0000", "R=255 G=0 B=0"));
assert_eq!(fill_of(&s, b), linked("#ff0000", "R=255 G=0 B=0"));
assert_eq!(doc(&s).node(b).unwrap().appearance.stroke_paint(), linked("#2a6fb0", "Brand"));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// Without Convert to Global the swatches are process colours and the art stays unlinked.
run(&mut s, "edit.undo", json!({}));
let r = run(&mut s, "swatch.newGroup", json!({"fromArtwork": true, "toGlobal": false}));
assert_eq!((r["name"].as_str(), r["linked"].as_u64()), (Some("Color Group"), Some(0)));
assert!(!doc(&s).swatch("R=0 G=255 B=0").unwrap().global);
assert_eq!(fill_of(&s, a), Paint::solid(Color::from_hex("#ff0000").unwrap()));
run(&mut s, "select.set", json!({"ids": []}));
assert!(s.execute("swatch.newGroup", &json!({"fromArtwork": true})).is_err(), "needs selected art");
}
#[test]
fn tints_of_global_swatches_get_swatches_only_when_asked() {
let mut s = session();
let (_, b) = art(&mut s);
// A 50% tint of Brand on b's stroke.
let tint = Color::from_hex("#95b7d8").unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).map_solid_paints_in(&[b], &mut |c, l| {
let hit = l.as_deref() == Some("Brand");
if hit {
*c = tint;
}
hit
});
let without = run(&mut s, "swatch.newGroup", json!({"fromArtwork": true}));
assert_eq!(without["swatches"], json!(["Brand", "R=255 G=0 B=0", "R=0 G=255 B=0"]), "a tint brings its swatch");
run(&mut s, "edit.undo", json!({}));
let with = run(&mut s, "swatch.newGroup", json!({"fromArtwork": true, "includeTints": true}));
assert_eq!(with["swatches"], json!(["Brand", "R=255 G=0 B=0", "R=0 G=255 B=0", "R=149 G=183 B=216"]));
assert_eq!(doc(&s).node(b).unwrap().appearance.stroke_paint(), Paint::Solid { color: tint, swatch: Some("Brand".into()) }, "tints stay linked");
}
#[test]
fn group_names_are_never_blank_and_never_shared_with_their_swatches() {
let mut s = session();
art(&mut s);
// A blank name falls back to the default; a swatch never takes its own group's name.
let r = run(&mut s, "swatch.newGroup", json!({"name": " ", "colors": ["#0000ff"]}));
assert_eq!(r["name"], "Color Group");
let r = run(&mut s, "swatch.newGroup", json!({"name": "R=255 G=0 B=0", "fromArtwork": true}));
assert_eq!(r["name"], "R=255 G=0 B=0");
assert_eq!(r["swatches"], json!(["Brand", "R=255 G=0 B=0 2", "R=0 G=255 B=0"]));
}
#[test]
fn a_group_from_a_mesh_holds_its_point_colours() {
let mut s = session();
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"ids": [a.0], "color": "#ff0000"}));
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "object.mesh.create", json!({"rows": 2, "cols": 2, "appearance": "center", "highlight": 100}));
let r = run(&mut s, "swatch.newGroup", json!({"fromArtwork": true}));
let names = r["swatches"].as_array().unwrap();
assert!(names.contains(&json!("R=255 G=0 B=0")) && names.contains(&json!("R=255 G=255 B=255")), "{r}");
}

View File

@ -215,9 +215,7 @@ impl Exporter<'_> {
/// swatch's CMYK equivalent is the alternate space).
fn solid(&mut self, c: &Color, swatch: Option<&str>) -> krilla::color::Color {
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
let spot = swatch
.and_then(|n| self.doc.swatches.iter().find(|s| s.spot && s.name == n))
.and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc)));
let spot = swatch.and_then(|n| self.doc.swatch(n).filter(|s| s.spot)).and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc)));
let Some((name, sc)) = spot else { return self.col(c) };
let cms = vectorcraft_color::cms::active();
let intent = cms.settings().intent;

View File

@ -58,7 +58,7 @@ pub fn plates(doc: &Document) -> Vec<Plate> {
Plate { name: (*n).into(), spot: false, rgb: c.cmyk_to_srgb(ink, false) }
})
.collect();
for sw in &doc.swatches {
for sw in doc.swatches_iter() {
if let (true, Paint::Solid { color, .. }) = (sw.spot, &sw.paint)
&& !v.iter().any(|p| p.name == sw.name)
{
@ -69,7 +69,7 @@ pub fn plates(doc: &Document) -> Vec<Plate> {
}
fn spot_swatch<'a>(doc: &'a Document, name: &str) -> Option<&'a Color> {
doc.swatches.iter().find(|s| s.spot && s.name == name).and_then(|s| match &s.paint {
doc.swatch(name).filter(|s| s.spot).and_then(|s| match &s.paint {
Paint::Solid { color, .. } => Some(color),
_ => None,
})

View File

@ -260,7 +260,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
},
"ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)),
"ui.dialog.cancel" => {
app.ui.dialog = None;
crate::dialogs::cancel(app);
ok(Value::Null)
}
"ui.resize" => {

View File

@ -0,0 +1,42 @@
//! A question answered with OK or Cancel; OK runs a command ([`ask`]). Used for confirmations such
//! as deleting swatches.
//!
//! Fields: `message` (the question, shown as the heading), `detail` (an optional line under it),
//! `__command` and `__params` (the command OK runs).
use serde_json::{Value, json};
use super::DialogSpec;
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::theme::Tokens;
/// The dialog kind of a confirmation.
pub const KIND: &str = "confirm";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| d.str("message"),
body: |_, ui, d| {
let detail = d.str("detail");
if !detail.is_empty() {
ui.label(egui::RichText::new(detail).color(Tokens::get(ui.ctx()).text_dim));
}
false
},
confirm,
max_width: Some(420.0),
..DialogSpec::FORM
};
/// Ask `message` (with an optional `detail` line); OK runs `command` with `params`.
pub fn ask(app: &mut VectorcraftApp, message: &str, detail: &str, command: &str, params: Value) {
let fields = json!({"message": message, "detail": detail, "__command": command, "__params": params});
app.ui.dialog = Some(Dialog::new(KIND, fields));
}
/// Closes before running, so a dialog the command opens stays open.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let params = d.fields.get("__params").cloned().unwrap_or_else(|| json!({}));
app.ui.dialog = None;
app.run(&d.str("__command"), params)
}

View File

@ -10,17 +10,25 @@ use crate::theme::Tokens;
pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(label).color(t.text_dim));
text(ui, d, key, 120.0);
ui.end_row();
}
/// 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 {
let t = Tokens::get(ui.ctx());
let mut s = d.str(key);
let r = egui::Frame::NONE
.fill(t.input)
.stroke(egui::Stroke::new(1.0, t.input_border))
.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(120.0)));
if r.inner.changed() {
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(width)));
let changed = r.inner.changed();
if changed {
d.fields.insert(key.into(), Value::String(s));
}
ui.end_row();
changed
}
/// A checkbox bound to `d.fields[key]`.

View File

@ -10,15 +10,19 @@ mod about;
mod all_tools;
mod artboard_options;
mod command;
pub mod confirm;
mod document_setup;
mod effect;
mod export_for_screens;
mod form;
pub mod new_color_group;
mod new_document;
pub mod new_swatch;
mod path_ops;
mod recolor;
mod save_changes;
mod shapes;
pub mod swatch_options;
mod tools;
mod transform;
@ -127,6 +131,10 @@ registry! {
Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)),
Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)),
FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)),
SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC,
Confirm: [confirm::KIND] => confirm::SPEC,
NewSwatch: [new_swatch::KIND] => new_swatch::SPEC,
NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
@ -142,6 +150,13 @@ fn run_and_close(app: &mut VectorcraftApp, id: &str, params: Value) -> DialogRes
r
}
/// Close the open dialog as Cancel does, rolling back a live preview (`ui.dialog.cancel`).
pub fn cancel(app: &mut VectorcraftApp) {
if app.ui.dialog.take().is_some_and(|d| spec(&d.kind).preview) {
let _ = app.session.cancel_interaction();
}
}
/// Apply the open dialog (OK).
pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
@ -206,10 +221,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
app.ui.dialog = Some(d);
if cancel {
if spec.preview {
let _ = app.session.cancel_interaction();
}
app.ui.dialog = None;
self::cancel(app);
} else if ok && let Err(e) = confirm(app) {
app.status(e);
}

View File

@ -0,0 +1,82 @@
//! New Color Group: a name and what the group is made from, the swatches selected in the Swatches
//! panel or the colours of the selected artwork (whose process colours can become global swatches
//! the art links to, with swatches for tints too). OK runs `swatch.newGroup`.
//!
//! Fields: `name`, `fromArtwork`, `toGlobal`, `includeTints`, `swatches` (the selected swatch
//! names) and `__art` (artwork is selected).
use serde_json::{Value, json};
use super::swatch_options::{grid, label};
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of New Color Group.
pub const KIND: &str = "newColorGroup";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "New Color Group".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
/// Open New Color Group for the swatches selected in the panel. It starts from the artwork when
/// art is selected and no swatches are.
pub fn open(app: &mut VectorcraftApp, swatches: Vec<String>) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let art = !st.selection.is_empty();
let fields = json!({
"name": st.doc.free_swatch_name("Color Group"),
"fromArtwork": art && swatches.is_empty(),
"toGlobal": true,
"includeTints": false,
"swatches": swatches,
"__art": art,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let art = d.bool("fromArtwork");
let set = |d: &mut Dialog, key: &str, v: bool| {
d.fields.insert(key.into(), json!(v));
};
grid(ui, |ui| {
label(ui, "Name:");
form::text(ui, d, "name", 190.0);
ui.end_row();
label(ui, "Create From:");
if widgets::radio(ui, "Selected Swatches", !art, true) {
set(d, "fromArtwork", false);
}
ui.end_row();
ui.label("");
if widgets::radio(ui, "Selected Artwork", art, d.bool("__art")) {
set(d, "fromArtwork", true);
}
ui.end_row();
for (key, text) in [("toGlobal", "Convert Process to Global"), ("includeTints", "Include Swatches for Tints")] {
ui.label("");
ui.horizontal(|ui| {
ui.add_space(18.0);
let on = d.bool(key);
if widgets::check(ui, text, on, art) {
set(d, key, !on);
}
});
ui.end_row();
}
});
false
}
/// `swatch.newGroup` parameters from the fields.
fn params(d: &Dialog) -> Value {
if d.bool("fromArtwork") {
json!({"name": d.str("name"), "fromArtwork": true, "toGlobal": d.bool("toGlobal"), "includeTints": d.bool("includeTints")})
} else {
json!({"name": d.str("name"), "swatches": d.fields.get("swatches").cloned().unwrap_or_else(|| json!([]))})
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
run_and_close(app, "swatch.newGroup", params(d))
}

View File

@ -0,0 +1,85 @@
//! New Swatch: the Swatch Options fields (name, colour type, Global, mode, sliders and hex)
//! prefilled from the active fill or stroke; a gradient or pattern only takes a name. OK runs
//! `swatch.new`.
//!
//! Fields: `name`, `spot`, `global`, `mode` and `color` as in Swatch Options, `group` (the colour
//! group the colour goes into; empty for none), `__paint` (the `swatch.new` paint of a gradient or
//! pattern; absent for a colour) and `__auto` (the default name, which follows the colour until
//! the name is edited).
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use super::swatch_options::{color_of, editor, grid, mode_id, name_row};
use super::{DialogSpec, run_and_close};
use crate::VectorcraftApp;
use crate::panels::color::Mode;
use crate::state::Dialog;
/// The dialog kind of New Swatch.
pub const KIND: &str = "newSwatch";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "New Swatch".into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM };
/// Open New Swatch for the active paint: a spot colour with `spot`, going into colour `group`.
pub fn open(app: &mut VectorcraftApp, spot: bool, group: Option<&str>) -> Result<Value, String> {
let paint = crate::panels::active_paint(app);
let name = app.session.active().ok_or("no document open")?.doc.new_swatch_name(&paint);
let mut fields = match &paint {
Paint::Solid { color, .. } => {
json!({"color": color, "mode": mode_id(Mode::of(color)), "spot": spot, "global": spot, "group": group.unwrap_or_default()})
}
Paint::None => return Err("pick a colour, gradient or pattern to save as a swatch".into()),
other => json!({"__paint": crate::panels::paint_params(other)}),
};
fields["name"] = json!(name);
fields["__auto"] = json!(name);
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if d.fields.contains_key("__paint") {
grid(ui, |ui| name_row(ui, d));
} else if editor(ui, d)
&& d.str("name") == d.str("__auto")
&& let Some(st) = app.session.active()
{
// The default name follows the colour until it is edited.
let auto = st.doc.new_swatch_name(&Paint::solid(color_of(d)));
d.fields.insert("name".into(), json!(auto));
d.fields.insert("__auto".into(), json!(auto));
}
false
}
/// `swatch.new` parameters from the fields.
fn params(d: &Dialog) -> Value {
let mut p = match d.fields.get("__paint") {
Some(paint) => paint.clone(),
None => json!({"color": color_of(d), "mode": d.str("mode"), "spot": d.bool("spot"), "global": d.bool("global")}),
};
p["name"] = json!(d.str("name"));
let group = d.str("group");
if !group.is_empty() {
p["group"] = json!(group);
}
p
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
run_and_close(app, "swatch.new", params(d))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn params_carry_the_colour_or_the_paint() {
let d = Dialog::new(KIND, json!({"name": "Sky", "color": "#2a6fb0", "mode": "rgb", "spot": true, "global": true, "group": "Mine"}));
assert_eq!(params(&d), json!({"name": "Sky", "color": color_of(&d), "mode": "rgb", "spot": true, "global": true, "group": "Mine"}));
let d = Dialog::new(KIND, json!({"name": "Dots", "__paint": {"pattern": "Dots"}, "group": ""}));
assert_eq!(params(&d), json!({"name": "Dots", "pattern": "Dots"}));
}
}

View File

@ -0,0 +1,267 @@
//! Swatch Options: name, colour type (process or spot), Global, colour mode with sliders and hex,
//! previewed live on the canvas (Cancel rolls the preview back). Double-clicking a swatch opens it
//! for a solid colour; gradients open the Gradient panel and patterns pattern editing ([`open`]).
//!
//! Fields: `__swatch` (the swatch being edited), `name`, `spot`, `global`, `mode` (`gray`, `rgb`,
//! `hsb`, `cmyk` or `web`), `color` (`"#rrggbb"` or a colour object such as
//! `{"model": "cmyk", "c": 0.1, "m": 0.2, "y": 0.3, "k": 0}`) and `preview`.
use egui::vec2;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use super::{DialogSpec, form, run_and_close};
use crate::VectorcraftApp;
use crate::panels::color::{Mode, components, from_components, parse_hex, track_color, web_safe};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets;
/// The dialog kind of Swatch Options.
pub const KIND: &str = "swatchOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Swatch Options".into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
/// Colour modes with their `mode` field / `swatch.edit` ids.
const MODES: [(Mode, &str); 5] = [(Mode::Grayscale, "gray"), (Mode::Rgb, "rgb"), (Mode::Hsb, "hsb"), (Mode::Cmyk, "cmyk"), (Mode::WebSafe, "web")];
const TYPES: [&str; 2] = ["Process Color", "Spot Color"];
pub(super) fn mode_id(m: Mode) -> &'static str {
MODES.iter().find(|x| x.0 == m).map_or("rgb", |x| x.1)
}
fn mode_of(d: &Dialog) -> Mode {
let id = d.str("mode");
MODES.iter().find(|x| x.1 == id).map_or(Mode::Rgb, |x| x.0)
}
/// The dialog's colour: a hex string or a serialized colour.
pub(super) fn color_of(d: &Dialog) -> Color {
match d.fields.get("color") {
Some(Value::String(s)) => Color::from_hex(s),
Some(v) => serde_json::from_value(v.clone()).ok(),
None => None,
}
.unwrap_or(Color::BLACK)
}
fn set_color(d: &mut Dialog, c: Color) {
d.fields.insert("color".into(), json!(c));
}
/// Open the editor of swatch `name`: Swatch Options for a colour, the Gradient panel (with the
/// gradient applied to the active proxy) for a gradient, pattern editing for a pattern.
pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
let sw = app.session.active().and_then(|st| st.doc.swatch(name).cloned()).ok_or_else(|| format!("no swatch `{name}`"))?;
match &sw.paint {
Paint::Solid { color, .. } => {
let fields = json!({
"__swatch": name, "name": name, "spot": sw.spot, "global": sw.global,
"mode": mode_id(Mode::of(color)), "color": color, "preview": true,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
Paint::Gradient(_) => {
app.run(crate::panels::paint_target(app), json!({"swatch": name}))?;
app.ui.open_panel = Some("gradient".into());
Ok(Value::Null)
}
Paint::Pattern { pattern, .. } => {
app.run("object.pattern.edit", json!({"name": pattern}))?;
app.ui.open_panel = Some("patternOptions".into());
Ok(Value::Null)
}
Paint::None => Err(format!("`{name}` has no options")),
}
}
/// `swatch.edit` parameters from the fields.
fn edit_params(d: &Dialog) -> Value {
json!({
"name": d.str("__swatch"),
"newName": d.str("name"),
"color": color_of(d),
"mode": d.str("mode"),
"global": d.bool("global"),
"spot": d.bool("spot"),
})
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let changed = editor(ui, d);
ui.add_space(8.0);
let mut pv = d.bool("preview");
let pv_changed = widgets::check(ui, "Preview", pv, true);
if pv_changed {
pv = !pv;
d.fields.insert("preview".into(), json!(pv));
}
if pv && (changed || pv_changed || !app.session.in_interaction()) {
let _ = app.session.begin_interaction("Swatch Options");
if let Err(e) = app.session.preview("swatch.edit", &edit_params(d)) {
app.status(e.to_string());
}
} else if !pv && pv_changed {
let _ = app.session.cancel_interaction();
}
false
}
/// Drop the preview and apply the edit as one undo step (which also updates the default paints).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let _ = app.session.cancel_interaction();
run_and_close(app, "swatch.edit", edit_params(d))
}
/// The fields' grid of the swatch dialogs.
pub(super) fn grid<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
egui::Grid::new("swatch-options").num_columns(2).spacing([10.0, 8.0]).show(ui, add).inner
}
/// A dimmed field label in the swatch dialogs' grid.
pub(super) fn label(ui: &mut egui::Ui, s: &str) {
ui.label(egui::RichText::new(s).color(Tokens::get(ui.ctx()).text_dim));
}
/// The Swatch Name row of the grid. Returns true when it changed.
pub(super) fn name_row(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, "Swatch Name:");
let changed = form::text(ui, d, "name", 190.0);
ui.end_row();
changed
}
/// The shared fields of Swatch Options and New Swatch: name, colour type, Global, mode, sliders and
/// hex. Returns true when anything changed.
pub(super) fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let mut changed = false;
grid(ui, |ui| {
changed |= name_row(ui, d);
label(ui, "Color Type:");
let spot = d.bool("spot");
if let Some(i) = widgets::dropdown(ui, "swatch-type", TYPES[usize::from(spot)], &TYPES, 200.0) {
d.fields.insert("spot".into(), json!(i == 1));
changed = true;
}
ui.end_row();
ui.label("");
// Spot colours are always global.
let global = spot || d.bool("global");
if widgets::check(ui, "Global", global, !spot) {
d.fields.insert("global".into(), json!(!global));
changed = true;
}
ui.end_row();
label(ui, "Color Mode:");
let mode = mode_of(d);
let labels: Vec<&str> = Mode::ALL.iter().map(|m| m.1).collect();
let current = Mode::ALL.iter().find(|m| m.0 == mode).map_or("RGB", |m| m.1);
if let Some(i) = widgets::dropdown(ui, "swatch-mode", current, &labels, 200.0) {
let m = Mode::ALL[i].0;
// Switching modes converts the colour (HSB is a view of RGB), like the Color panel.
let base = if m == Mode::Hsb { Mode::Rgb } else { m };
set_color(d, from_components(base, &components(base, &color_of(d))));
d.fields.insert("mode".into(), json!(mode_id(m)));
changed = true;
}
ui.end_row();
});
ui.add_space(6.0);
changed | sliders(ui, d)
}
/// One slider and value field per component of the dialog's mode, the hex field (RGB, HSB and Web)
/// and a chip of the colour. Returns true when the colour changed.
fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let mode = mode_of(d);
let color = color_of(d);
// Keep the displayed components while the colour is unchanged (hue survives S = 0 etc.).
let key = format!("{}|{}", mode_id(mode), serde_json::to_string(&color).unwrap_or_default());
let comps: Vec<f32> = match d.fields.get("__comps") {
Some(v) if d.str("__compsKey") == key => serde_json::from_value(v.clone()).unwrap_or_else(|_| components(mode, &color)),
_ => components(mode, &color),
};
let mut new: Option<(Color, Vec<f32>)> = None;
ui.horizontal(|ui| {
ui.vertical(|ui| {
for (i, lbl) in mode.labels().iter().enumerate() {
ui.horizontal(|ui| {
ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(*lbl).size(12.5).color(t.text)));
let max = mode.max(i);
let v = comps.get(i).copied().unwrap_or(0.0);
let track = |x: f32| crate::panels::c32(&track_color(mode, &comps, i, x));
if let (Some(nv), _) = widgets::color_slider(ui, ("swatch-slider", i), v / max, 200.0, &track) {
let mut c2 = comps.clone();
c2[i] = if mode == Mode::WebSafe { ((nv * 5.0).round() / 5.0) * max } else { (nv * max).round() };
new = Some((from_components(mode, &c2), c2));
}
if let Some(fv) = widgets::plain_field(ui, ("swatch-field", i), v as f64, mode.suffix(i), 0, 52.0) {
let mut c2 = comps.clone();
c2[i] = (fv as f32).clamp(0.0, max);
new = Some((from_components(mode, &c2), c2));
}
});
}
if matches!(mode, Mode::Rgb | Mode::Hsb | Mode::WebSafe) {
ui.horizontal(|ui| {
ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new("#").size(12.5).color(t.text)));
let hex = color.to_hex().trim_start_matches('#').to_uppercase();
if let Some(c) = hex_field(ui, &hex) {
let c = if mode == Mode::WebSafe { web_safe(&c) } else { c };
new = Some((c, components(mode, &c)));
}
});
}
});
let (r, _) = ui.allocate_exact_size(vec2(44.0, 44.0), egui::Sense::hover());
widgets::swatch_tile(ui, r, &Paint::solid(color), false, false);
});
let Some((c, comps)) = new else { return false };
set_color(d, c);
d.fields.insert("__compsKey".into(), json!(format!("{}|{}", mode_id(mode), serde_json::to_string(&c).unwrap_or_default())));
d.fields.insert("__comps".into(), json!(comps));
true
}
/// A hex field showing `hex`; returns the colour typed into it once committed.
fn hex_field(ui: &mut egui::Ui, hex: &str) -> Option<Color> {
let t = Tokens::get(ui.ctx());
let id = ui.id().with("swatch-hex");
let editing = ui.memory(|m| m.has_focus(id));
let mut buf: String = if editing { ui.data_mut(|m| m.get_temp::<String>(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() };
let resp = egui::Frame::NONE
.fill(t.input)
.stroke(egui::Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
.corner_radius(2)
.inner_margin(egui::Margin::symmetric(6, 4))
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut buf).id(id).frame(egui::Frame::NONE).desired_width(70.0).char_limit(7)))
.inner;
ui.data_mut(|m| m.insert_temp(id, buf.clone()));
(resp.lost_focus() && buf != hex).then(|| parse_hex(&buf)).flatten()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fields_round_trip_modes_and_colours() {
for (m, id) in MODES {
assert_eq!(mode_of(&Dialog::new(KIND, json!({"mode": id}))), m);
}
let d = Dialog::new(KIND, json!({"color": "#ff8000"}));
assert_eq!(color_of(&d).to_hex(), "#ff8000");
let mut d = Dialog::new(KIND, json!({}));
set_color(&mut d, Color::cmyk(0.1, 0.2, 0.3, 0.4));
assert_eq!(color_of(&d), Color::cmyk(0.1, 0.2, 0.3, 0.4));
let p = edit_params(&Dialog::new(KIND, json!({"__swatch": "A", "name": "B", "spot": true, "color": "#000000", "mode": "cmyk"})));
assert_eq!(
(p["name"].as_str(), p["newName"].as_str(), p["spot"].as_bool(), p["mode"].as_str()),
(Some("A"), Some("B"), Some(true), Some("cmyk"))
);
}
}

View File

@ -160,6 +160,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("help.about", "About VectorCraft", "", "{}"),
("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"),
("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"),
(
"ui.swatchOptions",
"Swatch Options…",
"",
"{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit), the Gradient panel for a gradient, pattern editing for a pattern",
),
(
"ui.newSwatch",
"New Swatch…",
"",
"{spot?, group?: colour group name} open New Swatch for the active fill or stroke (dialog `newSwatch`, engine: swatch.new)",
),
(
"ui.newColorGroup",
"New Color Group…",
"",
"{swatches?: [names]} open New Color Group, from those swatches or the selected artwork (dialog `newColorGroup`, engine: swatch.newGroup)",
),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@ -482,6 +500,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
},
"file.closeAll" => crate::unsaved::close_all(app, "closeAll"),
"app.quit" => crate::unsaved::close_all(app, "quit"),
"ui.swatchOptions" => match s("name") {
Some(name) => crate::dialogs::swatch_options::open(app, &name),
None => Err("missing `name`".into()),
},
"ui.newSwatch" => crate::dialogs::new_swatch::open(app, p.get("spot").and_then(Value::as_bool).unwrap_or(false), s("group").as_deref()),
"ui.newColorGroup" => {
let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
crate::dialogs::new_color_group::open(app, names.unwrap_or_default())
}
_ => return None,
};
Some(r)
@ -644,6 +671,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()),
"file.export.pdf" => app.session.active().is_some(),
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
_ => true,
}
}

View File

@ -274,7 +274,7 @@ pub(crate) fn color_json(c: &Color) -> Value {
}
}
/// Paint as command params (`{color}`, `{none}` or `{gradient}`).
/// Paint as command params (`{color}`, `{swatch}`, `{none}`, `{gradient}` or `{pattern}`).
pub(crate) fn paint_params(p: &Paint) -> Value {
match p {
Paint::None => json!({"none": true}),
@ -285,7 +285,7 @@ pub(crate) fn paint_params(p: &Paint) -> Value {
"angle": g.angle,
"stops": g.gradient.stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity})).collect::<Vec<_>>()
}}),
Paint::Pattern { .. } => json!({"none": true}),
Paint::Pattern { pattern, .. } => json!({"pattern": pattern}),
}
}
@ -321,5 +321,6 @@ mod tests {
let p = paint_params(&g);
assert_eq!(p["gradient"]["kind"], "linear");
assert_eq!(p["gradient"]["stops"].as_array().unwrap().len(), 2);
assert_eq!(paint_params(&vectorcraft_doc::pattern::pattern_paint("Dots")), json!({"pattern": "Dots"}));
}
}

View File

@ -83,6 +83,15 @@ enum Entry {
Folder(String),
}
impl Entry {
fn name(&self) -> &str {
match self {
Entry::Registration => REGISTRATION,
Entry::Swatch { name, .. } | Entry::Folder(name) => name,
}
}
}
const REGISTRATION: &str = "[Registration]";
fn entries(app: &VectorcraftApp, kind: Kind) -> Vec<Entry> {
@ -185,8 +194,41 @@ fn draw_folder(ui: &Ui, r: Rect) {
icons::paint(ui, "dc-folder", r.expand(1.0), t.icon);
}
fn selected_name(ui: &Ui) -> Option<String> {
pstate::<Option<String>>(ui.ctx(), "swatch-selected")
/// The swatches and colour groups selected in the panel, in click order, without names that no
/// longer exist (deleted, renamed or undone).
fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec<String> {
let Some(st) = app.session.active() else { return vec![] };
let mut names: Vec<String> = pstate(ui.ctx(), "swatch-selected");
names.retain(|n| n == REGISTRATION || st.doc.swatch_name_taken(n));
names
}
/// The selection after a click on `name`: Cmd/Ctrl toggles it, Shift extends from the last clicked
/// name over `order` (the names as displayed), a plain click selects it alone.
fn click_selection(ui: &Ui, mut sel: Vec<String>, order: &[&str], name: &str, m: egui::Modifiers) -> Vec<String> {
let anchor: String = pstate(ui.ctx(), "swatch-anchor");
let pos = |n: &str| order.iter().position(|o| *o == n);
if m.shift
&& let (Some(a), Some(b)) = (pos(&anchor), pos(name))
{
for n in &order[a.min(b)..=a.max(b)] {
if !sel.iter().any(|s| s == n) {
sel.push(n.to_string());
}
}
return sel;
}
set_pstate(ui.ctx(), "swatch-anchor", name.to_string());
if !m.command {
return vec![name.to_string()];
}
match sel.iter().position(|s| s == name) {
Some(i) => {
sel.remove(i);
}
None => sel.push(name.to_string()),
}
sel
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
@ -219,23 +261,23 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
Paint::None => Some("[None]".to_string()),
_ => None,
};
let sel = selected_name(ui).or(active_swatch);
let selected = selection(app, ui);
// Without a selection the swatch of the active paint is highlighted.
let fallback: Vec<String> = if selected.is_empty() { active_swatch.into_iter().collect() } else { vec![] };
let sel = if selected.is_empty() { &fallback } else { &selected };
let is_sel = |name: &str| sel.iter().any(|s| s == name);
let (tile, pitch) = view.tile();
let mut clicked: Option<Entry> = None;
let mut edit_pattern: Option<String> = None;
let mut clicked: Option<(Entry, egui::Modifiers)> = None;
// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing).
let mut edit: Option<String> = None;
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(if view == View::LargeThumb { 200.0 } else { 150.0 }).show(ui, |ui| {
ui.set_width(ui.available_width());
if view.is_list() {
for e in &items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), pitch), Sense::click());
let name = match e {
Entry::Registration => REGISTRATION.to_string(),
Entry::Swatch { name, .. } => name.clone(),
Entry::Folder(n) => n.clone(),
};
let is_sel = sel.as_deref() == Some(name.as_str());
if is_sel {
let name = e.name();
if is_sel(name) {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
@ -252,7 +294,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().text(
pos2(chip.right() + 8.0, r.center().y),
egui::Align2::LEFT_CENTER,
&name,
name,
egui::FontId::proportional(12.0),
t.text,
);
@ -271,16 +313,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
));
}
}
if resp.on_hover_text(&name).clicked() && !matches!(e, Entry::Folder(_)) {
clicked = Some(e.clone());
if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() {
edit = Some(name.to_string());
}
if resp.on_hover_text(name).clicked() {
clicked = Some((e.clone(), ui.input(|i| i.modifiers)));
}
}
} else {
let w = ui.available_width();
let per_row = ((w - 2.0) / pitch).floor().max(1.0) as usize;
let mut col = 0usize;
let mut rows: Vec<Vec<(usize, &Entry)>> = vec![vec![]];
for (i, e) in items.iter().enumerate() {
let mut rows: Vec<Vec<&Entry>> = vec![vec![]];
for e in &items {
// A group folder starts a new row.
if matches!(e, Entry::Folder(_)) && col > 0 {
rows.push(vec![]);
@ -290,28 +335,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
rows.push(vec![]);
col = 0;
}
rows.last_mut().unwrap().push((i, e));
rows.last_mut().unwrap().push(e);
col += 1;
}
for row in rows {
let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover());
for (c, (i, e)) in row.into_iter().enumerate() {
for (c, e) in row.into_iter().enumerate() {
let cell = Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile));
let resp = ui.interact(cell, ui.id().with(("sw", i)), Sense::click());
let (name, tip) = match e {
Entry::Registration => (REGISTRATION.to_string(), "[Registration]".to_string()),
Entry::Swatch { name, .. } => (name.clone(), name.clone()),
Entry::Folder(n) => (n.clone(), format!("Color Group: {n}")),
};
let resp = ui.interact(cell, tile_id(e), Sense::click());
let name = e.name();
match e {
Entry::Registration => draw_registration(ui, cell),
Entry::Swatch { paint, global, .. } => {
swatch_tile(ui, cell, paint, sel.as_deref() == Some(name.as_str()), resp.hovered());
swatch_tile(ui, cell, paint, is_sel(name), resp.hovered());
pattern_thumb(app, ui, cell.shrink(1.0), paint);
if resp.double_clicked()
&& let Paint::Pattern { pattern, .. } = paint
{
edit_pattern = Some(pattern.clone());
if resp.double_clicked() {
edit = Some(name.to_string());
}
if *global {
let k = cell.shrink(1.0);
@ -322,37 +361,78 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
));
}
}
Entry::Folder(_) => draw_folder(ui, cell),
Entry::Folder(_) => {
draw_folder(ui, cell);
if is_sel(name) {
ui.painter().rect_stroke(cell.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside);
}
}
}
if resp.on_hover_text(tip).clicked() && !matches!(e, Entry::Folder(_)) {
clicked = Some(e.clone());
let resp = match e {
Entry::Folder(n) => resp.on_hover_text(format!("Color Group: {n}")),
_ => resp.on_hover_text(name),
};
if resp.clicked() {
clicked = Some((e.clone(), ui.input(|i| i.modifiers)));
}
}
}
}
});
});
if let Some(name) = edit_pattern {
app.run("object.pattern.edit", json!({"name": name})).ok();
app.ui.open_panel = Some("patternOptions".into());
if let Some(name) = edit {
app.run("ui.swatchOptions", json!({"name": name})).ok();
}
if let Some(e) = clicked {
let name = match &e {
Entry::Registration => REGISTRATION.to_string(),
Entry::Swatch { name, .. } => name.clone(),
Entry::Folder(n) => n.clone(),
};
set_pstate(ui.ctx(), "swatch-selected", Some(name));
apply(app, ui, &e);
}
bottom(app, ui);
let selected = match clicked {
Some((e, m)) => {
let order: Vec<&str> = items.iter().map(Entry::name).collect();
let sel = click_selection(ui, selected, &order, e.name(), m);
set_pstate(ui.ctx(), "swatch-selected", sel.clone());
// A plain click also applies the swatch; modifier clicks only select.
if !(m.shift || m.command) {
apply(app, ui, &e);
}
sel
}
None => selected,
};
bottom(app, ui, &selected);
}
fn deletable(ui: &Ui) -> Option<String> {
selected_name(ui).filter(|n| !n.starts_with('['))
/// The interaction id of a tile in the thumbnail views (stable per swatch or group name).
fn tile_id(e: &Entry) -> egui::Id {
egui::Id::new(("swatch-tile", e.name()))
}
fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) {
/// The selected swatches and groups that can be deleted (all but None and Registration).
fn deletable(sel: &[String]) -> Vec<String> {
sel.iter().filter(|n| !n.starts_with('[')).cloned().collect()
}
/// The selected swatch when exactly one swatch with an editor is selected (not None, Registration
/// or a group).
fn editable(app: &VectorcraftApp, sel: &[String]) -> Option<String> {
match sel {
[n] if !n.starts_with('[') && app.session.active().is_some_and(|st| st.doc.swatch(n).is_some()) => Some(n.clone()),
_ => None,
}
}
/// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click).
fn delete(app: &mut VectorcraftApp, names: Vec<String>, now: bool) {
let params = json!({"names": names});
if now {
app.run("swatch.delete", params).ok();
return;
}
let message = match names.as_slice() {
[n] => format!("Delete “{n}”?"),
_ => format!("Delete these {} swatches and groups?", names.len()),
};
crate::dialogs::confirm::ask(app, &message, "Art using a deleted global swatch keeps its colour.", "swatch.delete", params);
}
fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) {
let kind: Kind = pstate(ui.ctx(), "swatch-kind");
widgets::bottom_bar(ui, |ui| {
widgets::icon_button_enabled(ui, "library", "Swatch Libraries (on the roadmap)", false, false, 24.0);
@ -364,53 +444,76 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
});
widgets::icon_button_enabled(ui, "dc-options", "Swatch Options (on the roadmap)", false, false, 24.0);
let opts = editable(app, sel);
if widgets::icon_button_enabled(ui, "dc-options", "Swatch Options", false, opts.is_some(), 24.0).clicked()
&& let Some(n) = opts
{
app.run("ui.swatchOptions", json!({"name": n})).ok();
}
ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0));
if widgets::icon_button(ui, "dc-folder", "New Color Group", false, 24.0).clicked() {
let names: Vec<String> = deletable(ui).into_iter().collect();
app.run("swatch.newGroup", json!({"swatches": names})).ok();
app.run("ui.newColorGroup", json!({"swatches": deletable(sel)})).ok();
}
// Alt-click skips the dialog; Ctrl/Cmd-click makes a spot colour.
if widgets::icon_button(ui, "dc-new-item", "New Swatch", false, 24.0).clicked() {
new_swatch(app);
let m = ui.input(|i| i.modifiers);
new_swatch(app, sel, m.command, m.alt);
}
let del = deletable(ui);
if widgets::icon_button_enabled(ui, "trash-2", "Delete Swatch", false, del.is_some(), 24.0).clicked()
&& let Some(n) = del
&& app.run("swatch.delete", json!({"name": n})).is_ok()
{
set_pstate::<Option<String>>(ui.ctx(), "swatch-selected", None);
// Alt-click deletes without asking.
let del = deletable(sel);
if widgets::icon_button_enabled(ui, "trash-2", "Delete Swatch", false, !del.is_empty(), 24.0).clicked() {
delete(app, del, ui.input(|i| i.modifiers.alt));
}
});
}
fn new_swatch(app: &mut VectorcraftApp) {
let p = active_paint(app);
let params = match &p {
Paint::None => json!({}),
other => super::paint_params(other),
};
/// The colour group the selection points at: the last selected group, or the group of the last
/// selected swatch.
fn target_group(app: &VectorcraftApp, sel: &[String]) -> Option<String> {
let d = &app.session.active()?.doc;
let last = sel.last()?;
if d.swatch_groups.iter().any(|g| g.name == *last) {
return Some(last.clone());
}
d.swatch_group_of(last).map(|g| d.swatch_groups[g].name.clone())
}
/// New Swatch from the active paint (a colour goes into the selected colour group): opens the
/// dialog, or saves at once with `now` (Alt-click). `spot` (Cmd/Ctrl-click) makes a spot colour.
fn new_swatch(app: &mut VectorcraftApp, sel: &[String], spot: bool, now: bool) {
let paint = active_paint(app);
let group = paint.color().and_then(|_| target_group(app, sel));
if !now {
app.run("ui.newSwatch", json!({"spot": spot, "group": group})).ok();
return;
}
let mut params = super::paint_params(&paint);
params["spot"] = json!(spot);
if let Some(g) = group {
params["group"] = json!(g);
}
app.run("swatch.new", params).ok();
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let view: View = pstate(ui.ctx(), "swatch-view");
let sel = deletable(ui);
let selected = selection(app, ui);
let del = deletable(&selected);
let opts = editable(app, &selected);
if menu_item(ui, "New Swatch…", true, false) {
new_swatch(app);
new_swatch(app, &selected, false, false);
}
if menu_item(ui, "New Color Group…", true, false) {
app.run("swatch.newGroup", json!({"swatches": sel.clone().into_iter().collect::<Vec<_>>()})).ok();
app.run("ui.newColorGroup", json!({"swatches": del})).ok();
}
if menu_item(ui, "Duplicate Swatch", sel.is_some(), false)
&& let Some(n) = &sel
if menu_item(ui, "Duplicate Swatch", opts.is_some(), false)
&& let Some(n) = &opts
{
app.run("swatch.duplicate", json!({"name": n})).ok();
}
menu_item(ui, "Merge Swatches", false, false);
if menu_item(ui, "Delete Swatch", sel.is_some(), false)
&& let Some(n) = &sel
{
app.run("swatch.delete", json!({"name": n})).ok();
if menu_item(ui, "Delete Swatch", !del.is_empty(), false) {
delete(app, del, false);
}
menu_item(ui, "Ungroup Color Group", false, false);
menu_item(ui, "Select All Unused", false, false);
@ -427,7 +530,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
ui.separator();
menu_item(ui, "Swatch Options…", false, false);
if menu_item(ui, "Swatch Options…", opts.is_some(), false)
&& let Some(n) = opts
{
app.run("ui.swatchOptions", json!({"name": n})).ok();
}
menu_item(ui, "Spot Colors…", false, false);
ui.separator();
menu_item(ui, "Open Swatch Library", false, false);
@ -437,7 +544,220 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
#[cfg(test)]
mod tests {
use super::*;
use crate::dialogs::swatch_options::KIND;
use egui::{Event, Modifiers, PointerButton, Pos2};
use vectorcraft_color::Color;
use vectorcraft_engine::Session;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app
}
/// One frame of `draw` on a persistent context (clicks need the previous frame's layout).
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui)) {
frame_with(app, ctx, events, Modifiers::NONE, time, draw);
}
/// [`frame`] with `modifiers` held.
fn frame_with(
app: &mut VectorcraftApp,
ctx: &egui::Context,
events: Vec<Event>,
modifiers: Modifiers,
time: f64,
draw: fn(&mut VectorcraftApp, &mut Ui),
) {
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)));
let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect();
let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| draw(app, ui));
out.textures_delta.clear();
}
fn context() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx
}
fn double_click(at: Pos2) -> Vec<Event> {
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
vec![Event::PointerMoved(at), button(true), button(false), button(true), button(false)]
}
/// Double-click the thumbnail of swatch `name` in the panel.
fn double_click_tile(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str) {
frame(app, ctx, vec![], 0.0, show);
let tile = ctx.read_response(egui::Id::new(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect;
frame(app, ctx, double_click(tile.center()), 1.0, show);
}
/// Click the thumbnail of swatch or group `name` with `modifiers` held (frames at `time`, `time + 0.5`).
fn click_tile(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) {
frame(app, ctx, vec![], time, show);
let at = ctx.read_response(egui::Id::new(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center();
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers };
frame_with(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5, show);
}
fn selected(ctx: &egui::Context) -> Vec<String> {
pstate(ctx, "swatch-selected")
}
fn dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context) {
frame(app, ctx, vec![], 2.0, |app, ui| crate::dialogs::show(app, ui.ctx()));
}
#[test]
fn double_click_opens_swatch_options_and_ok_edits_linked_art() {
let mut app = app();
app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [id], "swatch": "Red"})).unwrap();
let ctx = context();
double_click_tile(&mut app, &ctx, "Red");
let d = app.ui.dialog.as_ref().expect("double-click opens Swatch Options");
assert_eq!((d.kind.as_str(), d.str("__swatch").as_str(), d.str("mode").as_str()), (KIND, "Red", "rgb"));
// The dialog previews live on the canvas; OK keeps the edit as one undo step.
let undo = app.session.doc().unwrap().history.undo.len();
dialog_frame(&mut app, &ctx);
assert!(app.session.in_interaction(), "Preview runs as an interaction");
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("color".into(), json!("#00ff00"));
d.fields.insert("name".into(), json!("Signal"));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
let doc = &app.session.doc().unwrap().doc;
assert_eq!(doc.swatch("Signal").and_then(|w| w.paint.color()).map(|c| c.to_hex()), Some("#00ff00".into()));
let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
assert_eq!(fill, Paint::Solid { color: Color::from_hex("#00ff00").unwrap(), swatch: Some("Signal".into()) });
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
}
#[test]
fn cancel_rolls_the_preview_back_and_other_kinds_open_their_editors() {
let mut app = app();
let ctx = context();
let before = app.session.doc().unwrap().doc.clone();
app.run("ui.swatchOptions", json!({"name": "Bright Blue"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#000000"));
dialog_frame(&mut app, &ctx);
assert_ne!(app.session.doc().unwrap().doc, before, "previewed");
crate::dialogs::cancel(&mut app);
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
assert_eq!(app.session.doc().unwrap().doc, before);
// Gradients open the Gradient panel, patterns pattern editing, None nothing.
double_click_tile(&mut app, &ctx, "Sunset");
assert!(app.ui.dialog.is_none());
assert_eq!(app.ui.open_panel.as_deref(), Some("gradient"));
assert!(app.run("ui.swatchOptions", json!({"name": "[None]"})).is_err());
assert!(app.run("ui.swatchOptions", json!({})).is_err());
}
#[test]
fn modifier_clicks_select_several_swatches_and_groups() {
let mut app = app();
let ctx = context();
click_tile(&mut app, &ctx, "Red", Modifiers::NONE, 0.0);
assert_eq!(selected(&ctx), ["Red"]);
assert_eq!(app.session.paint.fill.color().map(|c| c.to_hex()), Some("#ed1c24".into()), "a plain click applies");
click_tile(&mut app, &ctx, "Amber", Modifiers::SHIFT, 2.0);
assert_eq!(selected(&ctx), ["Red", "Orange Red", "Orange", "Amber"], "Shift extends over the shown order");
click_tile(&mut app, &ctx, "Orange", Modifiers::COMMAND, 4.0);
click_tile(&mut app, &ctx, "Brights", Modifiers::COMMAND, 6.0);
assert_eq!(selected(&ctx), ["Red", "Orange Red", "Amber", "Brights"], "Cmd toggles swatches and colour groups");
assert_eq!(app.session.paint.fill.color().map(|c| c.to_hex()), Some("#ed1c24".into()), "modifier clicks only select");
click_tile(&mut app, &ctx, "Grays", Modifiers::NONE, 8.0);
assert_eq!(selected(&ctx), ["Grays"], "a plain click on a group selects it alone");
}
#[test]
fn delete_asks_then_removes_the_selection_and_unlinks_art() {
let mut app = app();
let ctx = context();
app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [id], "swatch": "Red"})).unwrap();
delete(&mut app, vec!["Red".into(), "Brights".into()], false);
let d = app.ui.dialog.as_ref().expect("delete asks first");
assert_eq!((d.kind.as_str(), d.str("message").as_str()), (crate::dialogs::confirm::KIND, "Delete these 2 swatches and groups?"));
dialog_frame(&mut app, &ctx);
assert!(app.session.doc().unwrap().doc.swatch("Red").is_some(), "nothing is deleted before OK");
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let doc = &app.session.doc().unwrap().doc;
assert!(doc.swatch("Red").is_none() && doc.swatch("Bright Red").is_none() && doc.swatch_groups.iter().all(|g| g.name != "Brights"));
let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
assert_eq!(fill, Paint::solid(Color::from_hex("#ed1c24").unwrap()), "the art keeps its colour, unlinked");
// Cancel keeps the swatch; Alt-click (now) deletes without asking.
delete(&mut app, vec!["Orange".into()], false);
assert_eq!(app.ui.dialog.as_ref().unwrap().str("message"), "Delete “Orange”?");
crate::dialogs::cancel(&mut app);
assert!(app.session.doc().unwrap().doc.swatch("Orange").is_some());
delete(&mut app, vec!["Orange".into()], true);
assert!(app.ui.dialog.is_none() && app.session.doc().unwrap().doc.swatch("Orange").is_none());
assert_eq!(deletable(&["[None]".into(), REGISTRATION.into(), "Grays".into()]), ["Grays"]);
}
#[test]
fn new_swatch_dialog_prefills_the_active_colour_and_saves_into_the_selected_group() {
let mut app = app();
let ctx = context();
app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap();
new_swatch(&mut app, &["Bright Red".into()], true, false);
let d = app.ui.dialog.as_ref().expect("New Swatch opens its dialog");
assert_eq!(
(d.kind.as_str(), d.str("name").as_str(), d.str("group").as_str()),
(crate::dialogs::new_swatch::KIND, "R=255 G=128 B=0", "Brights")
);
assert!(d.bool("spot") && d.bool("global"), "Cmd/Ctrl-click makes a spot colour");
dialog_frame(&mut app, &ctx);
crate::dialogs::confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
let w = doc.swatch("R=255 G=128 B=0").expect("created");
assert!(w.spot && doc.swatch_group_of(&w.name).is_some_and(|g| doc.swatch_groups[g].name == "Brights"));
// Alt-click saves at once; a gradient takes only a name and stays out of colour groups.
app.run("paint.setFill", json!({"swatch": "Sunset"})).unwrap();
new_swatch(&mut app, &["Brights".into()], false, true);
assert!(app.ui.dialog.is_none() && app.session.doc().unwrap().doc.swatch("New Gradient Swatch 1").is_some());
new_swatch(&mut app, &[], false, false);
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("name").as_str(), d.fields.contains_key("__paint")), ("New Gradient Swatch 2", true));
dialog_frame(&mut app, &ctx);
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.session.doc().unwrap().doc.swatch("New Gradient Swatch 2").is_some_and(|w| matches!(w.paint, Paint::Gradient(_))));
}
#[test]
fn new_color_group_dialog_makes_a_group_from_swatches_or_artwork() {
let mut app = app();
let ctx = context();
app.run("ui.newColorGroup", json!({"swatches": ["Red", "Sunset"]})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.kind.as_str(), d.str("name").as_str(), d.bool("fromArtwork")), (crate::dialogs::new_color_group::KIND, "Color Group", false));
dialog_frame(&mut app, &ctx);
crate::dialogs::confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
let g = doc.swatch_groups.iter().find(|g| g.name == "Color Group").expect("group made");
assert_eq!(g.swatches.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(), ["Red"], "gradients stay out");
// With art selected (and no swatches) it starts from the artwork: its colours become global.
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [id], "color": "#123456"})).unwrap();
app.run("paint.setStroke", json!({"ids": [id], "none": true})).unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
app.run("ui.newColorGroup", json!({})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("name").as_str(), d.bool("fromArtwork"), d.bool("toGlobal")), ("Color Group 2", true, true));
dialog_frame(&mut app, &ctx);
crate::dialogs::confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
let g = doc.swatch_groups.iter().find(|g| g.name == "Color Group 2").expect("group made");
assert_eq!(g.swatches.iter().map(|w| (w.name.as_str(), w.global)).collect::<Vec<_>>(), [("R=18 G=52 B=86", true)]);
let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()) });
}
#[test]
fn kind_filter() {

View File

@ -356,35 +356,48 @@ pub fn link_label(ui: &mut Ui, text: &str) -> Response {
resp
}
/// Illustrator-style checkbox with a disabled state. Returns true when toggled.
pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool {
/// The row of a checkbox or radio button: allocates a 13 pt box plus `label`, draws the label and
/// returns the box, the response and the box's border colour.
fn choice_row(ui: &mut Ui, label: &str, enabled: bool) -> (Rect, Response, Color32) {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled });
let (rect, resp) =
ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), if enabled { Sense::click() } else { Sense::hover() });
let bx = Rect::from_min_size(pos2(rect.left(), rect.center().y - 6.5), Vec2::splat(13.0));
ui.painter().galley(pos2(bx.right() + 5.0, rect.center().y - galley.size().y / 2.0), galley, t.text);
let border = if !enabled {
t.divider
} else if resp.hovered() {
t.text
} else {
t.button_border
};
(bx, resp, border)
}
/// Panel-style checkbox with a disabled state. Returns true when toggled.
pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (bx, resp, border) = choice_row(ui, label, enabled);
ui.painter().rect_filled(bx, CornerRadius::same(2), if value && enabled { t.accent_strong } else { t.input });
ui.painter().rect_stroke(
bx,
CornerRadius::same(2),
Stroke::new(
1.0,
if !enabled {
t.divider
} else if resp.hovered() {
t.text
} else {
t.button_border
},
),
StrokeKind::Inside,
);
ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside);
if value {
let c = if enabled { Color32::WHITE } else { t.text_disabled };
ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c));
ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c));
}
ui.painter().galley(pos2(bx.right() + 5.0, rect.center().y - galley.size().y / 2.0), galley, t.text);
enabled && resp.clicked()
}
/// Radio button in the style of [`check`], with a disabled state. Returns true when clicked.
pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (bx, resp, border) = choice_row(ui, label, enabled);
let c = bx.center();
ui.painter().circle(c, 6.0, if selected && enabled { t.accent_strong } else { t.input }, Stroke::new(1.0, border));
if selected {
ui.painter().circle_filled(c, 2.5, if enabled { Color32::WHITE } else { t.text_disabled });
}
enabled && resp.clicked()
}

View File

@ -19,3 +19,17 @@
| `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) |
| `ui.resize` / `ui.focus` | | |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,
`preview`), which previews on the canvas while open; `ui.dialog.confirm` applies it with `swatch.edit` as one undo
step and `ui.dialog.cancel` rolls the preview back. Gradient swatches open the Gradient panel and pattern swatches
pattern editing instead.
Confirmations: deleting swatches from the Swatches panel opens a `confirm` dialog (fields `message`, `detail`);
`ui.dialog.confirm` runs the command it asks about (here `swatch.delete`) and `ui.dialog.cancel` drops it.
New swatches and colour groups: `ui.newSwatch {spot?, group?}` opens the `newSwatch` dialog prefilled from the active
fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern only takes `name`) and
`ui.newColorGroup {swatches?}` opens `newColorGroup` (fields `name`, `fromArtwork`, `toGlobal`, `includeTints`,
`swatches`); `ui.dialog.confirm` runs `swatch.new` / `swatch.newGroup`.

View File

@ -71,6 +71,14 @@ or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"
Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with
`isError: true` and a message. The model can read the message and retry.
Swatches go through `run_command`: `swatch.list` lists every swatch with its colour group, kind and colour;
`swatch.edit {name, newName?, color?, mode?, global?, spot?}` edits one (fills, strokes and text linked to a
global or spot swatch follow its colour and name, as one undo step); `swatch.delete {names, unlink?}` deletes
swatches and colour groups in one undo step (art using a deleted global swatch keeps its colour, unlinked).
`swatch.new {color?, mode?, spot?, global?, group?}` saves a colour (into a colour group with `group`), gradient
or pattern; `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected
art's colours (by default as global swatches the art links to).
## Resources
| URI | Content |