From fcc0f4ca0fa564dba89f1caf314b7999ce2cf54c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 03:17:07 +0700 Subject: [PATCH] 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 --- crates/engine/src/cmd/recolor.rs | 28 ++++++- crates/engine/src/cmd/swatch.rs | 84 +++++++++++++++++++++ crates/engine/src/tests_recolor.rs | 83 ++++++++++++++++++++- crates/ui-egui/src/dialogs/recolor.rs | 102 ++++++++++++++++++-------- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/swatches.rs | 34 +++++++-- crates/ui-egui/src/tests_recolor.rs | 99 ++++++++++++++++++++++++- docs/control-protocol.md | 15 ++-- docs/mcp.md | 11 ++- 9 files changed, 401 insertions(+), 57 deletions(-) diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index c4ed2fd6..74c5c779 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -30,8 +30,8 @@ pub fn specs() -> Vec { "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 { } } 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 = 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::>()?, + 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 })) } diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 57483fde..90d0aee2 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -122,6 +122,15 @@ pub fn specs() -> Vec { 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, + relinks: Vec, + 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 { + 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 { + const C: &str = "swatch.editGroup"; + let group = str_param(p, "group").ok_or_else(|| bad(C, "missing `group`"))?; + let colors: Vec = 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::>()?; + 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})) +} diff --git a/crates/engine/src/tests_recolor.rs b/crates/engine/src/tests_recolor.rs index a0cb953b..a8de2898 100644 --- a/crates/engine/src/tests_recolor.rs +++ b/crates/engine/src/tests_recolor.rs @@ -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::>()) + }; + 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::>()[..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::>(), 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::>(), [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"); +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 63dbee76..591de5e5 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -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 { 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> = 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 = match p.get("colors") { - Some(Value::Array(cs)) => cs.iter().filter_map(color_value).map(|c| key(&c)).collect(), + let assign: Vec = 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 { 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 = 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 { 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 { 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 = 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 = 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))); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb5099a4..627fda65 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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)"), diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index b26ff622..1254e23c 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -396,8 +396,9 @@ fn drag_of(e: &Entry, names: Vec) -> 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, + /// 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, drop: Option, /// 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 { 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, 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 { diff --git a/crates/ui-egui/src/tests_recolor.rs b/crates/ui-egui/src/tests_recolor.rs index 8af4adca..7a3be321 100644 --- a/crates/ui-egui/src/tests_recolor.rs +++ b/crates/ui-egui/src/tests_recolor.rs @@ -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 { + 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 { + 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 = 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 = 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 = 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 = 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..]); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 75c9715e..5cc8eb46 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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). diff --git a/docs/mcp.md b/docs/mcp.md index 22c5663e..6f988644 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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