diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 285604af5..74a287128 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -84,6 +84,7 @@ mod stroke; mod style; pub mod stylelib; mod swatch; +pub mod swatchlegend; pub mod swatchlib; pub(crate) mod symboledit; pub mod symbollib; @@ -297,6 +298,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(paint::proxy_specs()); v.extend(overprint::specs()); v.extend(swatchlib::specs()); + v.extend(swatchlegend::specs()); v.extend(freeform::specs()); v.extend(flatten::specs()); v.extend(stylelib::specs()); diff --git a/crates/engine/src/cmd/swatchlegend.rs b/crates/engine/src/cmd/swatchlegend.rs new file mode 100644 index 000000000..e9964de6f --- /dev/null +++ b/crates/engine/src/cmd/swatchlegend.rs @@ -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 { + 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, cmd: &str) -> Result { + 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 { + 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 = 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 { + 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 = 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::>()?, + 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 = 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; diff --git a/crates/engine/src/cmd/swatchlegend/tests.rs b/crates/engine/src/cmd/swatchlegend/tests.rs new file mode 100644 index 000000000..280220798 --- /dev/null +++ b/crates/engine/src/cmd/swatchlegend/tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/panels/swatches/actions.rs b/crates/ui-egui/src/panels/swatches/actions.rs index be275c108..8bc4b7aec 100644 --- a/crates/ui-egui/src/panels/swatches/actions.rs +++ b/crates/ui-egui/src/panels/swatches/actions.rs @@ -164,6 +164,19 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Add Used Colors"), true, false) { app.run("swatch.addUsedColors", json!({})).ok(); } + // Create Swatch Legend (#4919): the selected colour swatches, else every one. + if menu_item(ui, tl!("Create Swatch Legend"), app.session.active().is_some(), false) { + let picked = deletable(&selected); + let colours: Vec = 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(); + let p = if colours.is_empty() { json!({}) } else { json!({ "names": colours }) }; + if let Err(e) = app.run("swatch.legend", p) { + app.status(e); + } + } ui.separator(); if menu_item(ui, tl!("Sort by Name"), true, false) { app.run("swatch.sortByName", json!({})).ok(); diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index c649e282b..0070d50ee 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -770,3 +770,40 @@ fn lab_spot_tiles_show_the_colour_the_spot_colors_options_pick() { assert_eq!(tile(&app), app.session.active().unwrap().doc.global_color("Ink").unwrap()); assert!(matches!(tile(&app), Color::Cmyk { .. })); } + +/// One frame of the panel menu at `time`; its painted labels. +fn menu_labels(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, time: f64) -> Vec<(String, Rect)> { + let input = + egui::RawInput { events, time: Some(time), screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| menu(app, ui)); + out.textures_delta.clear(); + crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) +} + +/// #4919: the menu's Create Swatch Legend draws the selected colour swatches, or every one when +/// none is selected. +#[test] +fn create_swatch_legend_draws_the_selected_swatches_or_all() { + let mut app = app(); + app.run("swatch.new", json!({"name": "Ink 185", "color": "#e4002b", "spot": true})).unwrap(); + let ctx = context(); + let click = |app: &mut VectorcraftApp, ctx: &egui::Context, t: f64| { + let labels = menu_labels(app, ctx, vec![], t); + let at = labels.iter().find(|(l, _)| l.trim() == "Create Swatch Legend").map(|(_, r)| r.center()).expect("Create Swatch Legend"); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + menu_labels(app, ctx, vec![Event::PointerMoved(at), button(true)], t + 0.1); + menu_labels(app, ctx, vec![button(false)], t + 0.2); + }; + let count = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + let id = *st.selection.objects.first().expect("the legend is selected"); + st.doc.node(id).unwrap().children().unwrap().len() / 2 + }; + set_pstate(&ctx, "swatch-selected", vec!["Ink 185".to_string(), "[None]".to_string()]); + click(&mut app, &ctx, 0.0); + assert_eq!(count(&app), 1, "the selected colour swatch alone"); + set_pstate(&ctx, "swatch-selected", Vec::::new()); + click(&mut app, &ctx, 1.0); + let colours = app.session.active().unwrap().doc.swatches_iter().filter(|w| w.paint.color().is_some()).count(); + assert_eq!(count(&app), colours, "every colour swatch"); +} diff --git a/docs/mcp.md b/docs/mcp.md index c9df9caeb..e553b1bd2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2108,6 +2108,17 @@ 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"]}}} ``` +`swatch.legend {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`). The Swatches +panel menu's Create Swatch Legend draws the selected colour swatches, else every one (#4919). → `{id, count}` + +```json +{"name":"run_command","arguments":{"command":"swatch.legend","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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0059e4f59..802f56bfb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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. The Swatches panel menu's Create Swatch Legend (`swatch.legend`) 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::swatchlegend::tests` (engine), `panels::swatches::tests::create_swatch_legend_draws_the_selected_swatches_or_all` (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 in the Swatches panel menu (`swatch.legend`, #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. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 616db671c..c11ecf1f4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 | Swatches panel menu: Create Swatch Legend draws labelled chips of the selected (or all) colour swatches 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 |