From 2f21fc3a66800cd5587f0580e5aeca5385fe03bc Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:20:52 +0700 Subject: [PATCH] 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 --- crates/engine/src/cmd/swatch.rs | 82 +++++++++++++++++++-------- crates/engine/src/tests_swatches.rs | 50 ++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 5 +- crates/ui-egui/src/panels/swatches.rs | 15 +++-- 4 files changed, 119 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 7b5793d9..84a509d3 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -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 { 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 { } fn swatch_new(s: &mut Session, p: &Value) -> Result { - 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 { 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 { @@ -77,13 +109,17 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { 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) diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs index 2d068906..f54722d6 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests_swatches.rs @@ -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(©).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"); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0b163e51..b6f319a3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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::>() }}), - 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"})); } } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 2320f8f7..e190419a 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -370,8 +370,9 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) { let names: Vec = 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::>()})).ok();