Fix #4919: Create Swatch Legend

swatch.legend draws a grid of chips, one per colour swatch (the given
ones, else every one), each filled with and linked to its swatch and
labelled with its name and colour values (HEX, RGB, CMYK, Lab or
Grayscale; by default its own model; spot swatches marked), black on
light chips and white on dark ones, as one selected group in one undo
step. The Swatches panel menu's Create Swatch Legend draws the selected
colour swatches, else every one.
This commit is contained in:
Don Omondi
2026-11-02 14:36:07 +07:00
committed by bflatastic
parent 601ab957e6
commit c823e7f5b2
8 changed files with 324 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
//! Create Swatch Legend (#4919): a grid of chips on the artboard, one per colour swatch, each
//! filled with its swatch and labelled with the swatch's name and colour values, as one group made
//! in one undo step.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint, cms::Model};
use vectorcraft_doc::{Appearance, Node, NodeKind, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
use super::{CommandSpec, Result, Session, bad, cmd, has_doc};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"swatch.legend",
"Create Swatch Legend",
["Window", "Swatches"],
None,
"{names?: [swatch name…] (default: every colour swatch of the document, in panel order), columns?: 1..50 (4), size?: chip side in pt 8..1000 (72), gap?: pt 0..1000 (12), values?: [hex|rgb|cmyk|lab|gray…] (default: each swatch's own colour model; spot swatches also say spot), x?, y? (the legend's top-left; default: the first artboard's, inset by `gap`)} draw a legend of colour swatches: one square chip per swatch filled with it (linked to it), labelled inside with the swatch name and the values asked for, in black on light chips and white on dark ones, as one group in one undo step; the group is selected → {id, count} (#4919)",
has_doc,
legend
)]
}
/// Most swatches one legend draws.
const MAX_CHIPS: usize = 2000;
const VALUES: [&str; 5] = ["hex", "rgb", "cmyk", "lab", "gray"];
fn number(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>, cmd: &str) -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && range.contains(n))
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number {}..{}", range.start(), range.end()))),
}
}
/// The label lines of colour `c`: the values `values` names, else those of its own model.
fn value_lines(c: Color, values: &[String], spot: bool) -> Vec<String> {
let own = match c.model() {
Model::Rgb => "rgb",
Model::Cmyk => "cmyk",
Model::Gray => "gray",
Model::Lab => "lab",
};
let picked: Vec<&str> = if values.is_empty() { vec![own] } else { values.iter().map(String::as_str).collect() };
let pct = |v: f32| (v * 100.0).round() as i32;
let mut lines: Vec<String> = picked
.into_iter()
.map(|v| match v {
"hex" => c.to_hex().to_uppercase(),
"rgb" => {
let [r, g, b] = c.to_rgb().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as i32);
format!("R={r} G={g} B={b}")
}
"cmyk" => {
let [cy, m, y, k] = c.to_cmyk().map(|v| pct(v.clamp(0.0, 1.0)));
format!("C={cy} M={m} Y={y} K={k}")
}
"lab" => {
let l = c.to_lab();
format!("L={} a={} b={}", l.l.round() as i32, l.a.round() as i32, l.b.round() as i32)
}
_ => {
let k = match c {
Color::Gray { k } => k,
_ => 1.0 - c.to_lab().l / 100.0,
};
format!("K={}%", pct(k.clamp(0.0, 1.0)))
}
})
.collect();
if spot {
lines.insert(0, "Spot".into());
}
lines
}
fn legend(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.legend";
let columns = number(p, "columns", 4.0, 1.0..=50.0, C)? as usize;
let size = number(p, "size", 72.0, 8.0..=1000.0, C)?;
let gap = number(p, "gap", 12.0, 0.0..=1000.0, C)?;
let values: Vec<String> = match p.get("values") {
None | Some(Value::Null) => vec![],
Some(Value::Array(a)) => a
.iter()
.map(|v| {
v.as_str().filter(|v| VALUES.contains(v)).map(str::to_string).ok_or_else(|| bad(C, "`values` must name hex, rgb, cmyk, lab or gray"))
})
.collect::<Result<_>>()?,
Some(_) => return Err(bad(C, "`values` must be a list")),
};
let st = s.doc()?;
let doc = &st.doc;
let colour = |w: &&vectorcraft_color::Swatch| w.paint.color().is_some();
let swatches: Vec<vectorcraft_color::Swatch> = match p.get("names") {
None | Some(Value::Null) => doc.swatches_iter().filter(colour).cloned().collect(),
Some(Value::Array(a)) => {
let mut out = vec![];
for n in a {
let n = n.as_str().ok_or_else(|| bad(C, "`names` must be a list of swatch names"))?;
let w = doc.swatches_iter().find(|w| w.name == n).ok_or_else(|| bad(C, format!("no swatch named `{n}`")))?;
if w.paint.color().is_none() {
return Err(bad(C, format!("`{n}` is not a colour swatch")));
}
out.push(w.clone());
}
out
}
Some(_) => return Err(bad(C, "`names` must be a list of swatch names")),
};
if swatches.is_empty() {
return Err(bad(C, "there are no colour swatches to draw"));
}
if swatches.len() > MAX_CHIPS {
return Err(bad(C, format!("a legend draws at most {MAX_CHIPS} swatches")));
}
let origin = doc.artboards.first().map_or(Point::ZERO, |a| a.rect.origin());
let x0 = number(p, "x", origin.x + gap, -1e6..=1e6, C)?;
let y0 = number(p, "y", origin.y + gap, -1e6..=1e6, C)?;
let system_layout = doc.setup.system_layout;
let parent = st.target_parent()?;
let text_size = (size / 7.0).clamp(4.0, 12.0);
let pad = (size / 12.0).clamp(1.0, 8.0);
let style = super::create::new_type_style(s, &json!({ "size": text_size }));
let count = swatches.len();
let gid = s.edit("Create Swatch Legend", |d, sel| {
let mut children = Vec::with_capacity(count * 2);
for (i, w) in swatches.iter().enumerate() {
let Some(c) = w.paint.color() else { continue };
let (col, row) = (i % columns, i / columns);
let (x, y) = (x0 + col as f64 * (size + gap), y0 + row as f64 * (size + gap));
let mut chip = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(x, y, x + size, y + size)),
Appearance::basic(w.paint.clone(), Paint::None, 0.0),
);
chip.name = Some(w.name.clone());
children.push(std::sync::Arc::new(chip));
let mut lines = vec![w.name.clone()];
lines.extend(value_lines(c, &values, w.spot));
let mut label_style = style.clone();
label_style.fill = Paint::solid(if c.to_lab().l < 50.0 { Color::WHITE } else { Color::BLACK });
label_style.stroke = Paint::None;
let mut t = TextObject::point(Point::new(x + pad, y + pad + text_size), &lines.join("\n"), label_style);
t.para = super::create::new_type_para();
t.system_layout = system_layout;
super::typecmd::refresh_bounds(&mut t);
children.push(std::sync::Arc::new(Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))));
}
let gid = d.alloc_id();
let mut g = Node::new(gid, NodeKind::Group { children, clip: false });
g.name = Some("Swatch Legend".into());
d.insert(parent, usize::MAX, g)?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0, "count": count }))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,93 @@
use serde_json::json;
use vectorcraft_color::Color;
use vectorcraft_doc::{Node, NodeId, NodeKind};
use crate::Session;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
s
}
fn group(s: &Session, id: u64) -> Node {
s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone()
}
/// The text of each label in group `g`, in order.
fn labels(g: &Node) -> Vec<(String, Color)> {
g.children()
.unwrap()
.iter()
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some((t.plain_text(), t.runs[0].style.fill.color().unwrap())),
_ => None,
})
.collect()
}
#[test]
fn a_legend_draws_a_labelled_chip_per_colour_swatch_in_one_undo_step() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Ink", "color": "#101010"})).unwrap();
s.execute("swatch.new", &json!({"name": "Paper", "color": "#f8f4e8"})).unwrap();
let colours = s.doc().unwrap().doc.swatches_iter().filter(|w| w.paint.color().is_some()).count();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("swatch.legend", &json!({"columns": 3, "size": 60, "gap": 10})).unwrap();
assert_eq!(r["count"].as_u64().unwrap() as usize, colours);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(r["id"].as_u64().unwrap())], "the legend is selected");
let g = group(&s, r["id"].as_u64().unwrap());
assert_eq!(g.name.as_deref(), Some("Swatch Legend"));
let kids = g.children().unwrap();
assert_eq!(kids.len(), 2 * colours, "a chip and a label each");
// Chips fill with their swatch, three to a row, 70 pt apart.
let chips: Vec<&Node> = kids.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).map(|n| &**n).collect();
let doc = &s.doc().unwrap().doc;
for (chip, w) in chips.iter().zip(doc.swatches_iter().filter(|w| w.paint.color().is_some())) {
assert_eq!(chip.appearance.fill_paint(), w.paint, "{}", w.name);
assert_eq!(chip.name.as_deref(), Some(w.name.as_str()));
}
let b = |i: usize| chips[i].geometric_bounds().unwrap();
assert!((b(1).x0 - b(0).x0 - 70.0).abs() < 1e-6 && (b(3).y0 - b(0).y0 - 70.0).abs() < 1e-6 && (b(3).x0 - b(0).x0).abs() < 1e-6);
assert!((b(0).width() - 60.0).abs() < 1e-6);
// Labels: the name, then the swatch's own values; white on the dark chip, black on the light.
let l = labels(&g);
let ink = l.iter().find(|(t, _)| t.starts_with("Ink")).unwrap();
let paper = l.iter().find(|(t, _)| t.starts_with("Paper")).unwrap();
assert!(ink.0.contains("R=16 G=16 B=16"), "{}", ink.0);
assert_eq!((ink.1, paper.1), (Color::WHITE, Color::BLACK));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g.id).is_none());
}
#[test]
fn a_legend_takes_names_values_and_a_position() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Signal", "color": "cmyk 0 100 100 0", "spot": true})).unwrap();
let r = s.execute("swatch.legend", &json!({"names": ["Signal"], "values": ["hex", "cmyk", "lab", "gray"], "x": 100, "y": 50})).unwrap();
assert_eq!(r["count"], 1);
let g = group(&s, r["id"].as_u64().unwrap());
let (text, _) = labels(&g).remove(0);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0], "Signal");
assert_eq!(lines[1], "Spot", "a spot swatch says so");
assert!(lines[2].starts_with('#') && lines[3] == "C=0 M=100 Y=100 K=0" && lines[4].starts_with("L=") && lines[5].starts_with("K="), "{lines:?}");
let chip = g.children().unwrap().iter().find(|n| matches!(n.kind, NodeKind::Path { .. })).unwrap().geometric_bounds().unwrap();
assert!((chip.x0 - 100.0).abs() < 1e-6 && (chip.y0 - 50.0).abs() < 1e-6);
// Refused, with nothing drawn.
let before = s.doc().unwrap().history.undo.len();
for bad in [
json!({"names": ["Nope"]}),
json!({"names": "Signal"}),
json!({"values": ["xyz"]}),
json!({"columns": 0}),
json!({"size": 2}),
json!({"gap": -1}),
json!({"x": f64::MAX}),
json!({"names": []}),
] {
assert!(s.execute("swatch.legend", &bad).is_err(), "{bad}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), before);
}