From bdf8f704af549f0296a329d061e2ea46e390ca74 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:03:56 +0700 Subject: [PATCH] M3.31: Swatches panel menu adds used colours, selects unused, merges, ungroups and sorts by kind New commands: swatch.addUsedColors {selection?, global?} adds a swatch for each colour the art uses that no solid swatch has (optionally global, the art linking to them); swatch.unused lists the swatches nothing uses (links, colours, gradients and patterns in art, symbols, pattern tiles and graphic styles count); swatch.merge keeps the first colour and relinks the others' art; swatch.ungroup and swatch.sortByKind (process, spot, gradients, patterns). Each edit is one undo step; the panel menu items are enabled. Document::visit_paints walks every paint of a document. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/swatches.rs | 17 +++ crates/engine/src/cmd/swatch.rs | 201 +++++++++++++++++++++++--- crates/engine/src/tests_swatchcmds.rs | 115 +++++++++++++++ crates/ui-egui/src/panels/swatches.rs | 49 ++++++- docs/mcp.md | 3 + 5 files changed, 363 insertions(+), 22 deletions(-) diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 6ff1c173..4e99240b 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -105,6 +105,23 @@ impl Document { } } +impl Document { + /// Visit every paint held by the art, symbol definitions, pattern tiles and graphic styles + /// (fills, strokes and text runs), and the colour of every gradient-mesh point as a solid paint. + pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) { + let mut tree = |n: &Node| { + n.walk(&mut |m| { + node_paints(m).for_each(&mut *f); + if let NodeKind::Mesh(mesh) = &m.kind { + mesh.points.iter().for_each(|p| f(&Paint::solid(p.color))); + } + }) + }; + self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art)).for_each(|n| tree(n)); + self.graphic_styles.iter().flat_map(|gs| &gs.appearance.items).for_each(|it| f(item_paint(it))); + } +} + /// The default name of a solid swatch: its values in its own colour model ("C=10 M=20 Y=30 K=0", /// "R=255 G=128 B=0", "Gray K=40"). pub fn color_name(c: Color) -> String { diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 4182f58a..63b35f35 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -68,6 +68,51 @@ pub fn specs() -> Vec { has_doc, swatch_move ), + cmd!( + "swatch.addUsedColors", + "Add Used Colors", + ["Window", "Swatches"], + None, + "{selection?: false (only the selected art's colours; default: all the art's), global?: false (the new swatches are global and the matching unlinked colours in that art link to them)} add a swatch for each colour used (fills, strokes, text, gradient stops, mesh points) that no solid swatch has yet, as one undo step → {added: [names], linked}", + has_doc, + swatch_add_used + ), + cmd!( + query "swatch.unused", + "Select All Unused", + ["Window", "Swatches"], + None, + "{} → {names: [the swatches nothing uses, in panel order]}: a colour is used when a paint links to it or an unlinked paint, gradient stop or mesh point has its colour; a gradient when a gradient links to it or has its stops; a pattern when it fills or strokes anything (art, symbols, pattern tiles and graphic styles count). None is never listed", + has_doc, + swatch_unused + ), + cmd!( + "swatch.merge", + "Merge Swatches", + ["Window", "Swatches"], + None, + "{names: [two or more solid-colour swatches]} keep the first (its name and colour) and delete the others; paints linked to them take its colour and link to it (or unlink when it isn't global), as one undo step → {name, merged: [deleted names], relinked}", + has_doc, + swatch_merge + ), + cmd!( + "swatch.ungroup", + "Ungroup Color Group", + ["Window", "Swatches"], + None, + "{name: colour group} move its swatches to the end of the ungrouped swatches and remove the group, as one undo step → {swatches: [names]}", + has_doc, + swatch_ungroup + ), + cmd!( + "swatch.sortByKind", + "Sort by Kind", + ["Window", "Swatches"], + None, + "{} order the swatches of each list by kind (None first, then process colours, spot colours, gradients, patterns; colour groups stay after them), keeping their order within a kind, as one undo step", + has_doc, + swatch_sort_by_kind + ), ] } @@ -176,6 +221,27 @@ fn artwork_colors(d: &Document, ids: &[NodeId], tints: bool, swatches: &mut Vec< } } +/// Link the solid paints in the subtrees of `ids` whose colour is one of `links`' to that swatch, +/// unless they already link to an existing swatch. Returns the number of paints linked. +fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> usize { + if links.is_empty() { + return 0; + } + let live: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); + d.map_solid_paints_in(ids, &mut |c, link| { + if link.as_ref().is_some_and(|l| live.contains(l)) { + return false; + } + match links.iter().find(|(lc, _)| lc == c) { + Some((_, n)) => { + *link = Some(n.clone()); + true + } + None => false, + } + }) +} + fn swatch_new_group(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.newGroup"; let mut names = str_list(p, "swatches"); @@ -216,23 +282,7 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { } let members: Vec = group.swatches.iter().map(|w| w.name.clone()).collect(); d.swatch_groups.push(group); - let linked = if links.is_empty() { - 0 - } else { - let live: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); - d.map_solid_paints_in(&sel.objects, &mut |c, link| { - if link.as_ref().is_some_and(|l| live.contains(l)) { - return false; - } - match links.iter().find(|(lc, _)| lc == c) { - Some((_, n)) => { - *link = Some(n.clone()); - true - } - None => false, - } - }) - }; + let linked = link_colors(d, &sel.objects, &links); Ok((name, members, linked)) })?; Ok(json!({"name": name, "swatches": members, "linked": linked})) @@ -481,3 +531,120 @@ fn swatch_sort(s: &mut Session, _: &Value) -> Result { })?; ok() } + +fn swatch_add_used(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.addUsedColors"; + let global = bool_or(p, "global", false); + let only_selection = bool_or(p, "selection", false); + let (added, linked) = s.edit("Add Used Colors", |d, sel| { + let ids: Vec = if only_selection { sel.objects.clone() } else { d.layers.iter().map(|l| l.id).collect() }; + if only_selection && ids.is_empty() { + return Err(bad(C, "select artwork to add its colours")); + } + let (mut linked_to, mut colors) = (vec![], vec![]); + artwork_colors(d, &ids, false, &mut linked_to, &mut colors); + // Colours a solid swatch already has are skipped. + colors.retain(|c| !d.swatches_iter().any(|w| w.paint.color() == Some(*c))); + let mut links = vec![]; + for c in colors { + let name = d.free_swatch_name(&color_name(c)); + d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(c), global, spot: false }); + links.push((c, name)); + } + let linked = if global { link_colors(d, &ids, &links) } else { 0 }; + Ok((links.into_iter().map(|(_, n)| n).collect::>(), linked)) + })?; + Ok(json!({"added": added, "linked": linked})) +} + +fn swatch_unused(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let (mut links, mut colors, mut gradients, mut patterns) = (vec![], vec![], vec![], vec![]); + d.visit_paints(&mut |p| match p { + Paint::Solid { swatch: Some(l), .. } if d.swatch(l).is_some() => links.push(l.clone()), + Paint::Solid { color, .. } => colors.push(*color), + Paint::Gradient(g) => { + links.extend(g.swatch.clone()); + colors.extend(g.gradient.stops.iter().map(|s| s.color)); + gradients.push(g.gradient.clone()); + } + Paint::Pattern { pattern, .. } => patterns.push(pattern.clone()), + Paint::None => {} + }); + let used = |w: &Swatch| { + links.contains(&w.name) + || match &w.paint { + Paint::None => true, + Paint::Solid { color, .. } => colors.contains(color), + Paint::Gradient(g) => gradients.contains(&g.gradient), + Paint::Pattern { pattern, .. } => patterns.contains(pattern), + } + }; + let names: Vec<&str> = d.swatches_iter().filter(|w| !used(w)).map(|w| w.name.as_str()).collect(); + Ok(json!({ "names": names })) +} + +fn swatch_merge(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.merge"; + let names = names_param(p, C)?; + let d = &s.doc()?.doc; + for n in &names { + if d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?.paint.color().is_none() { + return Err(bad(C, format!("`{n}` isn't a solid colour"))); + } + } + let (keep, merged) = names.split_first().ok_or_else(|| bad(C, "give two or more swatches"))?; + if merged.is_empty() { + return Err(bad(C, "give two or more swatches")); + } + let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?; + let (color, link) = (kept.paint.color().unwrap_or_default(), kept.global.then(|| keep.clone())); + let mut relink = |c: &mut Color, l: &mut Option| { + if !l.as_ref().is_some_and(|l| merged.contains(l)) { + return false; + } + *c = color; + *l = link.clone(); + true + }; + let relinked = s.edit("Merge Swatches", |d, _| { + for n in merged { + d.remove_swatch(n); + } + Ok(d.map_solid_paints(&mut relink)) + })?; + map_default_paints(s, &mut relink); + Ok(json!({"name": keep, "merged": merged, "relinked": relinked})) +} + +fn swatch_ungroup(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.ungroup"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); + let moved = s.edit("Ungroup Color Group", |d, _| { + let i = d.swatch_groups.iter().position(|g| g.name == name).ok_or_else(|| bad(C, format!("no colour group `{name}`")))?; + let g = d.swatch_groups.remove(i); + let names: Vec = g.swatches.iter().map(|w| w.name.clone()).collect(); + d.swatches.extend(g.swatches); + Ok(names) + })?; + Ok(json!({ "swatches": moved })) +} + +fn swatch_sort_by_kind(s: &mut Session, _: &Value) -> Result { + let rank = |w: &Swatch| match &w.paint { + Paint::None => 0, + Paint::Solid { .. } if !w.spot => 1, + Paint::Solid { .. } => 2, + Paint::Gradient(_) => 3, + Paint::Pattern { .. } => 4, + }; + s.edit("Sort Swatches", |d, _| { + // A stable sort: swatches keep their order within a kind. + d.swatches.sort_by_key(rank); + for g in &mut d.swatch_groups { + g.swatches.sort_by_key(rank); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/tests_swatchcmds.rs b/crates/engine/src/tests_swatchcmds.rs index 989357e8..457bc3f8 100644 --- a/crates/engine/src/tests_swatchcmds.rs +++ b/crates/engine/src/tests_swatchcmds.rs @@ -76,3 +76,118 @@ fn move_reorders_groups_and_refuses_bad_moves() { assert_eq!(undo_len(&s), undo, "failed moves leave no undo step"); assert!(doc(&s).swatch("Sunset").is_some_and(|w| matches!(w.paint, Paint::Gradient(_)))); } + +fn rect_filled(s: &mut Session, fill: Value) -> NodeId { + let id = NodeId(run(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + let mut p = fill; + p["ids"] = json!([id.0]); + run(s, "paint.setFill", p); + run(s, "paint.setStroke", json!({"ids": [id.0], "none": true})); + id +} + +fn fill(s: &Session, id: NodeId) -> Paint { + doc(s).node(id).unwrap().appearance.fill_paint() +} + +#[test] +fn add_used_colors_skips_colours_that_have_swatches() { + let mut s = session(); + rect_filled(&mut s, json!({"color": "#123456"})); + rect_filled(&mut s, json!({"color": "#ed1c24"})); + rect_filled(&mut s, json!({"swatch": "Orange"})); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.addUsedColors", json!({})); + assert_eq!(r, json!({"added": ["R=18 G=52 B=86"], "linked": 0}), "Red and Orange are swatches already"); + assert_eq!(undo_len(&s), undo + 1); + assert_eq!(run(&mut s, "swatch.addUsedColors", json!({}))["added"], json!([])); + // Only the selection's colours, as global swatches its paints link to. + let d = rect_filled(&mut s, json!({"color": "#abcdef"})); + rect_filled(&mut s, json!({"color": "#fedcba"})); + run(&mut s, "select.set", json!({"ids": [d.0]})); + let r = run(&mut s, "swatch.addUsedColors", json!({"selection": true, "global": true})); + assert_eq!(r, json!({"added": ["R=171 G=205 B=239"], "linked": 1})); + assert_eq!(fill(&s, d), Paint::Solid { color: vectorcraft_color::Color::from_hex("#abcdef").unwrap(), swatch: Some("R=171 G=205 B=239".into()) }); + assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global); +} + +#[test] +fn unused_leaves_out_linked_colours_gradients_and_patterns_in_use() { + let mut s = session(); + let all = run(&mut s, "swatch.unused", json!({})); + assert!(!all["names"].as_array().unwrap().contains(&json!("[None]"))); + assert!(all["names"].as_array().unwrap().contains(&json!("Sunset"))); + run(&mut s, "swatch.edit", json!({"name": "Teal", "global": true})); + rect_filled(&mut s, json!({"swatch": "Teal"})); + run(&mut s, "swatch.edit", json!({"name": "Teal", "color": "#010203"})); + rect_filled(&mut s, json!({"swatch": "Sunset"})); + rect_filled(&mut s, json!({"color": "#ed1c24"})); + let r = rect_filled(&mut s, json!({"color": "#ff0000"})); + run(&mut s, "select.set", json!({"ids": [r.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + rect_filled(&mut s, json!({"swatch": "Dots"})); + let names = run(&mut s, "swatch.unused", json!({}))["names"].clone(); + let names: Vec<&str> = names.as_array().unwrap().iter().filter_map(Value::as_str).collect(); + for used in ["Teal", "Sunset", "Red", "Dots", "White"] { + assert!(!names.contains(&used), "{used} is used: {names:?}"); + } + for unused in ["Orange", "Ocean", "Bright Red", "K=50"] { + assert!(names.contains(&unused), "{unused} is unused: {names:?}"); + } +} + +#[test] +fn merge_keeps_the_first_and_relinks_the_others_art() { + let mut s = session(); + for n in ["Red", "Orange"] { + run(&mut s, "swatch.edit", json!({"name": n, "global": true})); + } + let a = rect_filled(&mut s, json!({"swatch": "Red"})); + let b = rect_filled(&mut s, json!({"swatch": "Orange"})); + let red = doc(&s).swatch("Red").unwrap().paint.color().unwrap(); + let before = doc(&s).clone(); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.merge", json!({"names": ["Red", "Orange"]})); + assert_eq!(r, json!({"name": "Red", "merged": ["Orange"], "relinked": 1})); + assert!(doc(&s).swatch("Orange").is_none()); + assert_eq!(fill(&s, b), Paint::Solid { color: red, swatch: Some("Red".into()) }); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); + // A kept swatch that isn't global leaves the merged swatches' art unlinked, in its colour. + run(&mut s, "swatch.merge", json!({"names": ["Lime", "Red", "Bright Red"]})); + let lime = doc(&s).swatch("Lime").unwrap().paint.color().unwrap(); + assert_eq!(fill(&s, a), Paint::solid(lime)); + assert!(doc(&s).swatch("Red").is_none() && doc(&s).swatch("Bright Red").is_none()); + for bad in [json!({"names": ["Lime"]}), json!({"names": ["Lime", "Sunset"]}), json!({"names": ["Lime", "Nope"]})] { + assert!(s.execute("swatch.merge", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn ungroup_and_sort_by_kind_are_one_undo_step_each() { + let mut s = session(); + let undo = undo_len(&s); + assert_eq!(run(&mut s, "swatch.ungroup", json!({"name": "Brights"}))["swatches"].as_array().unwrap().len(), 5); + assert!(doc(&s).swatch_groups.iter().all(|g| g.name != "Brights")); + assert_eq!(names(&s, None).last().map(String::as_str), Some("Bright Violet")); + assert!(s.execute("swatch.ungroup", &json!({"name": "Red"})).is_err()); + // A gradient first, a spot colour among the process ones. + run(&mut s, "swatch.move", json!({"name": "Ocean", "to": 0})); + run(&mut s, "swatch.edit", json!({"name": "Orange", "spot": true})); + run(&mut s, "swatch.sortByKind", json!({})); + assert_eq!(undo_len(&s), undo + 4); + let rank = |w: &vectorcraft_color::Swatch| match &w.paint { + Paint::None => 0, + Paint::Solid { .. } if !w.spot => 1, + Paint::Solid { .. } => 2, + Paint::Gradient(_) => 3, + Paint::Pattern { .. } => 4, + }; + let ranks: Vec = doc(&s).swatches.iter().map(rank).collect(); + assert!(ranks.is_sorted(), "{ranks:?}"); + let n = names(&s, None); + assert_eq!((n[0].as_str(), n[1].as_str()), ("[None]", "White"), "the order within a kind is kept"); + assert_eq!(n[n.iter().position(|x| x == "Orange").unwrap() + 1], "Ocean", "the spot colour comes after the process ones, then the gradients"); +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 84686a65..b43a1379 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -713,6 +713,18 @@ fn editable(app: &VectorcraftApp, sel: &[String]) -> Option { } } +/// The selected solid-colour swatches in selection order (Merge Swatches keeps the first). +fn mergeable(app: &VectorcraftApp, sel: &[String]) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + sel.iter().filter(|n| st.doc.swatch(n).is_some_and(|w| w.paint.color().is_some())).cloned().collect() +} + +/// The first selected colour group (Ungroup Color Group). +fn selected_group(app: &VectorcraftApp, sel: &[String]) -> Option { + let d = &app.session.active()?.doc; + sel.iter().find(|n| d.swatch_groups.iter().any(|g| g.name == **n)).cloned() +} + /// 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}); @@ -801,18 +813,36 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { { app.run("swatch.duplicate", json!({"name": n})).ok(); } - menu_item(ui, "Merge Swatches", false, false); + // The first selected colour is kept. + let merge = mergeable(app, &selected); + if menu_item(ui, "Merge Swatches", merge.len() > 1, false) { + app.run("swatch.merge", json!({ "names": merge })).ok(); + } if menu_item(ui, "Delete Swatch", !del.is_empty(), false) { delete(app, del, false); } - menu_item(ui, "Ungroup Color Group", false, false); - menu_item(ui, "Select All Unused", false, false); - menu_item(ui, "Add Used Colors", false, false); + let group = selected_group(app, &selected); + if menu_item(ui, "Ungroup Color Group", group.is_some(), false) + && let Some(g) = group + { + app.run("swatch.ungroup", json!({ "name": g })).ok(); + } + if menu_item(ui, "Select All Unused", true, false) + && let Ok(r) = app.run("swatch.unused", json!({})) + { + let names: Vec = r["names"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + set_pstate(ui.ctx(), "swatch-selected", names); + } + if menu_item(ui, "Add Used Colors", true, false) { + app.run("swatch.addUsedColors", json!({})).ok(); + } ui.separator(); if menu_item(ui, "Sort by Name", true, false) { app.run("swatch.sortByName", json!({})).ok(); } - menu_item(ui, "Sort by Kind", false, false); + if menu_item(ui, "Sort by Kind", true, false) { + app.run("swatch.sortByKind", json!({})).ok(); + } ui.separator(); for (v, label) in View::ALL { if menu_item(ui, label, true, v == view) { @@ -1154,6 +1184,15 @@ mod tests { assert!(egui::DragAndDrop::payload::(&ctx).is_none()); } + #[test] + fn menu_targets_follow_the_panel_selection() { + let app = app(); + let sel: Vec = ["Red", "Sunset", "Grays", "Orange", "Brights"].map(String::from).to_vec(); + assert_eq!(mergeable(&app, &sel), ["Red", "Orange"], "Merge Swatches takes the selected colours, first kept"); + assert_eq!(selected_group(&app, &sel).as_deref(), Some("Grays")); + assert_eq!(selected_group(&app, &sel[..2]), None); + } + #[test] fn kind_filter() { let solid = Paint::solid(Color::BLACK); diff --git a/docs/mcp.md b/docs/mcp.md index 89ae6946..2f74c42e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -97,6 +97,9 @@ art's colours (by default as global swatches the art links to). `swatch.move {names, to?, group?}` reorders swatches or moves them into or out of a colour group (as dragging them in the Swatches panel does); give colour group names instead to reorder the groups. Dropping a swatch on art in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `ids: [the object under the pointer]`. +The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`, `swatch.unused` (a query: the +names Select All Unused selects), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and +`swatch.sortByKind`. ## Resources