M3.10: swatch names follow the colour model (C=10 M=20 Y=30 K=0, R=.. G=.. B=.., Gray K=..) and are unique document-wide; New Swatch saves the active gradient or pattern, Ctrl/Cmd-click makes a spot colour; colour groups take solid colours only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 12:20:52 +07:00
parent 00a0874d4d
commit 2f21fc3a66
4 changed files with 119 additions and 33 deletions

View File

@ -3,6 +3,7 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup};
use vectorcraft_doc::Document;
use vectorcraft_doc::pattern::pattern_paint;
use super::paint::paint_from;
use super::*;
@ -10,14 +11,22 @@ 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.new",
"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}",
has_doc,
swatch_new
),
cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", 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)} → {name}",
has_doc,
swatch_new_group
),
@ -27,21 +36,29 @@ pub fn specs() -> Vec<CommandSpec> {
}
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 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::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")),
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 = str_param(p, "name").map(str::to_string);
let name = s.edit("New Swatch", |d, _| {
let name = match requested {
Some(n) => free_name(d, &n),
None => default_name(d, &paint),
};
d.swatches.push(Swatch { name: name.clone(), paint, global, spot });
Ok(name)
})?;
Ok(json!({ "name": name }))
}
@ -58,15 +75,30 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// 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())
/// 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)),
}
}
/// 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(n).is_some() || d.swatch_groups.iter().any(|g| g.name == n))
unique_name(base, |n| d.swatch_name_taken(n))
}
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
@ -77,13 +109,17 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
let name = s.edit("New Color Group", |d, _| {
let name = free_name(d, 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 });
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 });
}
d.swatch_groups.push(group);
Ok(name)

View File

@ -74,3 +74,53 @@ fn document_info_counts_grouped_swatches() {
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");
}

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

@ -370,8 +370,9 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) {
let names: Vec<String> = deletable(ui).into_iter().collect();
app.run("swatch.newGroup", json!({"swatches": names})).ok();
}
// Ctrl/Cmd-click makes a spot colour (Alt-click skips the dialog).
if widgets::icon_button(ui, "dc-new-item", "New Swatch", false, 24.0).clicked() {
new_swatch(app);
new_swatch(app, ui.input(|i| i.modifiers.command));
}
let del = deletable(ui);
if widgets::icon_button_enabled(ui, "trash-2", "Delete Swatch", false, del.is_some(), 24.0).clicked()
@ -383,12 +384,10 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) {
});
}
fn new_swatch(app: &mut VectorcraftApp) {
let p = active_paint(app);
let params = match &p {
Paint::None => json!({}),
other => super::paint_params(other),
};
/// Save the active paint as a new swatch (a spot colour with `spot`).
fn new_swatch(app: &mut VectorcraftApp, spot: bool) {
let mut params = super::paint_params(&active_paint(app));
params["spot"] = json!(spot);
app.run("swatch.new", params).ok();
}
@ -396,7 +395,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let view: View = pstate(ui.ctx(), "swatch-view");
let sel = deletable(ui);
if menu_item(ui, "New Swatch…", true, false) {
new_swatch(app);
new_swatch(app, false);
}
if menu_item(ui, "New Color Group…", true, false) {
app.run("swatch.newGroup", json!({"swatches": sel.clone().into_iter().collect::<Vec<_>>()})).ok();