Swatches: Add Used Colors and Select All Unused look colors up in hash sets

swatch.addUsedColors and swatch.unused searched lists inside loops over
colors and swatches, checking each color against every color or swatch
before it. They now keep the same ordered lists and use hash sets and
maps for membership and lookup. A color's key is its model and the bits
of its components with -0.0 read as 0.0, so the key agrees with `==`; a
color with a NaN component matches nothing, as before.

Add Used Colors names its new swatches through
Document::free_swatch_names, which collects the taken swatch and color
group names once and gives the same names free_swatch_name gives when the
swatches are added one at a time. The added swatches, their names, order
and links, the undo step, and the swatches Select All Unused lists are
unchanged.
This commit is contained in:
dotCooCoo
2026-11-08 13:20:41 +07:00
committed by bflatastic
parent 874ae85fab
commit 39f36dc4ba
4 changed files with 242 additions and 18 deletions
+51
View File
@@ -7,6 +7,7 @@
//! those links (and colours) wherever art and swatches hold them, copying only the nodes that
//! change.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use vectorcraft_color::swatch::REGISTRATION;
@@ -85,6 +86,12 @@ impl Document {
fn numbered_swatch_name(&self, base: &str, from: usize) -> String {
(from..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string())
}
/// Names for several new swatches: [`FreeSwatchNames::take`] gives what
/// [`Document::free_swatch_name`] gives once the swatches named before it are added.
pub fn free_swatch_names(&self) -> FreeSwatchNames {
let taken = self.swatches_iter().map(|s| s.name.clone()).chain(self.swatch_groups.iter().map(|g| g.name.clone()));
FreeSwatchNames { taken: taken.chain([REGISTRATION.to_string()]).collect(), next: HashMap::new() }
}
/// The name a new swatch of `paint` gets by default, unused in the document: a colour's values
/// ([`color_name`], made free like [`Document::free_swatch_name`]), or "New Gradient Swatch 1",
/// "New Pattern Swatch 1"…
@@ -200,6 +207,34 @@ pub fn color_name(c: Color) -> String {
}
}
/// The swatch and color group names of a document ([`Document::free_swatch_names`]) and the names
/// handed out since, for naming many new swatches without searching the document for each name.
#[derive(Clone, Debug)]
pub struct FreeSwatchNames {
taken: HashSet<String>,
/// For each base already numbered, the number its next numbered name is looked for from:
/// names are only ever added, so the numbers below it stay taken.
next: HashMap<String, usize>,
}
impl FreeSwatchNames {
/// `base`, or the first of "base 2", "base 3"… not taken, as [`Document::free_swatch_name`]
/// gives it; the name is taken from then on.
pub fn take(&mut self, base: &str) -> String {
let name = if self.taken.contains(base) {
let from = self.next.get(base).copied().unwrap_or(2);
let (i, name) =
(from..).map(|i| (i, format!("{base} {i}"))).find(|(_, n)| !self.taken.contains(n)).unwrap_or_else(|| (from, base.to_string()));
self.next.insert(base.to_string(), i + 1);
name
} else {
base.to_string()
};
self.taken.insert(name.clone());
name
}
}
/// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`,
/// true when it changed them.
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
@@ -419,6 +454,22 @@ mod tests {
assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0");
}
#[test]
fn free_swatch_names_match_naming_each_swatch_as_it_is_added() {
let (mut d, _, _) = doc();
let mut names = d.free_swatch_names();
for base in ["Brand", "Brand", "Mine", "Fresh", "Fresh", "Brand 2", "Brand", REGISTRATION, "Fresh 3", "Fresh", "Mine 2"] {
let want = d.free_swatch_name(base);
assert_eq!(names.take(base), want, "{base}");
d.swatches.push(Swatch { name: want, paint: Paint::solid(Color::BLACK), global: false, spot: false });
}
let added: Vec<&str> = d.swatches.iter().rev().take(11).rev().map(|w| w.name.as_str()).collect();
assert_eq!(
added,
["Brand 2", "Brand 3", "Mine 2", "Fresh", "Fresh 2", "Brand 2 2", "Brand 4", "[Registration] 2", "Fresh 3", "Fresh 4", "Mine 2 2"]
);
}
#[test]
fn mapping_copies_only_changed_nodes() {
let (mut d, a, b) = doc();
+80 -17
View File
@@ -1,7 +1,9 @@
//! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting.
use std::collections::{HashMap, HashSet};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent};
use vectorcraft_color::{Color, Gradient, Paint, Swatch, SwatchGroup, tint_percent};
use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors};
use vectorcraft_doc::{Document, NodeId};
@@ -271,36 +273,80 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
/// each). A tint (a linked colour that differs from its swatch's) brings its swatch, and with
/// `tints` its own colour too. Links to missing swatches count as unlinked.
fn artwork_colors(d: &Document, ids: &[NodeId], tints: bool, swatches: &mut Vec<String>, colors: &mut Vec<Color>) {
let mut seen_swatches: HashSet<String> = swatches.iter().cloned().collect();
let mut seen_colors: HashSet<ColorKey> = colors.iter().filter_map(color_key).collect();
for n in ids.iter().filter_map(|id| d.node(*id)) {
node_colors(n, &mut |c, link| {
let sw = link.and_then(|l| d.swatch(l)).filter(|w| w.paint.color().is_some());
if let Some(w) = sw
&& !swatches.contains(&w.name)
&& !seen_swatches.contains(&w.name)
{
seen_swatches.insert(w.name.clone());
swatches.push(w.name.clone());
}
let own = sw.is_none_or(|w| tints && w.paint.color() != Some(*c));
if own && !colors.contains(c) {
// A color with a NaN component equals no color, so it is never already listed.
if own && color_key(c).is_none_or(|k| seen_colors.insert(k)) {
colors.push(*c);
}
});
}
}
/// 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.
/// A color's model and the bits of its components, with -0.0 read as 0.0: two keys are equal
/// exactly when the colors are equal (`==`). `None` for a color with a NaN component, which
/// equals no color.
type ColorKey = (u8, [u32; 4]);
fn color_key(c: &Color) -> Option<ColorKey> {
let (model, v) = match *c {
Color::Rgb { r, g, b } => (0, [r, g, b, 0.0]),
Color::Cmyk { c, m, y, k } => (1, [c, m, y, k]),
Color::Gray { k } => (2, [k, 0.0, 0.0, 0.0]),
Color::Lab { l, a, b } => (3, [l, a, b, 0.0]),
};
Some((model, [float_key(v[0])?, float_key(v[1])?, float_key(v[2])?, float_key(v[3])?]))
}
/// The bits of `v`, with -0.0 read as 0.0, so keys are equal exactly when the values are; `None`
/// for NaN.
fn float_key(v: f32) -> Option<u32> {
if v.is_nan() {
None
} else if v == 0.0 {
Some(0)
} else {
Some(v.to_bits())
}
}
/// The keys of a gradient's stop colors, equal for equal gradients; `None` when a stop's color
/// equals no color, so the gradient equals no gradient.
fn gradient_key(g: &Gradient) -> Option<Vec<ColorKey>> {
g.stops.iter().map(|s| color_key(&s.color)).collect()
}
/// Link the solid paints in the subtrees of `ids` whose color is one of `links`' to that swatch
/// (the first one listed for the color), 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();
let live: HashSet<String> = d.swatches_iter().map(|w| w.name.clone()).collect();
let mut by_color: HashMap<ColorKey, &String> = HashMap::new();
for (c, n) in links {
if let Some(k) = color_key(c) {
by_color.entry(k).or_insert(n);
}
}
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());
match color_key(c).and_then(|k| by_color.get(&k)) {
Some(n) => {
*link = Some((*n).clone());
true
}
None => false,
@@ -798,10 +844,12 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
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 have: HashSet<ColorKey> = d.swatches_iter().filter_map(|w| w.paint.color()).filter_map(|c| color_key(&c)).collect();
colors.retain(|c| color_key(c).is_none_or(|k| !have.contains(&k)));
let mut free = d.free_swatch_names();
let mut links = vec![];
for c in colors {
let name = d.free_swatch_name(&color_name(c));
let name = free.take(&color_name(c));
d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(c), global, spot: false });
links.push((c, name));
}
@@ -838,16 +886,31 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
}
}
}
let gradient_links: HashSet<&String> = gradient_links.iter().collect();
let link_names: HashSet<&str> = links.iter().map(|(l, _)| l.as_str()).collect();
let link_tints: HashSet<(&str, u32)> = links.iter().filter_map(|(l, t)| Some((l.as_str(), float_key(*t)?))).collect();
let colors: HashSet<ColorKey> = colors.iter().filter_map(color_key).collect();
let patterns: HashSet<&String> = patterns.iter().collect();
// Gradients by their stops' colors, each listed once.
let mut by_stops: HashMap<Vec<ColorKey>, Vec<&Gradient>> = HashMap::new();
for g in &gradients {
if let Some(k) = gradient_key(g) {
let list = by_stops.entry(k).or_default();
if !list.contains(&g) {
list.push(g);
}
}
}
let used = |w: &Swatch| {
gradient_links.contains(&w.name)
|| links.iter().any(|(l, t)| match w.tint_of() {
Some((base, tint)) => l == base && *t == tint,
None => *l == w.name,
})
|| match w.tint_of() {
Some((base, tint)) => float_key(tint).is_some_and(|t| link_tints.contains(&(base, t))),
None => link_names.contains(w.name.as_str()),
}
|| match &w.paint {
Paint::None => true,
Paint::Solid { color, .. } => colors.contains(color),
Paint::Gradient(g) => gradients.contains(&g.gradient),
Paint::Solid { color, .. } => color_key(color).is_some_and(|k| colors.contains(&k)),
Paint::Gradient(g) => gradient_key(&g.gradient).and_then(|k| by_stops.get(&k)).is_some_and(|list| list.contains(&&g.gradient)),
Paint::Pattern { pattern, .. } => patterns.contains(pattern),
}
};
+110 -1
View File
@@ -3,7 +3,7 @@
//! document's colour mode.
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_color::{Color, Paint};
use crate::*;
@@ -125,6 +125,115 @@ fn add_used_colors_skips_colours_that_have_swatches() {
assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global);
}
/// Give `id` the fill `p` exactly as written (a command would round or convert some colors).
fn set_fill(s: &mut Session, id: NodeId, p: Paint) {
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.set_fill(p);
}
/// Rectangles filled with repeated colors, near-equal colors that get the same name, a color and
/// its twin with a -0.0 component, a color whose name a swatch already has, and colors two
/// swatches already have (one of them through -0.0). Returns the rectangles in paint order.
fn used_colors_doc(s: &mut Session) -> Vec<NodeId> {
run(s, "swatch.new", json!({"name": "Gray K=33", "color": "#00ff00"}));
run(s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"}));
let red = doc(s).swatch("Red").unwrap().paint.color().unwrap();
let colors = [
Color::rgb(0.2, 0.4, 0.6),
Color::rgb(0.2, 0.4, 0.6),
Color::rgb(0.2, 0.4, 0.6001),
Color::rgb(0.0, 0.4, 0.6),
Color::rgb(-0.0, 0.4, 0.6),
Color::gray(0.33),
Color::gray(0.3301),
red,
Color::rgb(-0.0, 128.0 / 255.0, 1.0),
Color::rgb(0.2, 0.4, 0.6001),
];
colors
.into_iter()
.map(|c| {
let id = rect_filled(s, json!({"color": "#000000"}));
set_fill(s, id, Paint::solid(c));
id
})
.collect()
}
const USED_NAMES: [&str; 5] = ["R=51 G=102 B=153", "R=51 G=102 B=153 2", "R=0 G=102 B=153", "Gray K=33 2", "Gray K=33 3"];
#[test]
fn add_used_colors_adds_each_new_color_once_with_free_names() {
let mut s = session();
used_colors_doc(&mut s);
let before = doc(&s).clone();
let undo = undo_len(&s);
let r = run(&mut s, "swatch.addUsedColors", json!({}));
assert_eq!(r, json!({"added": USED_NAMES, "linked": 0}));
assert_eq!(names(&s, None)[names(&s, None).len() - 5..], USED_NAMES);
let color = |n: &str| doc(&s).swatch(n).unwrap().paint.color().unwrap();
assert_eq!(color("R=51 G=102 B=153 2"), Color::rgb(0.2, 0.4, 0.6001));
assert_eq!(color("Gray K=33 3"), Color::gray(0.3301));
assert_eq!(undo_len(&s), undo + 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(*doc(&s), before);
}
#[test]
fn add_used_colors_global_links_every_paint_of_a_new_color() {
let mut s = session();
let ids = used_colors_doc(&mut s);
let r = run(&mut s, "swatch.addUsedColors", json!({"global": true}));
assert_eq!(r, json!({"added": USED_NAMES, "linked": 8}));
let link = |s: &Session, id: NodeId| match fill(s, id) {
Paint::Solid { swatch, .. } => swatch,
p => panic!("{p:?}"),
};
// The index in USED_NAMES of the swatch each rectangle links to.
let want = [Some(0), Some(0), Some(1), Some(2), Some(2), Some(3), Some(4), None, None, Some(1)];
let got: Vec<Option<String>> = ids.iter().map(|id| link(&s, *id)).collect();
assert_eq!(got, want.map(|i: Option<usize>| i.map(|i| USED_NAMES[i].to_string())));
assert!(USED_NAMES.iter().all(|n| doc(&s).swatch(n).unwrap().global));
}
#[test]
fn unused_matches_links_tints_colors_gradients_and_patterns_exactly() {
let mut s = session();
run(&mut s, "swatch.edit", json!({"name": "Teal", "global": true}));
run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 40}));
run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 70}));
run(&mut s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"}));
let teal = doc(&s).swatch("Teal").unwrap().paint.color().unwrap();
let tinted = rect_filled(&mut s, json!({"color": "#000000"}));
set_fill(&mut s, tinted, Paint::Solid { color: teal.tinted(0.4), swatch: Some("Teal".into()), tint: 0.4 });
let zero = rect_filled(&mut s, json!({"color": "#000000"}));
set_fill(&mut s, zero, Paint::solid(Color::rgb(-0.0, 128.0 / 255.0, 1.0)));
// Sunset's gradient without a link to it, and Ocean's with one stop moved.
let gradient = |s: &Session, n: &str| match &doc(s).swatch(n).unwrap().paint {
Paint::Gradient(g) => g.gradient.clone(),
p => panic!("{p:?}"),
};
let sunset = gradient(&s, "Sunset");
let mut ocean = gradient(&s, "Ocean");
ocean.stops[0].offset += 0.25;
for g in [sunset.clone(), sunset, ocean] {
let id = rect_filled(&mut s, json!({"color": "#000000"}));
set_fill(&mut s, id, Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(g))));
}
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", "Teal 40%", "Zeroed", "Sunset", "Dots"] {
assert!(!names.contains(&used), "{used} is used: {names:?}");
}
for unused in ["Teal 70%", "Ocean", "Orange", "Red"] {
assert!(names.contains(&unused), "{unused} is unused: {names:?}");
}
}
#[test]
fn unused_leaves_out_linked_colours_gradients_and_patterns_in_use() {
let mut s = session();