mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
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:
co-authored by
Claude Opus 5.5
parent
fca068a0a7
commit
fcc0f4ca0f
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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(®).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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user