Merge pull request #5066 from mockydevs/feat/swatch-legend-4919

Fix #4919: Create Swatch Legend
This commit is contained in:
BFlat
2026-11-02 14:56:41 +07:00
committed by GitHub
37 changed files with 560 additions and 3 deletions
+7 -2
View File
@@ -2,10 +2,12 @@
//! Duotone and Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as
//! Filter writes any colour adjustment into the colours themselves, Select by Color Kind selects the
//! objects that use spot colours, carry a live colour adjustment, or are images of a colour model,
//! Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print, and Find Nearest
//! Swatch ([`nearest`]) matches colours to a swatch library's. Its panel and its submenu, Object ›
//! Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print, Find Nearest
//! Swatch ([`nearest`]) matches colours to a swatch library's, and Create Swatch Legend
//! ([`legend`]) draws labelled chips of the document's swatches. Its panel and its submenu, Object ›
//! Plug-ins › Color Effects, are the UI's.
pub mod legend;
mod nearest;
mod print_fixes;
#[cfg(test)]
@@ -71,6 +73,9 @@ pub fn specs() -> Vec<CommandSpec> {
nearest::apply
),
]
.into_iter()
.chain(legend::specs())
.collect()
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
@@ -0,0 +1,183 @@
//! Create Swatch Legend (`addon.color.swatchLegend`; Object › Plug-ins › Color Effects › 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, AreaFit, Node, NodeKind, TextKind, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
use super::super::super::{CommandSpec, bad, cmd, has_doc};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.color.swatchLegend",
"Create Swatch Legend",
["Object", "Plug-ins", "Color Effects"],
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…] (each at most once; 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
)]
}
/// The command.
pub const COMMAND: &str = "addon.color.swatchLegend";
/// 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)
}
_ => {
// As Edit Colors › Convert to Grayscale and the Color panel convert it.
let k = match c.in_model(Model::Gray) {
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 = COMMAND;
let columns = number(p, "columns", 4.0, 1.0..=50.0, C)?;
if columns.fract() != 0.0 {
return Err(bad(C, "`columns` must be a whole number 1..50"));
}
let columns = columns 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")),
};
// Each kind once: every value line goes into every chip, so repeats would only multiply them.
if values.iter().enumerate().any(|(i, v)| values[..i].contains(v)) {
return Err(bad(C, "`values` names a kind twice"));
}
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::super::super::create::new_type_style(s, &json!({ "size": text_size }));
let count = swatches.len();
// Each chip's fill links to its swatch, as paint.setFill {swatch} links art: editing a global or
// spot swatch then recolours the legend too.
let fills: Vec<Paint> =
swatches.iter().map(|w| super::super::super::paint::swatch_solid(doc, &w.name, None).unwrap_or_else(|_| w.paint.clone())).collect();
let gid = s.edit("Create Swatch Legend", |d, sel| {
let mut children = Vec::with_capacity(count * 2);
for (i, (w, fill)) in swatches.iter().zip(fills).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(fill, 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;
// Area type the size of the chip (less its inset) that shrinks text to fit, so a long
// name or value line stays on its chip.
let mut t = TextObject::point(Point::new(x + pad, y + pad), &lines.join("\n"), label_style);
let inner = (size - 2.0 * pad).max(1.0);
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, inner, inner)) };
t.area.fit = AreaFit::ShrinkText { min_percent: AreaFit::MIN_PERCENT };
t.para = super::super::super::create::new_type_para();
t.system_layout = system_layout;
super::super::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,118 @@
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("addon.color.swatchLegend", &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().color(), w.paint.color(), "{}", 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("addon.color.swatchLegend", &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!({"columns": 2.5}),
json!({"size": 2}),
json!({"gap": -1}),
json!({"x": f64::MAX}),
json!({"names": []}),
json!({"values": ["rgb", "rgb"]}),
] {
assert!(s.execute("addon.color.swatchLegend", &bad).is_err(), "{bad}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), before);
}
/// A chip of a global swatch is linked to it: editing the swatch recolours the chip. Each label is
/// area type inside its chip that shrinks a long name to fit (#4919 review).
#[test]
fn chips_stay_linked_and_labels_stay_on_their_chips() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "A Rather Long Brand Colour Name", "color": "#2050a0", "global": true})).unwrap();
let r = s
.execute("addon.color.swatchLegend", &json!({"names": ["A Rather Long Brand Colour Name"], "size": 40, "values": ["hex", "rgb", "cmyk"]}))
.unwrap();
let id = r["id"].as_u64().unwrap();
s.execute("swatch.edit", &json!({"name": "A Rather Long Brand Colour Name", "color": "#a02050"})).unwrap();
let g = group(&s, id);
let kids = g.children().unwrap();
let chip = kids.iter().find(|n| matches!(n.kind, NodeKind::Path { .. })).unwrap();
assert_eq!(chip.appearance.fill_paint().color().map(|c| c.to_hex()), Some("#a02050".into()), "the edit reached the chip");
let label = kids.iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap();
let NodeKind::Text(t) = &label.kind else { panic!("not type") };
assert!(matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) && matches!(t.area.fit, vectorcraft_doc::AreaFit::ShrinkText { .. }));
let (c, l) = (chip.geometric_bounds().unwrap(), label.geometric_bounds().unwrap());
assert!(l.x0 >= c.x0 - 1e-6 && l.y0 >= c.y0 - 1e-6 && l.x1 <= c.x1 + 1e-6 && l.y1 <= c.y1 + 1e-6, "label {l:?} inside chip {c:?}");
}
+4 -1
View File
@@ -3,8 +3,10 @@
//! filter, into the colours themselves (`addon.color.filter`); below, under Inks, it opens Duotone's
//! dialog and Print Ink Fixes' (`addon.color.printFixes`). The submenu applies the add-on's live
//! effects, Duotone and Vector Halftone among them (Effect › Color Effects has them too), opens
//! Print Ink Fixes and Find Nearest Swatch ([`nearest`]) and opens the panel.
//! Print Ink Fixes, Find Nearest Swatch ([`nearest`]) and Create Swatch Legend ([`legend`]) and
//! opens the panel.
pub mod legend;
pub mod nearest;
use egui::Ui;
@@ -100,6 +102,7 @@ pub fn menu() -> Vec<Item> {
Item::Sep,
cmd(print_fixes()),
Item::Cmd("Find Nearest Swatch…", nearest::COMMAND, Value::Null),
Item::Cmd("Create Swatch Legend…", legend::COMMAND, Value::Null),
Item::Sep,
Item::Sub(
"Select by Color Kind",
+93
View File
@@ -0,0 +1,93 @@
//! Create Swatch Legend in the interface (Object › Plug-ins › Color Effects › Create Swatch
//! Legend…, #4919): a dialog for the columns, the chip size, the gap and which colour values the
//! labels show, and whether only the swatches selected in the Swatches panel are drawn. It
//! previews on the canvas and runs `addon.color.swatchLegend` on OK.
use egui::Ui;
use serde_json::{Value, json};
use crate::VectorcraftApp;
use crate::dialogs::{DialogSpec, form};
use crate::state::Dialog;
use crate::widgets;
/// The command, and its dialog's kind.
pub const COMMAND: &str = "addon.color.swatchLegend";
pub const KIND: &str = "swatchLegend";
const LABEL_W: f32 = 80.0;
const FIELD_W: f32 = 90.0;
/// The values a label can show: (the command's name, the dialog's label).
pub const VALUES: [(&str, &str); 5] = [("hex", "HEX"), ("rgb", "RGB"), ("cmyk", "CMYK"), ("lab", "Lab"), ("gray", "Grayscale")];
/// The dialog's fields to start with: the command's defaults, each swatch's own values.
pub(crate) fn fields() -> Value {
json!({ "columns": 4, "size": 72, "gap": 12, "selectedOnly": false })
}
pub(crate) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Create Swatch Legend").to_string(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM };
/// The colour swatches selected in the Swatches panel.
fn selected_swatches(app: &VectorcraftApp, ctx: &egui::Context) -> Vec<String> {
let picked: Vec<String> = crate::panels::pstate(ctx, "swatch-selected");
app.session
.active()
.map(|st| st.doc.swatches_iter().filter(|w| w.paint.color().is_some() && picked.contains(&w.name)).map(|w| w.name.clone()).collect())
.unwrap_or_default()
}
/// The command's params from the dialog's fields.
pub(crate) fn params(d: &Dialog) -> Value {
let mut p = json!({ "columns": d.f64("columns", 4.0).round(), "size": d.f64("size", 72.0), "gap": d.f64("gap", 12.0) });
let values: Vec<&str> = VALUES.iter().filter(|(v, _)| d.bool(v)).map(|(v, _)| *v).collect();
if !values.is_empty() {
p["values"] = json!(values);
}
if d.bool("selectedOnly")
&& let Some(names) = d.fields.get("names").filter(|n| n.as_array().is_some_and(|a| !a.is_empty()))
{
p["names"] = names.clone();
}
p
}
fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
widgets::label_row(ui, tl!("Columns:"), LABEL_W, |ui| {
if let Some(v) = widgets::range_field(ui, ("swatch-legend", "columns"), d.f64("columns", 4.0), 1.0..=50.0, "", 0, FIELD_W) {
d.fields.insert("columns".into(), json!(v.round()));
}
});
widgets::label_row(ui, tl!("Size:"), LABEL_W, |ui| {
form::length_within(ui, d, "size", unit, FIELD_W, Some(8.0..=1000.0));
});
widgets::label_row(ui, tl!("Gap:"), LABEL_W, |ui| {
form::length_within(ui, d, "gap", unit, FIELD_W, Some(0.0..=1000.0));
});
ui.add_space(4.0);
// None ticked: each swatch shows the values of its own colour model.
ui.label(tl!("Show:"));
ui.horizontal_wrapped(|ui| {
for (v, label) in VALUES {
form::check(ui, d, v, tl!(label));
}
});
// Only the swatches selected in the Swatches panel, when some are.
let names = selected_swatches(app, ui.ctx());
if names.is_empty() {
d.fields.insert("selectedOnly".into(), json!(false));
} else {
form::check(ui, d, "selectedOnly", tl!("Selected Swatches Only"));
}
d.fields.insert("names".into(), json!(names));
form::preview(app, ui, d, "Create Swatch Legend", COMMAND, params(d));
false
}
/// OK: run the command (keep its preview) and close.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
form::commit_preview(app, COMMAND, params(d))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,58 @@
use serde_json::json;
use vectorcraft_engine::Session;
use crate::tests_labels::painted_text;
use crate::{VectorcraftApp, menus};
/// #4919: Object › Plug-ins › Color Effects › Create Swatch Legend… opens a dialog with the
/// columns, chip size, gap and values; it previews, Cancel rolls it back, and OK draws the legend
/// with the values picked as one undo step. With swatches selected in the Swatches panel, it can
/// draw only those.
#[test]
fn create_swatch_legend_opens_its_dialog_previews_and_runs_on_ok() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
app.run("swatch.new", json!({"name": "Ink", "color": "#123456"})).unwrap();
let entries = menus::menu_entries(&app);
let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Color Effects"] && e.label == "Create Swatch Legend…").expect("menu item");
let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params);
menus::invoke(&mut app, &cmd, p);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(super::KIND));
let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
for s in ["Create Swatch Legend", "Columns:", "Size:", "Gap:", "HEX", "CMYK", "Grayscale", "Preview"] {
assert!(shown.contains(s), "{s} in {shown}");
}
let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].children().map_or(0, Vec::len);
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("preview".into(), json!(true));
painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
assert_eq!(count(&app), 1, "previewed: one legend group");
crate::dialogs::cancel(&mut app);
assert_eq!(count(&app), 0, "rolled back");
// OK with HEX and CMYK and 2 columns.
menus::invoke(&mut app, &cmd, serde_json::Value::Null);
let n = app.session.doc().unwrap().history.undo.len();
let d = app.ui.dialog.as_mut().unwrap();
for (k, v) in [("hex", json!(true)), ("cmyk", json!(true)), ("columns", json!(2))] {
d.fields.insert(k.into(), v);
}
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.doc().unwrap().history.undo.len(), n + 1);
let st = app.session.doc().unwrap();
let g = st.doc.layers[0].children().unwrap().last().unwrap();
let label = g.children().unwrap().iter().find_map(|c| match &c.kind {
vectorcraft_doc::NodeKind::Text(t) if t.plain_text().starts_with("Ink") => Some(t.plain_text()),
_ => None,
});
let label = label.expect("Ink's label");
assert!(label.contains("#123456") && label.contains("C="), "{label}");
// Only the swatch selected in the Swatches panel.
let ctx = egui::Context::default();
crate::panels::set_pstate(&ctx, "swatch-selected", vec!["Ink".to_string()]);
assert_eq!(super::selected_swatches(&app, &ctx), ["Ink"]);
let mut d = crate::state::Dialog::new(super::KIND, super::fields());
d.fields.insert("selectedOnly".into(), json!(true));
d.fields.insert("names".into(), json!(["Ink"]));
assert_eq!(super::params(&d)["names"], json!(["Ink"]));
}
+1
View File
@@ -407,6 +407,7 @@ registry! {
PathToolOptions: [crate::addons::pathtools::KIND] => crate::addons::pathtools::OPTIONS_SPEC,
DivideDashes: [crate::addons::pathtools::dashes::KIND] => crate::addons::pathtools::dashes::SPEC,
NearestSwatch: [crate::addons::color::nearest::KIND] => crate::addons::color::nearest::SPEC,
SwatchLegend: [crate::addons::color::legend::KIND] => crate::addons::color::legend::SPEC,
ArrangeTools: [crate::addons::arrange::GRID, crate::addons::arrange::REPLACE, crate::addons::arrange::SIZE, crate::addons::arrange::OPACITY, crate::addons::arrange::SORT] => crate::addons::arrange::SPEC,
ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm),
}
+3
View File
@@ -6091,3 +6091,6 @@ breakText Glyphs Глифи
Small to Large От малко към голямо
Large to Small От голямо към малко
Distribute Evenly Равномерно разпределение
Create Swatch Legend Създаване на легенда на образците
Create Swatch Legend… Създаване на легенда на образците…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6122,3 +6122,6 @@ shape Open Otvoren
Small to Large Od manjeg prema većem
Large to Small Od većeg prema manjem
Distribute Evenly Rasporedi ravnomjerno
Create Swatch Legend Kreiraj legendu uzoraka boja
Create Swatch Legend… Kreiraj legendu uzoraka boja…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6216,3 +6216,6 @@ breakText Glyphs Glifs
Small to Large De petit a gran
Large to Small De gran a petit
Distribute Evenly Distribueix uniformement
Create Swatch Legend Crea una llegenda de mostres
Create Swatch Legend… Crea una llegenda de mostres…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6228,3 +6228,6 @@ breakText Glyphs Glyfy
Small to Large Od nejmenšího po největší
Large to Small Od největšího po nejmenší
Distribute Evenly Rozmístit rovnoměrně
Create Swatch Legend Vytvořit legendu vzorníku
Create Swatch Legend… Vytvořit legendu vzorníku…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6106,3 +6106,6 @@ breakText Glyphs Glyffer
Small to Large Fra lille til stor
Large to Small Fra stor til lille
Distribute Evenly Fordel jævnt
Create Swatch Legend Opret farveprøveforklaring
Create Swatch Legend… Opret farveprøveforklaring…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5814,3 +5814,6 @@ breakText Glyphs Glyphen
Small to Large Von klein nach groß
Large to Small Von groß nach klein
Distribute Evenly Gleichmäßig verteilen
Create Swatch Legend Farbfeldlegende erstellen
Create Swatch Legend… Farbfeldlegende erstellen…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6180,3 +6180,6 @@ breakText Glyphs Γλύφοι
Small to Large Από μικρό σε μεγάλο
Large to Small Από μεγάλο σε μικρό
Distribute Evenly Ομοιόμορφη κατανομή
Create Swatch Legend Δημιουργία υπομνήματος δειγμάτων χρωμάτων
Create Swatch Legend… Δημιουργία υπομνήματος δειγμάτων χρωμάτων…
HEX HEX
Can't render this file because it contains an unexpected character in line 163 and column 5.
+3
View File
@@ -6170,3 +6170,6 @@ breakText Glyphs Glifos
Small to Large De menor a mayor
Large to Small De mayor a menor
Distribute Evenly Distribuir uniformemente
Create Swatch Legend Crear leyenda de muestras
Create Swatch Legend… Crear leyenda de muestras…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6258,3 +6258,6 @@ breakText Glyphs Merkit
Small to Large Pienestä suureen
Large to Small Suuresta pieneen
Distribute Evenly Jaa tasaisesti
Create Swatch Legend Luo värimallien selite
Create Swatch Legend… Luo värimallien selite…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6223,3 +6223,6 @@ breakText Glyphs Glyphes
Small to Large Du plus petit au plus grand
Large to Small Du plus grand au plus petit
Distribute Evenly Répartir uniformément
Create Swatch Legend Créer une légende des nuances
Create Swatch Legend… Créer une légende des nuances…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6212,3 +6212,6 @@ breakText Glyphs Glifák
Small to Large Kicsitől a nagyig
Large to Small Nagytól a kicsiig
Distribute Evenly Egyenletes elosztás
Create Swatch Legend Színmintajelmagyarázat létrehozása
Create Swatch Legend… Színmintajelmagyarázat létrehozása…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6106,3 +6106,6 @@ breakText Glyphs Glif
Small to Large Kecil ke besar
Large to Small Besar ke kecil
Distribute Evenly Distribusikan merata
Create Swatch Legend Buat Legenda Swatch
Create Swatch Legend… Buat Legenda Swatch…
HEX HEX
Can't render this file because it contains an unexpected character in line 2206 and column 5.
+3
View File
@@ -5848,3 +5848,6 @@ breakText Glyphs Glifi
Small to Large Dal più piccolo al più grande
Large to Small Dal più grande al più piccolo
Distribute Evenly Distribuisci uniformemente
Create Swatch Legend Crea legenda campioni
Create Swatch Legend… Crea legenda campioni…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5913,3 +5913,6 @@ breakText Glyphs 字形
Small to Large 小さい順
Large to Small 大きい順
Distribute Evenly 均等に分布
Create Swatch Legend スウォッチの凡例を作成
Create Swatch Legend… スウォッチの凡例を作成…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6216,3 +6216,6 @@ breakText Glyphs გლიფები
Small to Large პატარიდან დიდისკენ
Large to Small დიდიდან პატარისკენ
Distribute Evenly თანაბრად განაწილება
Create Swatch Legend ფერის ნიმუშების ლეგენდის შექმნა
Create Swatch Legend… ფერის ნიმუშების ლეგენდის შექმნა…
HEX HEX
Can't render this file because it is too large.
+3
View File
@@ -5809,3 +5809,6 @@ breakText Glyphs 글리프
Small to Large 작은 것부터
Large to Small 큰 것부터
Distribute Evenly 균등 분포
Create Swatch Legend 견본 범례 만들기
Create Swatch Legend… 견본 범례 만들기…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6150,3 +6150,6 @@ breakText Glyphs Glyphs
Small to Large Van klein naar groot
Large to Small Van groot naar klein
Distribute Evenly Gelijkmatig verdelen
Create Swatch Legend Stalenlegenda maken
Create Swatch Legend… Stalenlegenda maken…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6083,3 +6083,6 @@ breakText Glyphs Glify
Small to Large Od najmniejszego do największego
Large to Small Od największego do najmniejszego
Distribute Evenly Rozmieść równomiernie
Create Swatch Legend Utwórz legendę próbek
Create Swatch Legend… Utwórz legendę próbek…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6301,3 +6301,6 @@ swatch Yellow Green Verde-amarelado
Small to Large Do menor para o maior
Large to Small Do maior para o menor
Distribute Evenly Distribuir uniformemente
Create Swatch Legend Criar Legenda de Amostras
Create Swatch Legend… Criar Legenda de Amostras…
HEX HEX
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -6205,3 +6205,6 @@ breakText Glyphs Глифы
Small to Large От меньшего к большему
Large to Small От большего к меньшему
Distribute Evenly Распределить равномерно
Create Swatch Legend Создать легенду образцов
Create Swatch Legend… Создать легенду образцов…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6280,3 +6280,6 @@ breakText Glyphs Glyfer
Small to Large Från liten till stor
Large to Small Från stor till liten
Distribute Evenly Fördela jämnt
Create Swatch Legend Skapa färgrutelegend
Create Swatch Legend… Skapa färgrutelegend…
HEX HEX
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -6185,3 +6185,6 @@ breakText Glyphs Glifler
Small to Large Küçükten Büyüğe
Large to Small Büyükten Küçüğe
Distribute Evenly Eşit Dağıt
Create Swatch Legend Renk Örneği Açıklaması Oluştur
Create Swatch Legend… Renk Örneği Açıklaması Oluştur…
HEX HEX
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -5828,3 +5828,6 @@ breakText Glyphs Гліфи
Small to Large Від меншого до більшого
Large to Small Від більшого до меншого
Distribute Evenly Розподілити рівномірно
Create Swatch Legend Створити легенду зразків
Create Swatch Legend… Створити легенду зразків…
HEX HEX
Can't render this file because it contains an unexpected character in line 177 and column 5.
+3
View File
@@ -5829,3 +5829,6 @@ breakText Glyphs Glyph
Small to Large Từ nhỏ đến lớn
Large to Small Từ lớn đến nhỏ
Distribute Evenly Phân bố đều
Create Swatch Legend Tạo chú giải mẫu màu
Create Swatch Legend… Tạo chú giải mẫu màu…
HEX HEX
Can't render this file because it contains an unexpected character in line 199 and column 5.
+3
View File
@@ -6069,3 +6069,6 @@ breakText Glyphs 字形
Small to Large 从小到大
Large to Small 从大到小
Distribute Evenly 均匀分布
Create Swatch Legend 创建色板图例
Create Swatch Legend… 创建色板图例…
HEX HEX
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5866,3 +5866,6 @@ breakText Glyphs 字符
Small to Large 從小到大
Large to Small 從大到小
Distribute Evenly 均勻分佈
Create Swatch Legend 建立色票圖例
Create Swatch Legend… 建立色票圖例…
HEX HEX
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -70,6 +70,7 @@ pub(super) fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static st
"object.vectorHalftone" => (crate::dialogs::halftone::KIND, crate::dialogs::halftone::fields()),
crate::addons::pathtools::dashes::COMMAND => (crate::addons::pathtools::dashes::KIND, crate::addons::pathtools::dashes::fields()),
crate::addons::color::nearest::COMMAND => (crate::addons::color::nearest::KIND, crate::addons::color::nearest::fields()),
crate::addons::color::legend::COMMAND => (crate::addons::color::legend::KIND, crate::addons::color::legend::fields()),
"artboard.rearrange" => (crate::dialogs::rearrange_artboards::KIND, crate::dialogs::rearrange_artboards::open_fields(app)),
_ => return None,
})
+12
View File
@@ -2108,6 +2108,18 @@ swatch in the library panel does). The pattern library Geometric Patterns (`geom
{"name":"run_command","arguments":{"command":"swatch.library.get","params":{"library":"earth-tones"}}}
{"name":"run_command","arguments":{"command":"swatch.library.add","params":{"library":"earth-tones","names":["Clay"]}}}
```
`addon.color.swatchLegend {names?, columns?, size?, gap?, values?, x?, y?}` draws a legend of colour swatches on the page as one
group, in one undo step, and selects it: a square chip per swatch (`size` pt, 72 by default, `columns` to a row, 4 by
default, `gap` pt apart, 12 by default) filled with the swatch and linked to it, labelled inside with the swatch's name
and its values (`values`: any of `hex`, `rgb`, `cmyk`, `lab`, `gray`; by default the swatch's own colour model; spot
swatches also say Spot), in black on light chips and white on dark ones. `names` picks swatches (default: every colour
swatch, in panel order); `x`, `y` place the top-left (default: the first artboard's, inset by `gap`). Object ›
Plug-ins › Color Effects › Create Swatch Legend… sets these in a dialog with a preview, and can limit it to the
swatches selected in the Swatches panel (#4919). → `{id, count}`
```json
{"name":"run_command","arguments":{"command":"addon.color.swatchLegend","params":{"columns":6,"values":["hex","cmyk"]}}}
```
`swatch.library.save {path?, format?: "vcswatches"|"gpl"|"ase"|"css", names?, name?, user?}` writes the document's
swatches as a library (`.vcswatches` keeps colour models, global, spot, gradients and colour
groups; `.gpl` is 8-bit RGB; a swatch exchange `.ase` file keeps solid colors in their own model
+4
View File
@@ -1499,12 +1499,16 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov
## Color Effects add-on: Find Nearest Swatch (2026-11-01)
A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Color Effects › Find Nearest Swatch… lists each colour of the selection (fills and strokes, as ticked; solid colours and gradient stops, inside groups and type) with the three swatches of the chosen library closest to it by CIEDE2000 difference, with that difference (`addon.color.nearestSwatch`, #4923). The library is the document's swatches, a built-in library, or a library or colour book the user saved or loaded (no colour book data ships with VectorCraft). Apply gives each colour its nearest swatch as one undo step (`addon.color.applyNearestSwatch`): global and spot swatches link, and a library's swatches the colours take are added to the document first, so a spot colour stays spot. Remaining: typing a colour to look up in the dialog (the command takes `color`), and applying a match other than the nearest. Tests: `cmd::addons::color::nearest::tests` (engine), `addons::color::nearest::tests::find_nearest_swatch_lists_the_matches_and_applies_the_nearest` (ui-egui).
## Create Swatch Legend (2026-11-01)
A VectorCraft addition (it replaces a script some users ran), so no area score moves. Object › Plug-ins › Color Effects › Create Swatch Legend… (`addon.color.swatchLegend`) opens a dialog (columns, chip size, gap, values, selected swatches only, with a preview) and draws a chip per colour swatch (the selected ones, else all) filled with and linked to its swatch, labelled with its name and values (HEX, RGB, CMYK, Lab or Grayscale; by default its own model; spot swatches marked), black on light chips and white on dark ones, in a grid of chosen columns, chip size and spacing, as one selected group in one undo step (#4919). Tests: `cmd::addons::color::legend::tests` (engine), `addons::color::legend::tests::create_swatch_legend_opens_its_dialog_previews_and_runs_on_ok` (ui-egui).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Color Effects add-on (a VectorCraft addition): Find Nearest Swatch (#4923); evidence added; scores unchanged. |
| 2026-11-01 | minor | Create Swatch Legend add-on under Object › Plug-ins › Color Effects (`addon.color.swatchLegend`, #4919); evidence added; scores unchanged. |
| 2026-11-01 | minor | Document Tools add-on (a VectorCraft addition): Export All Open Documents as PDF or SVG (#4920); evidence added; scores unchanged. |
| 2026-11-01 | minor | Path Precision Tools add-on (a VectorCraft addition): Expand Dashes into Paths (#4922); evidence added; scores unchanged. |
| 2026-11-01 | minor | Type core: the Glyphs panel highlights the character selected with the Type tool and scrolls it into view (#4908); evidence added; scores unchanged. |
+1
View File
@@ -401,6 +401,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|---|---|---|
| 2026-11-01 | minor | Color Effects add-on (a VectorCraft addition): Find Nearest Swatch (#4923); scores and estimates unchanged |
| 2026-11-01 | minor | Artboards panel: a row dragged above or below another reorders the artboards (#4687); scores and estimates unchanged |
| 2026-11-01 | minor | Object › Plug-ins › Color Effects › Create Swatch Legend…: a dialog that draws labelled chips of the colour swatches (all, or the ones selected in the Swatches panel) as one group (#4919); scores and estimates unchanged |
| 2026-11-01 | minor | Document Tools add-on (a VectorCraft addition): Export All Open Documents as PDF or SVG (#4920); scores and estimates unchanged |
| 2026-11-01 | minor | Path Precision Tools add-on (a VectorCraft addition): Expand Dashes into Paths (#4922); scores and estimates unchanged |
| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); scores and estimates unchanged |