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 03:17:07 +07:00
parent fca068a0a7
commit fcc0f4ca0f
9 changed files with 401 additions and 57 deletions

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 }))
}

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}))
}

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");
}

View File

@ -7,12 +7,13 @@
//! saturation and brightness, Reset, the Method and Limit to Library. Edit tab: a hue and
//! saturation wheel with a marker per new colour (drag one; with linked handles they all turn and
//! scale together), a brightness slider and the harmony rules. The preset menu sets up a 1, 2 or 3
//! colour job or a library.
//! colour job or a library. Opened on a colour group (Edit or Apply Color Group), OK also rewrites
//! the group with the new colours (renamed by `groupName`); without art only the group changes.
//!
//! Fields (`ui.dialog.set`): `rows` ([{from: [keys], to: key, exclude?}]), `colors` (null: Auto, or a
//! count), `method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo` (library id, "" for
//! none), `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`. Changing `colors`,
//! a preserve flag or `limitTo` reduces the rows again.
//! none), `group`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`,
//! `preview`. Changing `colors`, a preserve flag or `limitTo` reduces the rows again.
use egui::{Pos2, Rect, Sense, Ui, pos2, vec2};
use serde_json::{Value, json};
@ -44,17 +45,28 @@ const TABLE_H: f32 = 220.0;
const TAB_H: f32 = 400.0;
const PRESETS: [&str; 5] = ["Custom", "1 Color Job", "2 Color Job", "3 Color Job", "Color Library"];
/// Open Recolor Artwork (`ui.recolorDialog {colors?, library?}`) on the selected art. `colors` is
/// a count (an n-colour job) or the new colours to assign; `library` limits to a library ("" picks
/// Open Recolor Artwork (`ui.recolorDialog {colors?, library?, group?}`) on the selected art and,
/// with `group`, on that colour group: art is reduced to as many rows as the group has colours,
/// which become their new colours; without art the rows are the group's colours. `colors` is a
/// count (an n-colour job) or the new colours to assign; `library` limits to a library ("" picks
/// the first).
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let art = !st.selection.is_empty();
if !art {
return Err("select artwork to recolor".into());
let group = p.get("group").and_then(Value::as_str);
let group_colors: Option<Vec<Color>> = match group {
Some(g) => {
let g = st.doc.swatch_groups.iter().find(|x| x.name == g).ok_or_else(|| format!("no colour group `{g}`"))?;
Some(g.swatches.iter().filter_map(|w| w.paint.color()).collect())
}
None => None,
};
if !art && group.is_none() {
return Err("select artwork (or a colour group) to recolor".into());
}
let assign: Vec<Value> = match p.get("colors") {
Some(Value::Array(cs)) => cs.iter().filter_map(color_value).map(|c| key(&c)).collect(),
let assign: Vec<Value> = match (&group_colors, p.get("colors")) {
(Some(cs), _) => cs.iter().map(key).collect(),
(None, Some(Value::Array(cs))) => cs.iter().filter_map(color_value).map(|c| key(&c)).collect(),
_ => vec![],
};
let count = match p.get("colors").and_then(Value::as_u64) {
@ -67,10 +79,12 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
Some(l) => swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}`"))?,
None => String::new(),
};
// Without art the rows are the group's colours, as they are.
let rows: Vec<Value> = if art { vec![] } else { assign.iter().map(|k| json!({"from": [k], "to": k})).collect() };
let fields = json!({
"rows": [], "colors": count, "method": Method::ScaleTints.id(),
"rows": rows, "colors": count, "method": Method::ScaleTints.id(),
"preserveWhite": true, "preserveBlack": true, "preserveGrays": false, "limitTo": limit,
"tab": "assign", "rule": Harmony::Complementary.id(), "linked": true,
"group": group, "groupName": group, "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true,
"preview": true, "__art": art, "__assign": assign,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
@ -100,7 +114,7 @@ fn reduce_params(d: &Dialog) -> Value {
}
/// Reduce the art's colours into rows again when the count, preserve flags or library changed
/// (assigning the opener's colours as the new colours).
/// (assigning a group's colours or the opener's colours as the new colours).
fn sync(app: &mut VectorcraftApp, d: &mut Dialog) {
const DONE: &str = "__reduced";
let sig = reduce_params(d);
@ -156,9 +170,23 @@ fn selection(d: &Dialog) -> Vec<usize> {
d.fields.get("__sel").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default()
}
/// `recolor.apply` parameters: the rows, method and library.
/// `recolor.apply` parameters: the rows, method, library and the group with its new name and
/// colours: the rows' new colours, then (with art) the group's colours no row took.
fn apply_params(d: &Dialog) -> Value {
json!({"map": rows(d), "method": d.str("method"), "limitTo": d.str("limitTo")})
let rs = rows(d);
let group = d.fields.get("group").and_then(Value::as_str).map(|g| {
let rest =
d.fields.get("__assign").and_then(Value::as_array).filter(|_| d.bool("__art")).map_or(&[][..], |a| a.get(rs.len()..).unwrap_or_default());
let colors: Vec<&Value> = rs.iter().map(|r| &r["to"]).chain(rest).collect();
let name = d.str("groupName");
let rename = (!name.trim().is_empty() && name != g).then_some(name);
json!({"group": g, "groupColors": colors, "rename": rename})
});
let mut p = json!({"map": rs, "method": d.str("method"), "limitTo": d.str("limitTo")});
if let (Some(Value::Object(g)), Some(p)) = (group, p.as_object_mut()) {
p.extend(g.into_iter().filter(|(_, v)| !v.is_null()));
}
p
}
fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
@ -188,6 +216,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
snap(app, d, &mut rs);
d.fields.insert("rows".into(), Value::Array(rs));
}
if d.fields.get("group").is_some_and(Value::is_string) {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Color Group:").color(t.text_dim));
form::text(ui, d, "groupName", 200.0);
});
}
sync(app, d);
if d.bool("__art") {
form::preview(app, ui, d, "Recolor Artwork", CMD, apply_params(d));
@ -195,7 +230,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
false
}
/// OK: keep the previewed recolour as one undo step.
/// OK: keep the previewed recolour as one undo step (or apply it, e.g. to a colour group only).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut d = d.clone();
sync(app, &mut d);
@ -237,24 +272,27 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
let t = Tokens::get(ui.ctx());
let mut changed = false;
let total: usize = rows.iter().map(|r| r["from"].as_array().map_or(0, Vec::len)).sum();
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Colors:").color(t.text_dim));
let labels: Vec<String> = std::iter::once("Auto".to_string()).chain((1..=total).map(|n| n.to_string())).collect();
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
let cur = d.fields.get("colors").and_then(Value::as_u64).map_or("Auto".to_string(), |n| n.to_string());
if let Some(i) = widgets::dropdown(ui, "recolor-count", &cur, &refs, 70.0) {
d.fields.insert("colors".into(), if i == 0 { Value::Null } else { json!(i) });
}
ui.add_space(18.0);
ui.label(egui::RichText::new("Preserve:").color(t.text_dim));
for (k, label) in [("preserveWhite", "White"), ("preserveBlack", "Black"), ("preserveGrays", "Grays")] {
let on = d.bool(k);
if widgets::check(ui, label, on, d.bool("__art")) {
d.fields.insert(k.into(), json!(!on));
// Reduction and the preserve rules apply to art (a colour group alone has a row per colour).
if d.bool("__art") {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Colors:").color(t.text_dim));
let labels: Vec<String> = std::iter::once("Auto".to_string()).chain((1..=total).map(|n| n.to_string())).collect();
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
let cur = d.fields.get("colors").and_then(Value::as_u64).map_or("Auto".to_string(), |n| n.to_string());
if let Some(i) = widgets::dropdown(ui, "recolor-count", &cur, &refs, 70.0) {
d.fields.insert("colors".into(), if i == 0 { Value::Null } else { json!(i) });
}
}
});
ui.add_space(6.0);
ui.add_space(18.0);
ui.label(egui::RichText::new("Preserve:").color(t.text_dim));
for (k, label) in [("preserveWhite", "White"), ("preserveBlack", "Black"), ("preserveGrays", "Grays")] {
let on = d.bool(k);
if widgets::check(ui, label, on, true) {
d.fields.insert(k.into(), json!(!on));
}
}
});
ui.add_space(6.0);
}
ui.horizontal(|ui| {
ui.add_space(6.0);
ui.add_sized([CURRENT_W, 16.0], egui::Label::new(egui::RichText::new(format!("Current Colors ({total})")).color(t.text_dim)));

View File

@ -162,7 +162,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.recolorDialog",
"Recolor Artwork…",
"",
"{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order), library?: id or name (Limit to Library; \"\" the first library)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)",
"{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order), library?: id or name (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)",
),
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
("file.export.pdf", "Save as PDF…", "", "{path?, artboard? | artboards? | range?: \"1-3, 5\"} (document.export options)"),

View File

@ -396,8 +396,9 @@ fn drag_of(e: &Entry, names: Vec<String>) -> PanelDrag {
#[derive(Default)]
struct TileEvents {
clicked: Option<(Entry, egui::Modifiers)>,
/// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing).
edit: Option<String>,
/// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing),
/// or Edit or Apply Color Group on a colour group.
edit: Option<Entry>,
drop: Option<Drop>,
/// A drag is held over a tile or row.
over: bool,
@ -408,8 +409,8 @@ struct TileEvents {
/// starts a [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held over it.
fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) {
let name = e.name();
if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() {
ev.edit = Some(name.to_string());
if matches!(e, Entry::Swatch { .. } | Entry::Folder(_)) && resp.double_clicked() {
ev.edit = Some(e.clone());
}
if resp.drag_started() {
let with_sel = sel.iter().any(|s| s == name);
@ -685,8 +686,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
let zone = ui.interact(out.inner_rect, ui.id().with("swatch-drop"), Sense::hover());
zone_input(ui, &zone, &mut ev);
});
if let Some(name) = ev.edit {
app.run("ui.swatchOptions", json!({"name": name})).ok();
match ev.edit {
Some(Entry::Folder(g)) => edit_group(app, &g),
Some(e) => {
app.run("ui.swatchOptions", json!({"name": e.name()})).ok();
}
None => {}
}
if let Some(drop) = ev.drop {
apply_drop(app, drop);
@ -815,6 +820,11 @@ fn selected_group(app: &VectorcraftApp, sel: &[String]) -> Option<String> {
sel.iter().find(|n| d.swatch_groups.iter().any(|g| g.name == **n)).cloned()
}
/// Edit or Apply Color Group: Recolor Artwork on colour group `group` (and the selected art).
fn edit_group(app: &mut VectorcraftApp, group: &str) {
app.run("ui.recolorDialog", json!({ "group": group })).ok();
}
/// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click).
fn delete(app: &mut VectorcraftApp, names: Vec<String>, now: bool) {
let params = json!({"names": names});
@ -846,8 +856,13 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) {
}
}
});
// With a colour group selected the options button edits or applies the group.
let opts = editable(app, sel);
if widgets::icon_button_enabled(ui, "dc-options", "Swatch Options", false, opts.is_some(), 24.0).clicked()
if let Some(g) = selected_group(app, sel).filter(|_| opts.is_none()) {
if widgets::icon_button(ui, "palette", "Edit or Apply Color Group", false, 24.0).clicked() {
edit_group(app, &g);
}
} else if widgets::icon_button_enabled(ui, "dc-options", "Swatch Options", false, opts.is_some(), 24.0).clicked()
&& let Some(n) = opts
{
app.run("ui.swatchOptions", json!({"name": n})).ok();
@ -917,6 +932,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
delete(app, del, false);
}
let group = selected_group(app, &selected);
if menu_item(ui, "Edit or Apply Color Group…", group.is_some(), false)
&& let Some(g) = &group
{
edit_group(app, g);
}
if menu_item(ui, "Ungroup Color Group", group.is_some(), false)
&& let Some(g) = group
{

View File

@ -1,10 +1,12 @@
//! Recolor Artwork (M3.78), drawn headlessly.
//! Recolor Artwork (M3.78) and Edit or Apply Color Group (M3.79), drawn headlessly.
use egui::{Event, Pos2, Rect, vec2};
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, vec2};
use serde_json::{Value, json};
use vectorcraft_color::Color;
use vectorcraft_color::recolor::ColorKey;
use vectorcraft_engine::Session;
use crate::{VectorcraftApp, dialogs};
use crate::{VectorcraftApp, dialogs, panels};
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@ -30,6 +32,11 @@ fn dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context) {
frame(app, ctx, vec![], time, |app, ui| dialogs::show(app, ui.ctx()));
}
fn press(at: Pos2, clicks: usize) -> Vec<Event> {
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
std::iter::once(Event::PointerMoved(at)).chain((0..clicks).flat_map(|_| [button(true), button(false)])).collect()
}
/// Rectangles filled with `colors`, all selected.
fn art(app: &mut VectorcraftApp, colors: &[&str]) {
for (i, c) in colors.iter().enumerate() {
@ -44,6 +51,12 @@ fn colors(app: &mut VectorcraftApp) -> usize {
app.session.execute("recolor.colors", &json!({})).unwrap()["colors"].as_array().unwrap().len()
}
fn group_keys(app: &VectorcraftApp, name: &str) -> Vec<Value> {
let d = &app.session.doc().unwrap().doc;
let g = d.swatch_groups.iter().find(|g| g.name == name).unwrap();
g.swatches.iter().filter_map(|w| w.paint.color()).map(|c| json!(ColorKey::of(&c).to_string())).collect()
}
fn field(app: &VectorcraftApp, k: &str) -> Value {
app.ui.dialog.as_ref().and_then(|d| d.fields.get(k).cloned()).unwrap_or(Value::Null)
}
@ -109,3 +122,83 @@ fn presets_open_colour_jobs_and_libraries_and_both_tabs_draw() {
assert_eq!(tos, [json!("rgb 18 52 86"), json!("rgb 171 205 239")]);
dialogs::cancel(&mut app);
}
#[test]
fn double_clicking_a_colour_group_opens_it_and_ok_rewrites_it() {
let mut app = app();
let ctx = context();
let show: fn(&mut VectorcraftApp, &mut egui::Ui) = panels::swatches::show;
frame(&mut app, &ctx, vec![], 0.0, show);
let folder = ctx.read_response(egui::Id::new(("swatch-tile", "Brights"))).expect("the Brights folder").rect.center();
frame(&mut app, &ctx, press(folder, 2), 1.0, show);
let d = app.ui.dialog.as_ref().expect("a dialog opened");
assert_eq!((d.kind.as_str(), d.str("group")), (dialogs::recolor::KIND, "Brights".to_string()));
let keys = group_keys(&app, "Brights");
let from: Vec<Value> = field(&app, "rows").as_array().unwrap().iter().map(|r| r["from"][0].clone()).collect();
assert_eq!(from, keys, "a row per group colour, in order");
dialog_frame(&mut app, &ctx);
// A new first colour and a new name; nothing is selected, so only the group changes.
let mut rows = field(&app, "rows");
rows[0]["to"] = json!("cmyk 100 0 0 0");
set(&mut app, "rows", rows);
set(&mut app, "groupName", json!("Brights 2026"));
let undo = app.session.doc().unwrap().history.undo.len();
dialogs::confirm(&mut app).unwrap();
let mut want = keys.clone();
want[0] = json!("cmyk 100 0 0 0");
assert_eq!(group_keys(&app, "Brights 2026"), want);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
}
#[test]
fn the_options_button_edits_a_selected_group_and_applies_it_to_the_art() {
let mut app = app();
let ctx = context();
art(&mut app, &["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff"]);
let show: fn(&mut VectorcraftApp, &mut egui::Ui) = panels::swatches::show;
frame(&mut app, &ctx, vec![], 0.0, show);
let folder = ctx.read_response(egui::Id::new(("swatch-tile", "Brights"))).unwrap().rect.center();
frame(&mut app, &ctx, press(folder, 1), 1.0, show);
frame(&mut app, &ctx, vec![], 2.0, show);
// The bottom bar's third button: Swatch Options, or Edit or Apply Color Group for a group.
let mut bar: Vec<Rect> = ctx.viewport(|vp| {
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(24.0, 24.0)).map(|w| w.rect).collect()
});
let bottom = bar.iter().map(|r| r.top()).fold(f32::MIN, f32::max);
bar.retain(|r| r.top() == bottom);
bar.sort_by(|a, b| a.left().total_cmp(&b.left()));
frame(&mut app, &ctx, press(bar[2].center(), 1), 3.0, show);
assert_eq!(field(&app, "group"), "Brights");
dialog_frame(&mut app, &ctx);
let group = group_keys(&app, "Brights");
let tos: Vec<Value> = field(&app, "rows").as_array().unwrap().iter().map(|r| r["to"].clone()).collect();
assert_eq!(tos, group, "the art's colours in as many rows as the group has, taking its colours");
dialogs::confirm(&mut app).unwrap();
let used: Vec<String> = app.session.execute("recolor.colors", &json!({})).unwrap()["colors"]
.as_array()
.unwrap()
.iter()
.map(|c| c["key"].as_str().unwrap().to_string())
.collect();
assert!(used.iter().all(|k| group.contains(&json!(k))), "the art uses the group's colours: {used:?}");
assert!(matches!(ColorKey::parse(&used[0]).unwrap().color(), Color::Rgb { .. }));
}
#[test]
fn a_group_keeps_the_colours_no_art_row_took() {
let mut app = app();
let ctx = context();
art(&mut app, &["#ff0000", "#0000ff"]);
let before = group_keys(&app, "Brights");
app.run("ui.recolorDialog", json!({"group": "Brights"})).unwrap();
dialog_frame(&mut app, &ctx);
assert_eq!(field(&app, "rows").as_array().unwrap().len(), 2, "two art colours");
let mut rows = field(&app, "rows");
rows[1]["to"] = json!("rgb 1 2 3");
set(&mut app, "rows", rows);
dialogs::confirm(&mut app).unwrap();
let after = group_keys(&app, "Brights");
assert_eq!(after.len(), before.len());
assert_eq!((&after[0], &after[1]), (&before[0], &json!("rgb 1 2 3")));
assert_eq!(after[2..], before[2..]);
}

View File

@ -82,12 +82,15 @@ canvas while open; `ui.dialog.confirm` keeps the result as one undo step (`edit.
`edit.colors.saturate`) and `ui.dialog.cancel` rolls it back. Global mode answers with an error until tints of global
and spot colours exist; the dialog then stays open.
Recolor Artwork: `ui.recolorDialog {colors?, library?}` opens the `recolor` dialog on the selected art (`colors`: a
count for an n-colour job, or colours to assign as the new colours; `library`: Limit to Library, `""` for the first).
Fields: `rows` (`[{from: [keys], to: key, exclude?}]` as `recolor.reduce` returns them), `colors` (null for Auto),
`method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo`, `tab` (`assign` or `edit`), `rule` (harmony
id), `linked`, `preview`. Setting `colors`, a preserve flag or `limitTo` reduces the rows again (also on
`ui.dialog.confirm`). The dialog previews `recolor.apply` on the canvas; confirm keeps it as one undo step.
Recolor Artwork: `ui.recolorDialog {colors?, library?, group?}` opens the `recolor` dialog on the selected art
(`colors`: a count for an n-colour job, or colours to assign as the new colours; `library`: Limit to Library, `""` for
the first; `group`: a colour group, Edit or Apply Color Group). Fields: `rows` (`[{from: [keys], to: key, exclude?}]`
as `recolor.reduce` returns them), `colors` (null for Auto), `method`, `preserveWhite`, `preserveBlack`,
`preserveGrays`, `limitTo`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`. Setting
`colors`, a preserve flag or `limitTo` reduces the rows again (also on `ui.dialog.confirm`). The dialog previews
`recolor.apply` on the canvas; confirm keeps it as one undo step (with a group, the group is rewritten in the same
step; with no art selected only the group changes). In the Swatches panel, double-clicking a colour group's folder,
or the options button while a group is selected, opens it.
Library panel: `engine.execute {command: "window.swatchLibrary", params: {library}}` opens the read-only library
panel on a swatch library (`ui.inspect` shows it as `library_panel: {kind, id}`; `library: null` closes it).

View File

@ -319,20 +319,23 @@ patterns out. The Blend commands also grade gradient meshes, which keep their sh
Recolor Artwork keys colours by identity, not hex: `recolor.colors` lists each colour's `key` (`"rgb 255 0 0"`,
`"cmyk 0 100 100 0"`, `"gray 40"`, `"lab 55 60 -40"`; RGB in 0–255 levels, CMYK and Gray in percent), so CMYK,
Lab and RGB colours that look alike stay apart, and every colour parameter also accepts a key. `recolor.reduce {colors?, method?,
preserve?, limitTo?}` groups the selection's colours into rows of similar colours (k-means in Lab, weighted by use;
Lab and RGB colours that look alike stay apart, and every colour parameter also accepts a key.
`recolor.reduce {colors?, method?, preserve?, limitTo?}` groups the selection's colours into rows of similar colours (k-means in Lab, weighted by use;
tints of a global swatch share its row; White and Black are preserved by default) and returns a map of rows
`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, limitTo?}`.
`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, limitTo?, group?, groupColors?, rename?}`.
`method` says how a row's colours take its new colour: `exact`, `preserveTints` (tints of the row's darkest colour
stay tints), `scaleTints` (every colour becomes a tint as light, relative to the darkest), `tintsShades` (lighter and
darker than the row's average become tints and shades) or `hueShift` (the most saturated colour takes the new colour,
the others turn by the same hue). `limitTo` snaps new colours to a swatch library's nearest colour;
`recolor.randomize {map, order?, saturationBrightness?, seed?}` shuffles or varies new colours; a row with
`exclude: true` keeps its colours.
`exclude: true` keeps its colours. `swatch.editGroup {group, colors, rename?}` rewrites a colour group in one undo
step (art linked to its global swatches follows), and `recolor.apply` with `group` recolours the art and rewrites the
group together.
```json
{"name":"run_command","arguments":{"command":"recolor.reduce","params":{"colors":2,"preserve":{"grays":true}}}}
{"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":[{"from":["cmyk 0 100 100 0","cmyk 0 40 40 0"],"to":"cmyk 100 50 0 0"}],"method":"scaleTints"}}}
{"name":"run_command","arguments":{"command":"swatch.editGroup","params":{"group":"Brights","colors":["#ff0000","cmyk 0 0 100 0"]}}}
```
## Group, layer and type appearance