M3.13: New Swatch and New Color Group dialogs — New Swatch reuses the Swatch Options fields prefilled from the active colour (its default name follows the colour), goes into the selected colour group, Alt-click skips it and Cmd/Ctrl makes a spot colour; New Color Group makes a group from the selected swatches or the selected artwork's colours, optionally as global swatches the art links to and with swatches for tints

swatch.new takes mode and group; swatch.newGroup takes fromArtwork, toGlobal and includeTints and reports the group's swatches and the paints it linked. Adds a shared radio widget and the ui.newSwatch / ui.newColorGroup openers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-04 17:26:57 +07:00
co-authored by Claude Opus 5.5
parent 3082be6ecf
commit 7da0bfb1c6
12 changed files with 529 additions and 82 deletions
+85 -41
View File
@@ -2,8 +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::*;
@@ -16,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Swatch",
["Window", "Swatches"],
None,
"{name?, color? | swatch? | gradient? | pattern?: name (default: the current fill), global?, spot? (a spot colour, always global)} 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}",
"{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
),
@@ -34,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Color Group",
["Window", "Swatches"],
None,
"{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches)} → {name}",
"{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 and gradient stops; 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
),
@@ -70,20 +71,31 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
};
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(color),
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 => return Err(bad(C, "only solid colours can be spot colours")),
_ if spot || mode.is_some() || group.is_some() => return Err(bad(C, "spot, mode and group apply to solid colours only")),
other => other,
};
let requested = name_param(p, "name");
let name = s.edit("New Swatch", |d, _| {
let name = match requested {
Some(n) => free_name(d, &n),
None => default_name(d, &paint),
Some(n) => d.free_swatch_name(&n),
None => d.new_swatch_name(&paint),
};
d.swatches.push(Swatch { name: name.clone(), paint, global, spot });
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 }))
@@ -128,38 +140,47 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"deleted": names, "unlinked": unlinked}))
}
/// The default name of a new solid swatch: its values in its own colour model.
fn color_name(c: Color) -> String {
let pct = |v: f32| (v * 100.0).round();
let byte = |v: f32| (v * 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)),
/// 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);
}
});
}
}
/// A free default name for a new swatch of `paint`: its colour values, or "New Gradient Swatch 1"…
fn default_name(d: &Document, paint: &Paint) -> String {
let base = match paint {
Paint::Solid { color, .. } => return free_name(d, &color_name(*color)),
Paint::Gradient(_) => "New Gradient Swatch",
_ => "New Pattern Swatch",
};
(1..).map(|i| format!("{base} {i}")).find(|n| !d.swatch_name_taken(n)).unwrap_or_else(|| base.to_string())
}
/// 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_name_taken(n))
}
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
let names = str_list(p, "swatches");
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 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 = 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 (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 {
@@ -169,21 +190,44 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
group.swatches.push(sw);
}
}
for c in &colors {
let nm = unique_name(&color_name(*c), |n| d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n));
group.swatches.push(Swatch { name: nm, 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| 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);
@@ -297,7 +341,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
}
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| free_name(d, &n));
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}`")))?;
+76 -6
View File
@@ -1,5 +1,6 @@
//! 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 and new groups.
//! 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};
@@ -24,12 +25,9 @@ fn doc(s: &Session) -> &vectorcraft_doc::Document {
&s.doc().unwrap().doc
}
/// Put a solid swatch into colour group `group` directly in the model (no command creates one in a
/// group before M3.13).
/// Add a global solid swatch to colour group `group`.
fn grouped_swatch(s: &mut Session, group: &str, name: &str, color: Color) {
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let g = d.swatch_groups.iter_mut().find(|g| g.name == group).unwrap();
g.swatches.push(vectorcraft_color::Swatch { name: name.into(), paint: Paint::solid(color), global: true, spot: false });
assert_eq!(run(s, "swatch.new", json!({"name": name, "color": color, "global": true, "group": group}))["name"], name);
}
#[test]
@@ -254,3 +252,75 @@ fn deleting_several_swatches_and_groups() {
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");
}