M3.79: Edit or Apply Color Group opens Recolor Artwork on a colour group

Double-clicking a colour group's folder in the Swatches panel, or the options
button (Edit or Apply Color Group) while a group is selected, opens Recolor
Artwork on the group: without art the rows are the group's colours and OK
rewrites the group; with art selected the art is reduced to the group's size,
takes its colours, and art and group change in one undo step. New
swatch.editGroup {group, colors, rename?} rewrites a group in order (art linked
to its global swatches follows); recolor.apply takes group, groupColors and
rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 04:20:27 +07:00
co-authored by Claude Opus 5.5
parent fca068a0a7
commit fcc0f4ca0f
9 changed files with 401 additions and 57 deletions
+25 -3
View File
@@ -30,8 +30,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library (new colours snap to its nearest colour), includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) as one undo step → {changed}",
has_selection,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library (new colours snap to its nearest colour), group?: colour group rewritten with groupColors?: [colour] (default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) and the group, as one undo step → {changed}",
has_doc,
apply
),
cmd!(
@@ -305,10 +305,32 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let ids = s.doc()?.selection.objects.clone();
let group = str_param(p, "group");
if ids.is_empty() && group.is_none() {
return Err(bad(C, "select artwork to recolour, or give a colour `group`"));
}
let map = ColorMap::new(&rows, method);
let f = |c: Color| map.map(c);
let scope = Scope { fill: true, stroke: true, ..Scope::of(p) };
let changed = s.edit("Recolor Artwork", |d, _| Ok(Recolor::new(scope, &f).run(d, &ids)))?;
let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" };
let new: Vec<Color> = match p.get("groupColors").and_then(Value::as_array) {
Some(cs) => cs
.iter()
.map(|v| {
color_value(v).map(|c| palette.as_ref().map_or(c, |pl| pl.nearest(c))).ok_or_else(|| bad(C, format!("bad colour {v} in groupColors")))
})
.collect::<Result<_>>()?,
None => rows.iter().filter_map(Row::new_color).collect(),
};
let rename = str_param(p, "rename");
let (changed, edit) = s.edit(label, |d, _| {
let changed = Recolor::new(scope, &f).run(d, &ids);
let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?;
Ok((changed, edit))
})?;
if let Some(e) = edit {
e.defaults(s);
}
Ok(json!({ "changed": changed }))
}
+84
View File
@@ -122,6 +122,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
swatch_spot_options
),
cmd!(
"swatch.editGroup",
"Edit Color Group",
["Window", "Swatches"],
None,
"{group: colour group, colors: [colour] (hex, key, {c,m,y,k} or {l,a,b}; the group's swatches take them in order, extra colours become new swatches, swatches past the end are removed; built-in swatches can't change), rename?: new group name} rewrite a colour group as one undo step; art linked to its global swatches takes their new colours (and keeps its colour, unlinked, when its swatch goes) → {name, swatches: [names], relinked}",
has_doc,
swatch_edit_group
),
]
}
@@ -770,3 +779,78 @@ fn follow_bases(bases: &[(String, Color)]) -> impl FnMut(&mut Color, &mut Option
std::mem::replace(c, new) != new
}
}
/// A colour group rewrite: its name afterwards and the swatch links it changed.
pub(super) struct GroupEdit {
name: String,
swatches: Vec<String>,
relinks: Vec<Relink>,
relinked: usize,
}
impl GroupEdit {
/// Apply the link changes to the default fill and stroke for new art.
pub(super) fn defaults(&self, s: &mut Session) {
self.relinks.iter().for_each(|r| r.defaults(s));
}
}
/// Rewrite colour group `group` with `colors`: its swatches take them in order (keeping their
/// names, kinds and global or spot flags), extra colours are added as new swatches and swatches
/// past the end are removed. Paints linked to its global swatches follow (a removed swatch's are
/// unlinked); `rename` renames the group. A built-in swatch (None, Registration) it would change or
/// remove is refused.
pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], rename: Option<&str>, cmd: &str) -> Result<GroupEdit> {
let gi = d.swatch_groups.iter().position(|g| g.name == group).ok_or_else(|| bad(cmd, format!("no colour group `{group}`")))?;
let name = match rename.map(str::trim).filter(|n| !n.is_empty() && *n != group) {
Some(n) => d.free_swatch_name(n),
None => group.to_string(),
};
let old = &d.swatch_groups[gi].swatches;
if let Some(w) = old.iter().enumerate().find(|(i, w)| w.is_reserved() && colors.get(*i).is_none_or(|c| w.paint.color() != Some(*c))).map(|x| x.1)
{
return Err(bad(cmd, format!("`{}` can't be edited or removed", w.name)));
}
let mut relinks = vec![];
let mut swatches = std::mem::take(&mut d.swatch_groups[gi].swatches);
for gone in swatches.split_off(colors.len().min(swatches.len())) {
if gone.global {
relinks.push(Relink { from: gone.name.clone(), to: gone.name, color: None, keep: false });
}
}
for (w, c) in swatches.iter_mut().zip(colors) {
if w.paint.color() != Some(*c) {
// A tint swatch becomes a colour of its own.
w.paint = Paint::solid(*c);
if w.global {
// Art linked to a Lab spot shows it as the Spot Colors options say.
relinks.push(Relink { from: w.name.clone(), to: w.name.clone(), color: Some(d.linked_color(*c, w.spot)), keep: true });
}
}
}
for c in colors.iter().skip(swatches.len()) {
let nm = unique_name(&color_name(*c), |n| n == name || d.swatch_name_taken(n) || swatches.iter().any(|w| w.name == n));
swatches.push(Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false });
}
let g = &mut d.swatch_groups[gi];
(g.name, g.swatches) = (name.clone(), swatches);
let names = g.swatches.iter().map(|w| w.name.clone()).collect();
let relinked = if relinks.is_empty() { 0 } else { d.map_solid_paints(&mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) };
Ok(GroupEdit { name, swatches: names, relinks, relinked })
}
fn swatch_edit_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.editGroup";
let group = str_param(p, "group").ok_or_else(|| bad(C, "missing `group`"))?;
let colors: Vec<Color> = p
.get("colors")
.and_then(Value::as_array)
.ok_or_else(|| bad(C, "missing `colors`"))?
.iter()
.map(|v| color_value(v).ok_or_else(|| bad(C, format!("bad colour {v}"))))
.collect::<Result<_>>()?;
let rename = str_param(p, "rename");
let e = s.edit("Edit Color Group", |d, _| rewrite_group(d, group, &colors, rename, C))?;
e.defaults(s);
Ok(json!({"name": e.name, "swatches": e.swatches, "relinked": e.relinked}))
}
+82 -1
View File
@@ -1,5 +1,5 @@
//! Recolor Artwork (M3.78): colour reduction, preserve rules, recolour methods, Limit to Library,
//! randomize.
//! randomize; Edit Color Group (M3.79).
use serde_json::{Value, json};
use vectorcraft_color::Color;
@@ -200,6 +200,8 @@ fn methods_and_bad_params() {
assert!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "#00ff00"}, "method": "nope"})).is_err());
assert!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "nonsense"}})).is_err());
assert!(s.execute("recolor.apply", &json!({})).is_err());
run(&mut s, "select.none", json!({}));
assert!(s.execute("recolor.apply", &json!({"map": {}})).is_err(), "needs art or a group");
}
#[test]
@@ -222,3 +224,82 @@ fn randomize_shuffles_new_colours_and_skips_excluded_rows() {
let hue = |v: &Value| color_value(v).unwrap().to_hsb()[0];
assert!((hue(&sb["map"][0]["to"]) - 0.0).abs() < 1.0 && (hue(&sb["map"][1]["to"]) - 120.0).abs() < 1.0, "hue kept");
}
#[test]
fn edit_group_rewrites_in_order_as_one_undo_step() {
let mut s = session();
let g = run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["#ff0000", "#00ff00", "#0000ff"]}));
let names = keys(&g["swatches"]);
let group = |s: &Session| {
let d = &s.doc().unwrap().doc;
let g = d.swatch_groups.iter().find(|g| g.name == "Theme" || g.name == "Brand Theme").unwrap();
(g.name.clone(), g.swatches.iter().map(|w| (w.name.clone(), w.paint.color().unwrap().to_hex())).collect::<Vec<_>>())
};
let before = group(&s);
let r = run(
&mut s,
"swatch.editGroup",
json!({"group": "Theme", "colors": ["#111111", "cmyk 0 0 0 50", "#333333", "#444444"], "rename": "Brand Theme"}),
);
assert_eq!(r["name"], "Brand Theme");
let (name, sw) = group(&s);
assert_eq!(name, "Brand Theme");
assert_eq!(sw.iter().map(|w| w.1.as_str()).collect::<Vec<_>>()[..1], ["#111111"]);
assert_eq!(sw.len(), 4, "an extra colour becomes a new swatch");
assert_eq!(sw[..3].iter().map(|w| w.0.clone()).collect::<Vec<_>>(), names, "the swatches keep their names, in order");
assert!(matches!(s.doc().unwrap().doc.swatch(&names[1]).unwrap().paint.color(), Some(Color::Cmyk { .. })));
run(&mut s, "edit.undo", json!({}));
assert_eq!(group(&s), before, "one undo step");
// Fewer colours drop the swatches past the end.
run(&mut s, "swatch.editGroup", json!({"group": "Theme", "colors": ["#ababab"]}));
assert_eq!(group(&s).1.len(), 1);
assert!(s.execute("swatch.editGroup", &json!({"group": "Nope", "colors": []})).is_err());
}
#[test]
fn edit_group_relinks_art_and_apply_rewrites_the_group_with_the_art() {
let mut s = session();
run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["#ff0000"]}));
let name = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap().swatches[0].name.clone();
run(&mut s, "swatch.edit", json!({"name": name, "global": true}));
let linked = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [linked.0], "swatch": name}));
let r = run(&mut s, "swatch.editGroup", json!({"group": "Theme", "colors": ["#00ff00"]}));
assert_eq!(r["relinked"], 1);
assert_eq!(fill(&s, linked).to_hex(), "#00ff00", "linked art follows its swatch");
// Recolor with a group: the art and the group change in one undo step.
let plain = rect(&mut s, json!("#0000ff"));
run(&mut s, "select.set", json!({"ids": [plain.0]}));
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, "recolor.apply", json!({"map": [{"from": ["#0000ff"], "to": "#ffff00"}], "group": "Theme"}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(fill(&s, plain).to_hex(), "#ffff00");
assert_eq!(s.doc().unwrap().doc.swatch(&name).unwrap().paint.color().unwrap().to_hex(), "#ffff00");
// Without a selection, only the group.
run(&mut s, "select.none", json!({}));
run(&mut s, "recolor.apply", json!({"map": [{"from": ["#ffff00"], "to": "#ff00ff"}], "group": "Theme"}));
assert_eq!(s.doc().unwrap().doc.swatch(&name).unwrap().paint.color().unwrap().to_hex(), "#ff00ff");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Edit Color Group");
// Explicit group colours.
run(&mut s, "recolor.apply", json!({"map": [], "group": "Theme", "groupColors": ["#010203", "cmyk 0 0 0 20"]}));
let g = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap().clone();
assert_eq!(g.swatches.iter().map(|w| w.paint.color().unwrap()).collect::<Vec<_>>(), [Color::rgb8(1, 2, 3), Color::cmyk(0.0, 0.0, 0.0, 0.2)]);
}
#[test]
fn edit_group_refuses_to_change_built_in_swatches() {
let mut s = session();
run(&mut s, "swatch.newGroup", json!({"name": "Marks", "colors": ["#ff0000"]}));
// A group holding the built-in Registration swatch (as an older file might).
s.edit("test", |d, _| {
d.swatch_groups.iter_mut().find(|g| g.name == "Marks").unwrap().swatches.insert(0, vectorcraft_color::swatch::registration().clone());
Ok(())
})
.unwrap();
let reg = vectorcraft_color::swatch::registration().paint.color().unwrap();
assert!(s.execute("swatch.editGroup", &json!({"group": "Marks", "colors": ["#00ff00", "#0000ff"]})).is_err(), "changed");
assert!(s.execute("swatch.editGroup", &json!({"group": "Marks", "colors": []})).is_err(), "removed");
let key = vectorcraft_color::recolor::ColorKey::of(&reg).to_string();
let ok = run(&mut s, "swatch.editGroup", json!({"group": "Marks", "colors": [key, "#0000ff"]}));
assert_eq!(ok["swatches"][0], vectorcraft_color::swatch::REGISTRATION, "kept as it is");
}