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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 23:03:56 +07:00
parent fe258915e3
commit bdf8f704af
5 changed files with 363 additions and 22 deletions

View File

@ -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 {

View File

@ -68,6 +68,51 @@ pub fn specs() -> Vec<CommandSpec> {
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<String> = 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<Value> {
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<Value> {
}
let members: Vec<String> = 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<String> = 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<Value> {
})?;
ok()
}
fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
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<NodeId> = 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::<Vec<_>>(), linked))
})?;
Ok(json!({"added": added, "linked": linked}))
}
fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
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<Value> {
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<String>| {
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<Value> {
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<String> = 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<Value> {
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()
}

View File

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

View File

@ -713,6 +713,18 @@ fn editable(app: &VectorcraftApp, sel: &[String]) -> Option<String> {
}
}
/// The selected solid-colour swatches in selection order (Merge Swatches keeps the first).
fn mergeable(app: &VectorcraftApp, sel: &[String]) -> Vec<String> {
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<String> {
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<String>, 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<String> = 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::<Paint>(&ctx).is_none());
}
#[test]
fn menu_targets_follow_the_panel_selection() {
let app = app();
let sel: Vec<String> = ["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);

View File

@ -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