From 00a0874d4d2626949597f93a2d81f0091cbb3951 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:15:10 +0700 Subject: [PATCH 1/8] =?UTF-8?q?M3.9:=20swatches=20in=20colour=20groups=20a?= =?UTF-8?q?re=20first-class=20=E2=80=94=20Document=20Color=20Mode=20conver?= =?UTF-8?q?ts=20them,=20they=20can=20be=20spot=20colours=20with=20their=20?= =?UTF-8?q?own=20plate=20and=20PDF=20Separation,=20and=20Document=20Info?= =?UTF-8?q?=20counts=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document::swatches_iter/_mut, swatch_mut, swatch_group_of, remove_swatch and a copy-on-write walk over linked solid paints (map_solid_paints) cover every colour group. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 4 +- crates/doc/src/swatches.rs | 279 ++++++++++++++++++++++++++++ crates/engine/src/cmd/colormgmt.rs | 6 +- crates/engine/src/cmd/docinfo.rs | 2 +- crates/engine/src/cmd/docmenu.rs | 2 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_swatches.rs | 76 ++++++++ crates/pdf/src/export.rs | 4 +- crates/render/src/proof.rs | 4 +- 9 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 crates/doc/src/swatches.rs create mode 100644 crates/engine/src/tests_swatches.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9d4b9cc0..4378dd75 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -12,6 +12,7 @@ pub mod live; pub mod node; pub mod pattern; pub mod selection; +pub mod swatches; pub mod text; use std::collections::BTreeMap; @@ -594,9 +595,6 @@ impl Document { pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> { self.patterns.iter_mut().find(|p| p.name == name) } - pub fn swatch(&self, name: &str) -> Option<&Swatch> { - self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter())).find(|s| s.name == name) - } /// Deep-clone `node` with fresh ids for it and all descendants. pub fn reid(&mut self, node: &Node) -> Node { let mut n = node.clone(); diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs new file mode 100644 index 00000000..dc4c52c0 --- /dev/null +++ b/crates/doc/src/swatches.rs @@ -0,0 +1,279 @@ +//! Swatch lookups across colour groups and the walks over the paints that link to swatches. +//! +//! A swatch lives either in `Document::swatches` or in one of `Document::swatch_groups`; every +//! lookup here covers both. Solid paints link to a global swatch by name (`Paint::Solid.swatch`); +//! [`Document::map_solid_paints`] rewrites those links (and colours) wherever art can hold them, +//! copying only the nodes that change. + +use std::sync::Arc; + +use vectorcraft_color::{Color, Paint, Swatch}; + +use crate::{AppearanceItem, Document, Node, NodeId, NodeKind}; + +impl Document { + /// Every swatch: the ungrouped ones first, then each colour group's in order. + pub fn swatches_iter(&self) -> impl Iterator { + self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter())) + } + pub fn swatches_iter_mut(&mut self) -> impl Iterator { + self.swatches.iter_mut().chain(self.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut())) + } + pub fn swatch(&self, name: &str) -> Option<&Swatch> { + self.swatches_iter().find(|s| s.name == name) + } + pub fn swatch_mut(&mut self, name: &str) -> Option<&mut Swatch> { + self.swatches_iter_mut().find(|s| s.name == name) + } + /// Index of the colour group holding swatch `name` (`None` when ungrouped or missing). + pub fn swatch_group_of(&self, name: &str) -> Option { + self.swatch_groups.iter().position(|g| g.swatches.iter().any(|s| s.name == name)) + } + /// Is `name` used by a swatch or a colour group (they share one namespace)? + pub fn swatch_name_taken(&self, name: &str) -> bool { + self.swatch(name).is_some() || self.swatch_groups.iter().any(|g| g.name == name) + } + /// Remove swatch `name` from wherever it lives. + pub fn remove_swatch(&mut self, name: &str) -> Option { + if let Some(i) = self.swatches.iter().position(|s| s.name == name) { + return Some(self.swatches.remove(i)); + } + let g = self.swatch_group_of(name)?; + let list = &mut self.swatch_groups[g].swatches; + let i = list.iter().position(|s| s.name == name)?; + Some(list.remove(i)) + } + + /// Visit every solid paint (fills, strokes and text runs) in the art, symbol definitions, + /// pattern tiles and graphic styles. `f(colour, link)` may change both and returns true when it + /// did. Only nodes holding a changed paint (and the paths to them) are copied. Returns the + /// number of changed paints. + pub fn map_solid_paints(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { + let mut n = map_trees(&mut self.layers, f); + for s in &mut self.symbols { + n += map_tree(&mut s.art, f); + } + for p in &mut self.patterns { + n += map_trees(&mut p.art, f); + } + for gs in &mut self.graphic_styles { + for it in &mut gs.appearance.items { + n += usize::from(map_solid(item_paint_mut(it), f)); + } + } + n + } + + /// [`Document::map_solid_paints`] limited to the subtrees of `ids` (each paint visited once even + /// when an id is inside another). + pub fn map_solid_paints_in(&mut self, ids: &[NodeId], f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { + let roots: Vec = + ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect(); + let mut n = 0; + for id in roots { + let Some(node) = self.node(id) else { continue }; + if let Some((new, count)) = map_node(node, f) + && let Some(slot) = self.node_mut(id) + { + *slot = new; + n += count; + } + } + n + } +} + +/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes and text +/// runs (with their link) and gradient stops (unlinked), in paint order. +pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) { + n.walk(&mut |m| { + for p in node_paints(m) { + match p { + Paint::Solid { color, swatch } => f(color, swatch.as_deref()), + Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, None)), + _ => {} + } + } + }); +} + +fn item_paint(it: &AppearanceItem) -> &Paint { + match it { + AppearanceItem::Fill(l) => &l.paint, + AppearanceItem::Stroke(l) => &l.paint, + } +} + +fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint { + match it { + AppearanceItem::Fill(l) => &mut l.paint, + AppearanceItem::Stroke(l) => &mut l.paint, + } +} + +/// The paints a node holds itself (not its children): appearance items, then text runs. +fn node_paints(n: &Node) -> impl Iterator { + let runs = match &n.kind { + NodeKind::Text(t) => t.runs.as_slice(), + _ => &[], + }; + n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])) +} + +/// Mutable [`node_paints`], in the same order. +fn node_paints_mut(n: &mut Node) -> impl Iterator { + let runs = match &mut n.kind { + NodeKind::Text(t) => t.runs.as_mut_slice(), + _ => &mut [], + }; + n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])) +} + +/// Apply `f` to a solid paint; true when it changed. +fn map_solid(p: &mut Paint, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> bool { + match p { + Paint::Solid { color, swatch } => f(color, swatch), + _ => false, + } +} + +/// `n` with `f` applied to its subtree's solid paints, or `None` when nothing changed (then nothing +/// was copied). Also returns the number of changed paints. +fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> Option<(Node, usize)> { + // Try each own paint on a scratch copy first, so unchanged nodes are never cloned. + let mut changes: Vec<(usize, Color, Option)> = vec![]; + for (i, p) in node_paints(n).enumerate() { + if let Paint::Solid { color, swatch } = p { + let (mut c, mut s) = (*color, swatch.clone()); + if f(&mut c, &mut s) { + changes.push((i, c, s)); + } + } + } + let mut count = changes.len(); + let mut out: Option = None; + if !changes.is_empty() { + let mut m = n.clone(); + let mut changes = changes.into_iter().peekable(); + for (i, p) in node_paints_mut(&mut m).enumerate() { + if changes.peek().is_some_and(|c| c.0 == i) + && let (Some((_, c, s)), Paint::Solid { color, swatch }) = (changes.next(), p) + { + *color = c; + *swatch = s; + } + } + out = Some(m); + } + if let Some(ch) = n.children() { + for (i, c) in ch.iter().enumerate() { + if let Some((new, k)) = map_node(c, f) { + let m = out.get_or_insert_with(|| n.clone()); + if let Some(slot) = m.children_mut().and_then(|v| v.get_mut(i)) { + *slot = Arc::new(new); + } + count += k; + } + } + } + out.map(|m| (m, count)) +} + +fn map_tree(n: &mut Arc, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { + match map_node(n, f) { + Some((new, count)) => { + *n = Arc::new(new); + count + } + None => 0, + } +} + +fn map_trees(v: &mut [Arc], f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { + v.iter_mut().map(|n| map_tree(n, f)).sum() +} + +#[cfg(test)] +mod tests { + use vectorcraft_color::SwatchGroup; + use vectorcraft_geom::{Rect, shapes}; + + use super::*; + use crate::Appearance; + + fn linked(color: Color, name: &str) -> Paint { + Paint::Solid { color, swatch: Some(name.into()) } + } + + /// A document with a grouped global swatch "Brand", a rectangle filled with it and an unlinked one. + fn doc() -> (Document, NodeId, NodeId) { + let mut d = Document::new(100.0, 100.0); + let red = Color::rgb(1.0, 0.0, 0.0); + d.swatch_groups.push(SwatchGroup { + name: "Mine".into(), + swatches: vec![Swatch { name: "Brand".into(), paint: Paint::solid(red), global: true, spot: false }], + }); + let layer = d.layers[0].id; + let mut ids = vec![]; + for paint in [linked(red, "Brand"), Paint::solid(red)] { + let id = d.alloc_id(); + let r = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::basic(paint, Paint::None, 0.0))).unwrap(); + ids.push(id); + } + (d, ids[0], ids[1]) + } + + #[test] + fn lookups_cover_colour_groups() { + let (mut d, _, _) = doc(); + let total = d.swatches.len() + d.swatch_groups.iter().map(|g| g.swatches.len()).sum::(); + assert_eq!(d.swatches_iter().count(), total); + assert!(d.swatch("Brand").is_some_and(|s| s.global)); + assert_eq!(d.swatch_group_of("Brand").map(|g| d.swatch_groups[g].name.as_str()), Some("Mine")); + d.swatch_mut("Brand").unwrap().spot = true; + assert!(d.swatch("Brand").unwrap().spot); + assert!(d.swatch_name_taken("Mine") && d.swatch_name_taken("Brand") && !d.swatch_name_taken("Nope")); + assert_eq!(d.remove_swatch("Brand").map(|s| s.name), Some("Brand".into())); + assert!(d.swatch("Brand").is_none() && d.remove_swatch("Brand").is_none()); + } + + #[test] + fn mapping_copies_only_changed_nodes() { + let (mut d, a, b) = doc(); + let before = d.clone(); + let blue = Color::rgb(0.0, 0.0, 1.0); + let n = d.map_solid_paints(&mut |c, s| { + if s.as_deref() != Some("Brand") { + return false; + } + *c = blue; + true + }); + assert_eq!(n, 1); + assert_eq!(d.node(a).unwrap().appearance.fill_paint(), linked(blue, "Brand")); + assert_eq!(d.node(b).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let unchanged = |d: &Document| d.layers[0].children().unwrap()[1].clone(); + assert!(Arc::ptr_eq(&unchanged(&d), &unchanged(&before)), "the unlinked rectangle is shared, not copied"); + // Nothing to change: the tree is untouched. + let again = d.clone(); + assert_eq!(d.map_solid_paints(&mut |_, _| false), 0); + assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0])); + } + + #[test] + fn mapping_within_ids_visits_each_paint_once() { + let (mut d, a, b) = doc(); + let layer = d.layers[0].id; + let mut seen = 0; + let n = d.map_solid_paints_in(&[layer, a], &mut |_, s| { + seen += 1; + s.take().is_some() + }); + assert_eq!((n, seen), (1, 2), "the layer covers `a`: each fill is visited once"); + assert_eq!(d.node(a).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let mut colors = vec![]; + node_colors(d.node(b).unwrap(), &mut |c, s| colors.push((*c, s.map(str::to_string)))); + assert_eq!(colors, [(Color::rgb(1.0, 0.0, 0.0), None)]); + } +} diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 230ed000..e06b0225 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -257,7 +257,7 @@ fn convert_mode(s: &mut Session, p: &Value) -> Result { } n }); - for sw in &mut d.swatches { + for sw in d.swatches_iter_mut() { match &mut sw.paint { Paint::Solid { color, .. } => *color = conv(color), Paint::Gradient(g) => { @@ -486,14 +486,14 @@ fn set_spot(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.setSpot"; let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); let spot = bool_or(p, "spot", true); - let sw = s.doc()?.doc.swatches.iter().find(|w| w.name == name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; + let sw = s.doc()?.doc.swatch(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; if !matches!(sw.paint, Paint::Solid { .. }) { return Err(bad(C, "only solid-colour swatches can be spot colours")); } if sw.spot != spot { let n = name.clone(); s.edit("Swatch Options", |d, _| { - if let Some(w) = d.swatches.iter_mut().find(|w| w.name == n) { + if let Some(w) = d.swatch_mut(&n) { w.spot = spot; if spot { w.global = true; diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 8d2cb49d..570dc855 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -113,7 +113,7 @@ fn info(s: &mut Session, p: &Value) -> Result { "images": images, "symbols": symbols, "spotColors": spot, - "swatches": d.swatches.len(), + "swatches": d.swatches_iter().count(), "graphicStyles": d.graphic_styles.len(), "characterStyles": d.char_styles.len(), "paragraphStyles": d.para_styles.len(), diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index fce823ea..b47a74fd 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -469,7 +469,7 @@ fn color_mode(s: &mut Session, p: &Value) -> Result { for l in &mut d.layers { map_node_colors(Arc::make_mut(l), &f, true, true); } - for sw in &mut d.swatches { + for sw in d.swatches_iter_mut() { if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint { *color = f(*color); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 020464db..4f49f21e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -733,6 +733,8 @@ mod tests_pattern; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_swatches; +#[cfg(test)] mod tests_textedit; #[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs new file mode 100644 index 00000000..2d068906 --- /dev/null +++ b/crates/engine/src/tests_swatches.rs @@ -0,0 +1,76 @@ +//! Swatches: colour groups as first-class swatch homes (colour mode, spot plates, PDF, Document +//! Info), naming and kinds, Swatch Options edits reaching linked art, deleting and new groups. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn rect(s: &mut Session) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()) +} + +fn doc(s: &Session) -> &vectorcraft_doc::Document { + &s.doc().unwrap().doc +} + +/// Put a solid swatch into colour group `group` directly in the model (no command creates one in a +/// group before M3.13). +fn grouped_swatch(s: &mut Session, group: &str, name: &str, color: Color) { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let g = d.swatch_groups.iter_mut().find(|g| g.name == group).unwrap(); + g.swatches.push(vectorcraft_color::Swatch { name: name.into(), paint: Paint::solid(color), global: true, spot: false }); +} + +#[test] +fn document_color_mode_converts_grouped_swatches() { + let mut s = session(); + grouped_swatch(&mut s, "Grays", "Ink", Color::rgb(0.2, 0.4, 0.6)); + run(&mut s, "object.convertDocumentColorMode", json!({"mode": "cmyk"})); + for g in &doc(&s).swatch_groups { + for sw in &g.swatches { + assert!(!matches!(sw.paint.color(), Some(Color::Rgb { .. })), "{} in {} is still RGB", sw.name, g.name); + } + } + assert!(matches!(doc(&s).swatch("Ink").unwrap().paint.color(), Some(Color::Cmyk { .. }))); + // The File menu's mode switch converts them as well. + let mut s = session(); + run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"})); + assert!(matches!(doc(&s).swatch("Bright Red").unwrap().paint.color(), Some(Color::Cmyk { .. }))); +} + +#[test] +fn grouped_spot_swatches_print_on_their_own_plate() { + let mut s = session(); + grouped_swatch(&mut s, "Brights", "Signal Orange", Color::cmyk(0.0, 0.6, 1.0, 0.0)); + assert_eq!(run(&mut s, "swatch.setSpot", json!({"name": "Signal Orange"})), json!({"name": "Signal Orange", "spot": true})); + assert!(doc(&s).swatch("Signal Orange").is_some_and(|w| w.spot && w.global)); + let plates = run(&mut s, "color.plates", json!({})); + assert!(plates["plates"].as_array().unwrap().iter().any(|p| p["name"] == "Signal Orange" && p["spot"] == true), "{plates}"); + let a = rect(&mut s); + run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Signal Orange"})); + let bytes = vectorcraft_pdf::export(doc(&s), &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap(); + let pdf = String::from_utf8_lossy(&bytes); + assert!(pdf.contains("/Separation") && pdf.contains("Signal#20Orange"), "a grouped spot swatch is a Separation"); + let info = run(&mut s, "document.info", json!({})); + assert_eq!(info["spotColors"], json!(["Signal Orange"])); +} + +#[test] +fn document_info_counts_grouped_swatches() { + let mut s = session(); + let d = doc(&s); + let all = d.swatches.len() + d.swatch_groups.iter().map(|g| g.swatches.len()).sum::(); + assert!(all > d.swatches.len()); + assert_eq!(run(&mut s, "document.info", json!({}))["swatches"], json!(all)); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 20cea8e3..bcb5c847 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -215,9 +215,7 @@ impl Exporter<'_> { /// swatch's CMYK equivalent is the alternate space). fn solid(&mut self, c: &Color, swatch: Option<&str>) -> krilla::color::Color { use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace}; - let spot = swatch - .and_then(|n| self.doc.swatches.iter().find(|s| s.spot && s.name == n)) - .and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc))); + let spot = swatch.and_then(|n| self.doc.swatch(n).filter(|s| s.spot)).and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc))); let Some((name, sc)) = spot else { return self.col(c) }; let cms = vectorcraft_color::cms::active(); let intent = cms.settings().intent; diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 7d51b31c..22ac9629 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -58,7 +58,7 @@ pub fn plates(doc: &Document) -> Vec { Plate { name: (*n).into(), spot: false, rgb: c.cmyk_to_srgb(ink, false) } }) .collect(); - for sw in &doc.swatches { + for sw in doc.swatches_iter() { if let (true, Paint::Solid { color, .. }) = (sw.spot, &sw.paint) && !v.iter().any(|p| p.name == sw.name) { @@ -69,7 +69,7 @@ pub fn plates(doc: &Document) -> Vec { } fn spot_swatch<'a>(doc: &'a Document, name: &str) -> Option<&'a Color> { - doc.swatches.iter().find(|s| s.spot && s.name == name).and_then(|s| match &s.paint { + doc.swatch(name).filter(|s| s.spot).and_then(|s| match &s.paint { Paint::Solid { color, .. } => Some(color), _ => None, }) From 2f21fc3a66800cd5587f0580e5aeca5385fe03bc Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:20:52 +0700 Subject: [PATCH 2/8] M3.10: swatch names follow the colour model (C=10 M=20 Y=30 K=0, R=.. G=.. B=.., Gray K=..) and are unique document-wide; New Swatch saves the active gradient or pattern, Ctrl/Cmd-click makes a spot colour; colour groups take solid colours only Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/swatch.rs | 82 +++++++++++++++++++-------- crates/engine/src/tests_swatches.rs | 50 ++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 5 +- crates/ui-egui/src/panels/swatches.rs | 15 +++-- 4 files changed, 119 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 7b5793d9..84a509d3 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -3,6 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup}; use vectorcraft_doc::Document; +use vectorcraft_doc::pattern::pattern_paint; use super::paint::paint_from; use super::*; @@ -10,14 +11,22 @@ use crate::EngineError; pub fn specs() -> Vec { vec![ - cmd!("swatch.new", "New Swatch", ["Window", "Swatches"], None, "{name?, color?, global?} (default: current fill)", has_doc, swatch_new), + cmd!( + "swatch.new", + "New Swatch", + ["Window", "Swatches"], + None, + "{name?, color? | swatch? | gradient? | pattern?: name (default: the current fill), global?, spot? (a spot colour, always global)} save a colour, gradient or pattern as a swatch. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name}", + has_doc, + swatch_new + ), cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete), cmd!( "swatch.newGroup", "New Color Group", ["Window", "Swatches"], None, - "{name?, swatches?: [names] (moved into the group), colors?: [colour] (added as new swatches)}", + "{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches)} → {name}", has_doc, swatch_new_group ), @@ -27,21 +36,29 @@ pub fn specs() -> Vec { } fn swatch_new(s: &mut Session, p: &Value) -> Result { - let paint = match paint_from(s, p)? { - Some(x) => x, - None => s.paint.fill.clone(), + const C: &str = "swatch.new"; + let paint = match str_param(p, "pattern") { + Some(name) if s.doc()?.doc.pattern(name).is_some() => pattern_paint(name), + Some(name) => return Err(bad(C, format!("no pattern `{name}`"))), + None => paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()), }; - let name = match str_param(p, "name") { - Some(n) => n.to_string(), - None => match &paint { - Paint::Solid { color, .. } => rgb_name(*color), - _ => format!("Swatch {}", s.doc()?.doc.swatches.len() + 1), - }, + let spot = bool_or(p, "spot", false); + let global = spot || bool_or(p, "global", false); + let paint = match paint { + // The swatch holds the colour itself, not a link to the swatch it may have come from. + Paint::Solid { color, .. } => Paint::solid(color), + Paint::None => return Err(bad(C, "a swatch needs a colour, gradient or pattern")), + _ if spot => return Err(bad(C, "only solid colours can be spot colours")), + other => other, }; - let global = bool_or(p, "global", false); - s.edit("New Swatch", |d, _| { - d.swatches.push(Swatch { name: name.clone(), paint, global, spot: false }); - Ok(()) + let requested = str_param(p, "name").map(str::to_string); + let name = s.edit("New Swatch", |d, _| { + let name = match requested { + Some(n) => free_name(d, &n), + None => default_name(d, &paint), + }; + d.swatches.push(Swatch { name: name.clone(), paint, global, spot }); + Ok(name) })?; Ok(json!({ "name": name })) } @@ -58,15 +75,30 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { ok() } -/// The default name of a new solid swatch: its 8-bit RGB values. -fn rgb_name(c: Color) -> String { - let [r, g, b] = c.to_rgb(); - format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()) +/// The default name of a new solid swatch: its values in its own colour model. +fn color_name(c: Color) -> String { + let pct = |v: f32| (v * 100.0).round(); + let byte = |v: f32| (v * 255.0).round(); + match c { + Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), + Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)), + Color::Gray { k } => format!("Gray K={}", pct(k)), + } +} + +/// A free default name for a new swatch of `paint`: its colour values, or "New Gradient Swatch 1"… +fn default_name(d: &Document, paint: &Paint) -> String { + let base = match paint { + Paint::Solid { color, .. } => return free_name(d, &color_name(*color)), + Paint::Gradient(_) => "New Gradient Swatch", + _ => "New Pattern Swatch", + }; + (1..).map(|i| format!("{base} {i}")).find(|n| !d.swatch_name_taken(n)).unwrap_or_else(|| base.to_string()) } /// A swatch or colour-group name not used yet in `d`. fn free_name(d: &Document, base: &str) -> String { - unique_name(base, |n| d.swatch(n).is_some() || d.swatch_groups.iter().any(|g| g.name == n)) + unique_name(base, |n| d.swatch_name_taken(n)) } fn swatch_new_group(s: &mut Session, p: &Value) -> Result { @@ -77,13 +109,17 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { let name = s.edit("New Color Group", |d, _| { let name = free_name(d, requested.as_deref().unwrap_or("Color Group")); let mut group = SwatchGroup { name: name.clone(), swatches: vec![] }; + // Colour groups hold solid colours only. for n in &names { - if let Some(pos) = d.swatches.iter().position(|sw| &sw.name == n) { - group.swatches.push(d.swatches.remove(pos)); + if d.swatch(n).is_some_and(|sw| matches!(sw.paint, Paint::Solid { .. })) + && let Some(sw) = d.remove_swatch(n) + { + group.swatches.push(sw); } } for c in &colors { - group.swatches.push(Swatch { name: rgb_name(*c), paint: Paint::solid(*c), global: false, spot: false }); + let nm = unique_name(&color_name(*c), |n| d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n)); + group.swatches.push(Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false }); } d.swatch_groups.push(group); Ok(name) diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs index 2d068906..f54722d6 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests_swatches.rs @@ -74,3 +74,53 @@ fn document_info_counts_grouped_swatches() { assert!(all > d.swatches.len()); assert_eq!(run(&mut s, "document.info", json!({}))["swatches"], json!(all)); } + +#[test] +fn new_swatches_are_named_by_their_colour_model() { + let mut s = session(); + let name = |s: &mut Session, p: Value| run(s, "swatch.new", p)["name"].as_str().unwrap().to_string(); + assert_eq!(name(&mut s, json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})), "C=10 M=20 Y=30 K=0"); + assert_eq!(name(&mut s, json!({"color": "#ff8000"})), "R=255 G=128 B=0"); + assert_eq!(name(&mut s, json!({"color": {"gray": 40}})), "Gray K=40"); + // The same colour again (or an explicit name in use) gets the next free number, document-wide. + assert_eq!(name(&mut s, json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})), "C=10 M=20 Y=30 K=0 2"); + assert_eq!(name(&mut s, json!({"name": "Grays", "color": "#000000"})), "Grays 2"); + assert_eq!(name(&mut s, json!({"name": "Bright Red", "color": "#000000"})), "Bright Red 2"); + // A spot swatch is always global; saving a linked colour stores the colour, not the link. + let spot = name(&mut s, json!({"name": "Ink", "color": "#336699", "spot": true})); + assert!(doc(&s).swatch(&spot).is_some_and(|w| w.spot && w.global)); + let copy = name(&mut s, json!({"swatch": "Ink"})); + assert_eq!(doc(&s).swatch(©).unwrap().paint, Paint::solid(Color::from_hex("#336699").unwrap())); + assert!(s.execute("swatch.new", &json!({"none": true})).is_err(), "None is not a swatch"); +} + +#[test] +fn new_swatch_saves_the_active_pattern_or_gradient() { + let mut s = session(); + let a = rect(&mut s); + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "object.pattern.make", json!({})); + run(&mut s, "object.pattern.done", json!({})); + let pattern = doc(&s).patterns[0].name.clone(); + let name = run(&mut s, "swatch.new", json!({"pattern": pattern}))["name"].as_str().unwrap().to_string(); + assert_eq!(name, "New Pattern Swatch 1"); + assert!(matches!(&doc(&s).swatch(&name).unwrap().paint, Paint::Pattern { pattern: p, .. } if *p == pattern)); + assert!(s.execute("swatch.new", &json!({"pattern": "Nope"})).is_err()); + let g = json!({"gradient": {"kind": "radial", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}}); + let name = run(&mut s, "swatch.new", g)["name"].as_str().unwrap().to_string(); + assert_eq!(name, "New Gradient Swatch 1"); + assert!(matches!(doc(&s).swatch(&name).unwrap().paint, Paint::Gradient(_))); + assert!(s.execute("swatch.new", &json!({"pattern": pattern, "spot": true})).is_err(), "only colours are spot"); +} + +#[test] +fn colour_groups_hold_solid_colours_only() { + let mut s = session(); + let g = run(&mut s, "swatch.newGroup", json!({"name": "Mix", "swatches": ["Sunset", "Red", "[None]", "Bright Blue"], "colors": ["#00ff00"]})); + let d = doc(&s); + let group = d.swatch_groups.iter().find(|x| x.name == g["name"]).unwrap(); + let names: Vec<&str> = group.swatches.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, ["Red", "Bright Blue", "R=0 G=255 B=0"]); + assert!(d.swatches.iter().any(|w| w.name == "Sunset") && d.swatches.iter().any(|w| w.name == "[None]"), "gradients and None stay put"); + assert!(d.swatch_groups.iter().find(|x| x.name == "Brights").unwrap().swatches.iter().all(|w| w.name != "Bright Blue"), "moved, not copied"); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0b163e51..b6f319a3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -274,7 +274,7 @@ pub(crate) fn color_json(c: &Color) -> Value { } } -/// Paint as command params (`{color}`, `{none}` or `{gradient}`). +/// Paint as command params (`{color}`, `{swatch}`, `{none}`, `{gradient}` or `{pattern}`). pub(crate) fn paint_params(p: &Paint) -> Value { match p { Paint::None => json!({"none": true}), @@ -285,7 +285,7 @@ pub(crate) fn paint_params(p: &Paint) -> Value { "angle": g.angle, "stops": g.gradient.stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity})).collect::>() }}), - Paint::Pattern { .. } => json!({"none": true}), + Paint::Pattern { pattern, .. } => json!({"pattern": pattern}), } } @@ -321,5 +321,6 @@ mod tests { let p = paint_params(&g); assert_eq!(p["gradient"]["kind"], "linear"); assert_eq!(p["gradient"]["stops"].as_array().unwrap().len(), 2); + assert_eq!(paint_params(&vectorcraft_doc::pattern::pattern_paint("Dots")), json!({"pattern": "Dots"})); } } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 2320f8f7..e190419a 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -370,8 +370,9 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) { let names: Vec = deletable(ui).into_iter().collect(); app.run("swatch.newGroup", json!({"swatches": names})).ok(); } + // Ctrl/Cmd-click makes a spot colour (Alt-click skips the dialog). if widgets::icon_button(ui, "dc-new-item", "New Swatch", false, 24.0).clicked() { - new_swatch(app); + new_swatch(app, ui.input(|i| i.modifiers.command)); } let del = deletable(ui); if widgets::icon_button_enabled(ui, "trash-2", "Delete Swatch", false, del.is_some(), 24.0).clicked() @@ -383,12 +384,10 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) { }); } -fn new_swatch(app: &mut VectorcraftApp) { - let p = active_paint(app); - let params = match &p { - Paint::None => json!({}), - other => super::paint_params(other), - }; +/// Save the active paint as a new swatch (a spot colour with `spot`). +fn new_swatch(app: &mut VectorcraftApp, spot: bool) { + let mut params = super::paint_params(&active_paint(app)); + params["spot"] = json!(spot); app.run("swatch.new", params).ok(); } @@ -396,7 +395,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let view: View = pstate(ui.ctx(), "swatch-view"); let sel = deletable(ui); if menu_item(ui, "New Swatch…", true, false) { - new_swatch(app); + new_swatch(app, false); } if menu_item(ui, "New Color Group…", true, false) { app.run("swatch.newGroup", json!({"swatches": sel.clone().into_iter().collect::>()})).ok(); From d272d32be3128eaca6eae88bf57d9db3aebb16dc Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:32:17 +0700 Subject: [PATCH 3/8] =?UTF-8?q?M3.11:=20Swatch=20Options=20=E2=80=94=20dou?= =?UTF-8?q?ble-click,=20the=20options=20button=20or=20the=20menu=20item=20?= =?UTF-8?q?opens=20a=20colour=20swatch's=20editor=20(name,=20process/spot,?= =?UTF-8?q?=20Global,=20Gray/RGB/HSB/CMYK/Web=20sliders,=20hex)=20with=20a?= =?UTF-8?q?=20live=20canvas=20preview;=20gradients=20open=20the=20Gradient?= =?UTF-8?q?=20panel=20and=20patterns=20pattern=20editing;=20swatch.edit=20?= =?UTF-8?q?recolours=20and=20renames=20every=20linked=20fill,=20stroke=20a?= =?UTF-8?q?nd=20text=20run=20in=20one=20undo=20step,=20spot=20forces=20Glo?= =?UTF-8?q?bal,=20grouped=20swatches=20edit=20too;=20swatch.list=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui.dialog.cancel over the control channel now rolls back a dialog's live preview. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/swatch.rs | 164 +++++++++++- crates/engine/src/tests_swatches.rs | 94 +++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/dialogs/form.rs | 14 +- crates/ui-egui/src/dialogs/mod.rs | 9 + crates/ui-egui/src/dialogs/swatch_options.rs | 253 +++++++++++++++++++ crates/ui-egui/src/menus.rs | 11 + crates/ui-egui/src/panels/swatches.rs | 142 +++++++++-- docs/control-protocol.md | 6 + docs/mcp.md | 4 + 10 files changed, 679 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/swatch_options.rs diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 84a509d3..f72c72e0 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -32,6 +32,24 @@ pub fn specs() -> Vec { ), cmd!("swatch.duplicate", "Duplicate Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_duplicate), cmd!("swatch.sortByName", "Sort by Name", ["Window", "Swatches"], None, "{}", has_doc, swatch_sort), + cmd!( + "swatch.edit", + "Swatch Options", + ["Window", "Swatches"], + None, + "{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global)} edit a swatch in any colour group, as one undo step. Fills, strokes and text linked to a global swatch take its new colour and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only → {name, relinked: paints changed}", + has_doc, + swatch_edit + ), + cmd!( + query "swatch.list", + "Swatches", + [], + None, + "{group?: name (only that colour group's swatches)} → {swatches: [{name, kind: \"none\"|\"color\"|\"gradient\"|\"pattern\", group, global, spot, color?, hex?, gradient?, pattern?}], groups: [{name, swatches: [names]}]}", + has_doc, + swatch_list + ), ] } @@ -51,7 +69,7 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { _ if spot => return Err(bad(C, "only solid colours can be spot colours")), other => other, }; - let requested = str_param(p, "name").map(str::to_string); + let requested = name_param(p, "name"); let name = s.edit("New Swatch", |d, _| { let name = match requested { Some(n) => free_name(d, &n), @@ -144,6 +162,150 @@ fn swatch_duplicate(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": new})) } +/// A trimmed, non-empty name parameter. +fn name_param(p: &Value, key: &str) -> Option { + str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) +} + +/// `c` converted to a Swatch Options colour mode through the colour settings (HSB is an RGB view; +/// Web snaps each channel to a multiple of 0x33). +fn convert_to_mode(c: Color, mode: &str, cmd: &str) -> Result { + use vectorcraft_color::cms::Model; + let cms = vectorcraft_color::cms::active(); + let to = |m: Model| cms.convert(&c, m, cms.settings().intent); + Ok(match mode.to_ascii_lowercase().as_str() { + "gray" | "grayscale" => to(Model::Gray), + "rgb" | "hsb" => to(Model::Rgb), + "cmyk" => to(Model::Cmyk), + "web" => { + let snap = |v: f32| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0; + let [r, g, b] = to(Model::Rgb).to_rgb_uncalibrated(); + Color::rgb(snap(r), snap(g), snap(b)) + } + other => return Err(bad(cmd, format!("unknown mode `{other}` (gray, rgb, hsb, cmyk or web)"))), + }) +} + +/// How a swatch edit reaches the solid paints linked to swatch `from`. +struct Relink { + from: String, + /// The swatch's name after the edit. + to: String, + /// The new colour of a global swatch (linked paints take it). + color: Option, + /// false: drop the links (the swatch stopped being global or is deleted); paints keep their colour. + keep: bool, +} + +impl Relink { + /// Apply to one paint; true when it changed. + fn apply(&self, c: &mut Color, link: &mut Option) -> bool { + if link.as_deref() != Some(self.from.as_str()) { + return false; + } + if !self.keep { + *link = None; + return true; + } + let renamed = self.to != self.from; + if renamed { + *link = Some(self.to.clone()); + } + let recolored = self.color.is_some_and(|n| n != *c); + if let Some(n) = self.color { + *c = n; + } + renamed || recolored + } + + /// Apply to the default fill and stroke for new art, except during a live preview (Cancel rolls + /// the document back, not the session). + fn defaults(&self, s: &mut Session) { + if s.in_interaction() { + return; + } + for p in [&mut s.paint.fill, &mut s.paint.stroke] { + if let Paint::Solid { color, swatch } = p { + self.apply(color, swatch); + } + } + } +} + +fn swatch_edit(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.edit"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); + let d = &s.doc()?.doc; + let sw = d.swatch(&name).cloned().ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; + if sw.paint.is_none() { + return Err(bad(C, format!("`{name}` can't be edited"))); + } + let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + let solid = sw.paint.color(); + if solid.is_none() && (given("color") || given("mode") || bool_or(p, "spot", false)) { + return Err(bad(C, "colour, mode and spot apply to solid-colour swatches only")); + } + let mut color = match p.get("color").filter(|v| !v.is_null()) { + Some(v) => Some(color_value(v).ok_or_else(|| bad(C, format!("bad color {v}")))?), + None => solid, + }; + if let (Some(c), Some(m)) = (color, str_param(p, "mode")) { + color = Some(convert_to_mode(c, m, C)?); + } + let spot = bool_or(p, "spot", sw.spot); + let global = spot || bool_or(p, "global", sw.global); + let to = name_param(p, "newName").filter(|n| *n != name).map_or_else(|| name.clone(), |n| free_name(d, &n)); + let relink = Relink { from: name.clone(), to: to.clone(), color: color.filter(|_| global), keep: global }; + let relinked = s.edit("Swatch Options", |d, _| { + let w = d.swatch_mut(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; + w.name = to.clone(); + w.spot = spot; + w.global = global; + if let (Some(c), Paint::Solid { color: wc, .. }) = (color, &mut w.paint) { + *wc = c; + } + Ok(d.map_solid_paints(&mut |c, l| relink.apply(c, l))) + })?; + relink.defaults(s); + Ok(json!({"name": to, "relinked": relinked})) +} + +fn swatch_json(sw: &Swatch, group: Option<&str>) -> Value { + let mut v = json!({"name": sw.name, "group": group, "global": sw.global, "spot": sw.spot}); + match &sw.paint { + Paint::None => v["kind"] = json!("none"), + Paint::Solid { color, .. } => { + v["kind"] = json!("color"); + v["color"] = json!(color); + v["hex"] = json!(color.to_hex()); + } + Paint::Gradient(g) => { + v["kind"] = json!("gradient"); + v["gradient"] = json!(g.gradient.kind.label().to_lowercase()); + } + Paint::Pattern { pattern, .. } => { + v["kind"] = json!("pattern"); + v["pattern"] = json!(pattern); + } + } + v +} + +fn swatch_list(s: &mut Session, p: &Value) -> Result { + let d = &s.doc()?.doc; + let only = str_param(p, "group"); + let groups: Vec<&SwatchGroup> = match only { + Some(g) => vec![d.swatch_groups.iter().find(|x| x.name == g).ok_or_else(|| bad("swatch.list", format!("no colour group `{g}`")))?], + None => d.swatch_groups.iter().collect(), + }; + let ungrouped = d.swatches.iter().filter(|_| only.is_none()).map(|w| swatch_json(w, None)); + let grouped = groups.iter().flat_map(|g| g.swatches.iter().map(|w| swatch_json(w, Some(&g.name)))); + let swatches: Vec = ungrouped.chain(grouped).collect(); + let groups: Vec = + groups.iter().map(|g| json!({"name": g.name, "swatches": g.swatches.iter().map(|w| &w.name).collect::>()})).collect(); + Ok(json!({"swatches": swatches, "groups": groups})) +} + fn swatch_sort(s: &mut Session, _: &Value) -> Result { s.edit("Sort Swatches", |d, _| { // [None] and other bracketed specials stay first, like the reference app. diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs index f54722d6..aab7203f 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests_swatches.rs @@ -124,3 +124,97 @@ fn colour_groups_hold_solid_colours_only() { assert!(d.swatches.iter().any(|w| w.name == "Sunset") && d.swatches.iter().any(|w| w.name == "[None]"), "gradients and None stay put"); assert!(d.swatch_groups.iter().find(|x| x.name == "Brights").unwrap().swatches.iter().all(|w| w.name != "Bright Blue"), "moved, not copied"); } + +fn fill_of(s: &Session, id: NodeId) -> Paint { + doc(s).node(id).unwrap().appearance.fill_paint() +} + +fn linked(hex: &str, name: &str) -> Paint { + Paint::Solid { color: Color::from_hex(hex).unwrap(), swatch: Some(name.into()) } +} + +#[test] +fn editing_a_global_swatch_recolours_linked_art_in_one_step() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true})); + let (a, b) = (rect(&mut s), rect(&mut s)); + run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Brand"})); + run(&mut s, "paint.setFill", json!({"ids": [b.0], "color": "#2a6fb0"})); + let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Hi"}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setFill", json!({"ids": [t.0], "swatch": "Brand"})); + run(&mut s, "select.set", json!({"ids": []})); + run(&mut s, "paint.setStroke", json!({"swatch": "Brand"})); + let undo_depth = s.doc().unwrap().history.undo.len(); + let r = run(&mut s, "swatch.edit", json!({"name": "Brand", "color": "#ff0000"})); + assert_eq!(r, json!({"name": "Brand", "relinked": 2})); + assert_eq!(fill_of(&s, a), linked("#ff0000", "Brand")); + assert_eq!(fill_of(&s, b), Paint::solid(Color::from_hex("#2a6fb0").unwrap()), "unlinked art keeps its colour"); + let text_fill = |s: &Session| match &doc(s).node(t).unwrap().kind { + vectorcraft_doc::NodeKind::Text(tx) => tx.runs[0].style.fill.clone(), + _ => unreachable!(), + }; + assert_eq!(text_fill(&s), linked("#ff0000", "Brand"), "text runs follow the swatch"); + assert_eq!(s.paint.stroke, linked("#ff0000", "Brand"), "so does the default stroke"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth + 1, "one undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Brand")); + assert_eq!(doc(&s).swatch("Brand").unwrap().paint.color(), Color::from_hex("#2a6fb0")); +} + +#[test] +fn renaming_relinks_and_spot_forces_global() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true})); + let a = rect(&mut s); + run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Brand"})); + // A name in use gets the next free number. + let r = run(&mut s, "swatch.edit", json!({"name": "Brand", "newName": "Red"})); + assert_eq!(r, json!({"name": "Red 2", "relinked": 1})); + assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Red 2")); + assert!(doc(&s).swatch("Brand").is_none()); + run(&mut s, "swatch.edit", json!({"name": "Red 2", "spot": true, "global": false})); + assert!(doc(&s).swatch("Red 2").is_some_and(|w| w.spot && w.global), "spot colours stay global"); + // Turning Global off (no longer spot) unlinks the art, which keeps its colour. + let r = run(&mut s, "swatch.edit", json!({"name": "Red 2", "spot": false, "global": false, "color": "#000000"})); + assert_eq!(r["relinked"], 1); + assert_eq!(fill_of(&s, a), Paint::solid(Color::from_hex("#2a6fb0").unwrap())); + assert!(doc(&s).swatch("Red 2").is_some_and(|w| !w.global && w.paint.color() == Some(Color::BLACK))); +} + +#[test] +fn grouped_swatches_edit_and_convert_modes() { + let mut s = session(); + let r = run(&mut s, "swatch.edit", json!({"name": "Bright Red", "mode": "cmyk", "global": true})); + assert_eq!(r["name"], "Bright Red"); + let w = doc(&s).swatch("Bright Red").unwrap(); + assert!(w.global && matches!(w.paint.color(), Some(Color::Cmyk { .. }))); + assert_eq!(doc(&s).swatch_group_of("Bright Red").map(|g| doc(&s).swatch_groups[g].name.clone()), Some("Brights".into())); + run(&mut s, "swatch.edit", json!({"name": "Bright Red", "color": "#123456", "mode": "web"})); + assert_eq!(doc(&s).swatch("Bright Red").unwrap().paint.color().unwrap().to_hex(), "#003366"); + run(&mut s, "swatch.edit", json!({"name": "Bright Red", "mode": "gray"})); + assert!(matches!(doc(&s).swatch("Bright Red").unwrap().paint.color(), Some(Color::Gray { .. }))); + // Gradients can be renamed but have no colour, mode or spot; None can't be edited. + assert!(s.execute("swatch.edit", &json!({"name": "Sunset", "color": "#000000"})).is_err()); + assert!(s.execute("swatch.edit", &json!({"name": "Sunset", "spot": true})).is_err()); + assert!(s.execute("swatch.edit", &json!({"name": "[None]", "newName": "x"})).is_err()); + assert!(s.execute("swatch.edit", &json!({"name": "Bright Red", "mode": "lab"})).is_err()); + assert_eq!(run(&mut s, "swatch.edit", json!({"name": "Sunset", "newName": "Dusk"}))["name"], "Dusk"); +} + +#[test] +fn swatch_list_reports_kinds_and_groups() { + let mut s = session(); + let all = run(&mut s, "swatch.list", json!({})); + let list = all["swatches"].as_array().unwrap(); + assert_eq!(list.len(), doc(&s).swatches_iter().count()); + let find = |n: &str| list.iter().find(|w| w["name"] == n).unwrap().clone(); + assert_eq!(find("[None]")["kind"], "none"); + assert_eq!(find("Red")["kind"], "color"); + assert_eq!(find("Red")["hex"], "#ed1c24"); + assert_eq!(find("Sunset")["kind"], "gradient"); + assert_eq!(find("K=50")["group"], "Grays"); + let grays = run(&mut s, "swatch.list", json!({"group": "Grays"})); + assert_eq!(grays["swatches"].as_array().unwrap().len(), 9); + assert_eq!(grays["groups"][0]["name"], "Grays"); + assert!(s.execute("swatch.list", &json!({"group": "Nope"})).is_err()); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e4431f94..d2de3a52 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -260,7 +260,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques }, "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { - app.ui.dialog = None; + crate::dialogs::cancel(app); ok(Value::Null) } "ui.resize" => { diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index d30b461a..9a6d1416 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -10,17 +10,25 @@ use crate::theme::Tokens; pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(label).color(t.text_dim)); + text(ui, d, key, 120.0); + ui.end_row(); +} + +/// A text field `width` wide bound to `d.fields[key]`. Returns true when it changed. +pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> bool { + let t = Tokens::get(ui.ctx()); let mut s = d.str(key); let r = egui::Frame::NONE .fill(t.input) .stroke(egui::Stroke::new(1.0, t.input_border)) .corner_radius(egui::CornerRadius::same(3)) .inner_margin(egui::Margin::symmetric(6, 3)) - .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0))); - if r.inner.changed() { + .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(width))); + let changed = r.inner.changed(); + if changed { d.fields.insert(key.into(), Value::String(s)); } - ui.end_row(); + changed } /// A checkbox bound to `d.fields[key]`. diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 9532e989..36167848 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -19,6 +19,7 @@ mod path_ops; mod recolor; mod save_changes; mod shapes; +pub mod swatch_options; mod tools; mod transform; @@ -127,6 +128,7 @@ registry! { Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)), Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)), FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)), + SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -142,6 +144,13 @@ fn run_and_close(app: &mut VectorcraftApp, id: &str, params: Value) -> DialogRes r } +/// Close the open dialog as Cancel does, rolling back a live preview (`ui.dialog.cancel`). +pub fn cancel(app: &mut VectorcraftApp) { + if app.ui.dialog.take().is_some_and(|d| spec(&d.kind).preview) { + let _ = app.session.cancel_interaction(); + } +} + /// Apply the open dialog (OK). pub fn confirm(app: &mut VectorcraftApp) -> DialogResult { let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs new file mode 100644 index 00000000..5b6622ee --- /dev/null +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -0,0 +1,253 @@ +//! Swatch Options: name, colour type (process or spot), Global, colour mode with sliders and hex, +//! previewed live on the canvas (Cancel rolls the preview back). Double-clicking a swatch opens it +//! for a solid colour; gradients open the Gradient panel and patterns pattern editing ([`open`]). +//! +//! Fields: `__swatch` (the swatch being edited), `name`, `spot`, `global`, `mode` (`gray`, `rgb`, +//! `hsb`, `cmyk` or `web`), `color` (`"#rrggbb"` or a colour object such as +//! `{"model": "cmyk", "c": 0.1, "m": 0.2, "y": 0.3, "k": 0}`) and `preview`. + +use egui::vec2; +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; + +use super::{DialogSpec, form, run_and_close}; +use crate::VectorcraftApp; +use crate::panels::color::{Mode, components, from_components, parse_hex, track_color, web_safe}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::widgets; + +/// The dialog kind of Swatch Options. +pub const KIND: &str = "swatchOptions"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Swatch Options".into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM }; + +/// Colour modes with their `mode` field / `swatch.edit` ids. +const MODES: [(Mode, &str); 5] = [(Mode::Grayscale, "gray"), (Mode::Rgb, "rgb"), (Mode::Hsb, "hsb"), (Mode::Cmyk, "cmyk"), (Mode::WebSafe, "web")]; + +const TYPES: [&str; 2] = ["Process Color", "Spot Color"]; + +fn mode_id(m: Mode) -> &'static str { + MODES.iter().find(|x| x.0 == m).map_or("rgb", |x| x.1) +} + +fn mode_of(d: &Dialog) -> Mode { + let id = d.str("mode"); + MODES.iter().find(|x| x.1 == id).map_or(Mode::Rgb, |x| x.0) +} + +/// The dialog's colour: a hex string or a serialized colour. +fn color_of(d: &Dialog) -> Color { + match d.fields.get("color") { + Some(Value::String(s)) => Color::from_hex(s), + Some(v) => serde_json::from_value(v.clone()).ok(), + None => None, + } + .unwrap_or(Color::BLACK) +} + +fn set_color(d: &mut Dialog, c: Color) { + d.fields.insert("color".into(), json!(c)); +} + +/// Open the editor of swatch `name`: Swatch Options for a colour, the Gradient panel (with the +/// gradient applied to the active proxy) for a gradient, pattern editing for a pattern. +pub fn open(app: &mut VectorcraftApp, name: &str) -> Result { + let sw = app.session.active().and_then(|st| st.doc.swatch(name).cloned()).ok_or_else(|| format!("no swatch `{name}`"))?; + match &sw.paint { + Paint::Solid { color, .. } => { + let fields = json!({ + "__swatch": name, "name": name, "spot": sw.spot, "global": sw.global, + "mode": mode_id(Mode::of(color)), "color": color, "preview": true, + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) + } + Paint::Gradient(_) => { + app.run(crate::panels::paint_target(app), json!({"swatch": name}))?; + app.ui.open_panel = Some("gradient".into()); + Ok(Value::Null) + } + Paint::Pattern { pattern, .. } => { + app.run("object.pattern.edit", json!({"name": pattern}))?; + app.ui.open_panel = Some("patternOptions".into()); + Ok(Value::Null) + } + Paint::None => Err(format!("`{name}` has no options")), + } +} + +/// `swatch.edit` parameters from the fields. +fn edit_params(d: &Dialog) -> Value { + json!({ + "name": d.str("__swatch"), + "newName": d.str("name"), + "color": color_of(d), + "mode": d.str("mode"), + "global": d.bool("global"), + "spot": d.bool("spot"), + }) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let changed = editor(ui, d); + ui.add_space(8.0); + let mut pv = d.bool("preview"); + let pv_changed = widgets::check(ui, "Preview", pv, true); + if pv_changed { + pv = !pv; + d.fields.insert("preview".into(), json!(pv)); + } + if pv && (changed || pv_changed || !app.session.in_interaction()) { + let _ = app.session.begin_interaction("Swatch Options"); + if let Err(e) = app.session.preview("swatch.edit", &edit_params(d)) { + app.status(e.to_string()); + } + } else if !pv && pv_changed { + let _ = app.session.cancel_interaction(); + } + false +} + +/// Drop the preview and apply the edit as one undo step (which also updates the default paints). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let _ = app.session.cancel_interaction(); + run_and_close(app, "swatch.edit", edit_params(d)) +} + +/// The shared fields of Swatch Options and New Swatch: name, colour type, Global, mode, sliders and +/// hex. Returns true when anything changed. +pub(super) fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let label = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim)); + let mut changed = false; + egui::Grid::new("swatch-options").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + label(ui, "Swatch Name:"); + changed |= form::text(ui, d, "name", 190.0); + ui.end_row(); + label(ui, "Color Type:"); + let spot = d.bool("spot"); + if let Some(i) = widgets::dropdown(ui, "swatch-type", TYPES[usize::from(spot)], &TYPES, 200.0) { + d.fields.insert("spot".into(), json!(i == 1)); + changed = true; + } + ui.end_row(); + ui.label(""); + // Spot colours are always global. + let global = spot || d.bool("global"); + if widgets::check(ui, "Global", global, !spot) { + d.fields.insert("global".into(), json!(!global)); + changed = true; + } + ui.end_row(); + label(ui, "Color Mode:"); + let mode = mode_of(d); + let labels: Vec<&str> = Mode::ALL.iter().map(|m| m.1).collect(); + let current = Mode::ALL.iter().find(|m| m.0 == mode).map_or("RGB", |m| m.1); + if let Some(i) = widgets::dropdown(ui, "swatch-mode", current, &labels, 200.0) { + let m = Mode::ALL[i].0; + // Switching modes converts the colour (HSB is a view of RGB), like the Color panel. + let base = if m == Mode::Hsb { Mode::Rgb } else { m }; + set_color(d, from_components(base, &components(base, &color_of(d)))); + d.fields.insert("mode".into(), json!(mode_id(m))); + changed = true; + } + ui.end_row(); + }); + ui.add_space(6.0); + changed | sliders(ui, d) +} + +/// One slider and value field per component of the dialog's mode, the hex field (RGB, HSB and Web) +/// and a chip of the colour. Returns true when the colour changed. +fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let mode = mode_of(d); + let color = color_of(d); + // Keep the displayed components while the colour is unchanged (hue survives S = 0 etc.). + let key = format!("{}|{}", mode_id(mode), serde_json::to_string(&color).unwrap_or_default()); + let comps: Vec = match d.fields.get("__comps") { + Some(v) if d.str("__compsKey") == key => serde_json::from_value(v.clone()).unwrap_or_else(|_| components(mode, &color)), + _ => components(mode, &color), + }; + let mut new: Option<(Color, Vec)> = None; + ui.horizontal(|ui| { + ui.vertical(|ui| { + for (i, lbl) in mode.labels().iter().enumerate() { + ui.horizontal(|ui| { + ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(*lbl).size(12.5).color(t.text))); + let max = mode.max(i); + let v = comps.get(i).copied().unwrap_or(0.0); + let track = |x: f32| crate::panels::c32(&track_color(mode, &comps, i, x)); + if let (Some(nv), _) = widgets::color_slider(ui, ("swatch-slider", i), v / max, 200.0, &track) { + let mut c2 = comps.clone(); + c2[i] = if mode == Mode::WebSafe { ((nv * 5.0).round() / 5.0) * max } else { (nv * max).round() }; + new = Some((from_components(mode, &c2), c2)); + } + if let Some(fv) = widgets::plain_field(ui, ("swatch-field", i), v as f64, mode.suffix(i), 0, 52.0) { + let mut c2 = comps.clone(); + c2[i] = (fv as f32).clamp(0.0, max); + new = Some((from_components(mode, &c2), c2)); + } + }); + } + if matches!(mode, Mode::Rgb | Mode::Hsb | Mode::WebSafe) { + ui.horizontal(|ui| { + ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new("#").size(12.5).color(t.text))); + let hex = color.to_hex().trim_start_matches('#').to_uppercase(); + if let Some(c) = hex_field(ui, &hex) { + let c = if mode == Mode::WebSafe { web_safe(&c) } else { c }; + new = Some((c, components(mode, &c))); + } + }); + } + }); + let (r, _) = ui.allocate_exact_size(vec2(44.0, 44.0), egui::Sense::hover()); + widgets::swatch_tile(ui, r, &Paint::solid(color), false, false); + }); + let Some((c, comps)) = new else { return false }; + set_color(d, c); + d.fields.insert("__compsKey".into(), json!(format!("{}|{}", mode_id(mode), serde_json::to_string(&c).unwrap_or_default()))); + d.fields.insert("__comps".into(), json!(comps)); + true +} + +/// A hex field showing `hex`; returns the colour typed into it once committed. +fn hex_field(ui: &mut egui::Ui, hex: &str) -> Option { + let t = Tokens::get(ui.ctx()); + let id = ui.id().with("swatch-hex"); + let editing = ui.memory(|m| m.has_focus(id)); + let mut buf: String = if editing { ui.data_mut(|m| m.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(egui::Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(2) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut buf).id(id).frame(egui::Frame::NONE).desired_width(70.0).char_limit(7))) + .inner; + ui.data_mut(|m| m.insert_temp(id, buf.clone())); + (resp.lost_focus() && buf != hex).then(|| parse_hex(&buf)).flatten() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fields_round_trip_modes_and_colours() { + for (m, id) in MODES { + assert_eq!(mode_of(&Dialog::new(KIND, json!({"mode": id}))), m); + } + let d = Dialog::new(KIND, json!({"color": "#ff8000"})); + assert_eq!(color_of(&d).to_hex(), "#ff8000"); + let mut d = Dialog::new(KIND, json!({})); + set_color(&mut d, Color::cmyk(0.1, 0.2, 0.3, 0.4)); + assert_eq!(color_of(&d), Color::cmyk(0.1, 0.2, 0.3, 0.4)); + let p = edit_params(&Dialog::new(KIND, json!({"__swatch": "A", "name": "B", "spot": true, "color": "#000000", "mode": "cmyk"}))); + assert_eq!( + (p["name"].as_str(), p["newName"].as_str(), p["spot"].as_bool(), p["mode"].as_str()), + (Some("A"), Some("B"), Some(true), Some("cmyk")) + ); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7c61cf88..c9a4a7b9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -160,6 +160,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("help.about", "About VectorCraft", "", "{}"), ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), + ( + "ui.swatchOptions", + "Swatch Options…", + "", + "{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit), the Gradient panel for a gradient, pattern editing for a pattern", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -482,6 +488,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::unsaved::close_all(app, "closeAll"), "app.quit" => crate::unsaved::close_all(app, "quit"), + "ui.swatchOptions" => match s("name") { + Some(name) => crate::dialogs::swatch_options::open(app, &name), + None => Err("missing `name`".into()), + }, _ => return None, }; Some(r) @@ -644,6 +654,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), "file.export.pdf" => app.session.active().is_some(), + "ui.swatchOptions" => app.session.active().is_some(), _ => true, } } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index e190419a..f56bf834 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -222,7 +222,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let sel = selected_name(ui).or(active_swatch); let (tile, pitch) = view.tile(); let mut clicked: Option = None; - let mut edit_pattern: Option = None; + // Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing). + let mut edit: Option = None; widgets::list_box(ui, |ui| { egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(if view == View::LargeThumb { 200.0 } else { 150.0 }).show(ui, |ui| { ui.set_width(ui.available_width()); @@ -271,6 +272,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { )); } } + if let Entry::Swatch { name, .. } = e + && resp.double_clicked() + { + edit = Some(name.clone()); + } if resp.on_hover_text(&name).clicked() && !matches!(e, Entry::Folder(_)) { clicked = Some(e.clone()); } @@ -279,8 +285,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let w = ui.available_width(); let per_row = ((w - 2.0) / pitch).floor().max(1.0) as usize; let mut col = 0usize; - let mut rows: Vec> = vec![vec![]]; - for (i, e) in items.iter().enumerate() { + let mut rows: Vec> = vec![vec![]]; + for e in &items { // A group folder starts a new row. if matches!(e, Entry::Folder(_)) && col > 0 { rows.push(vec![]); @@ -290,14 +296,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { rows.push(vec![]); col = 0; } - rows.last_mut().unwrap().push((i, e)); + rows.last_mut().unwrap().push(e); col += 1; } for row in rows { let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover()); - for (c, (i, e)) in row.into_iter().enumerate() { + for (c, e) in row.into_iter().enumerate() { let cell = Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); - let resp = ui.interact(cell, ui.id().with(("sw", i)), Sense::click()); + let resp = ui.interact(cell, tile_id(e), Sense::click()); let (name, tip) = match e { Entry::Registration => (REGISTRATION.to_string(), "[Registration]".to_string()), Entry::Swatch { name, .. } => (name.clone(), name.clone()), @@ -308,10 +314,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { Entry::Swatch { paint, global, .. } => { swatch_tile(ui, cell, paint, sel.as_deref() == Some(name.as_str()), resp.hovered()); pattern_thumb(app, ui, cell.shrink(1.0), paint); - if resp.double_clicked() - && let Paint::Pattern { pattern, .. } = paint - { - edit_pattern = Some(pattern.clone()); + if resp.double_clicked() { + edit = Some(name.clone()); } if *global { let k = cell.shrink(1.0); @@ -332,9 +336,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); - if let Some(name) = edit_pattern { - app.run("object.pattern.edit", json!({"name": name})).ok(); - app.ui.open_panel = Some("patternOptions".into()); + if let Some(name) = edit { + app.run("ui.swatchOptions", json!({"name": name})).ok(); } if let Some(e) = clicked { let name = match &e { @@ -348,10 +351,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { bottom(app, ui); } +/// The interaction id of a tile in the thumbnail views (stable per swatch or group name). +fn tile_id(e: &Entry) -> egui::Id { + let name = match e { + Entry::Registration => REGISTRATION, + Entry::Swatch { name, .. } | Entry::Folder(name) => name, + }; + egui::Id::new(("swatch-tile", name)) +} + fn deletable(ui: &Ui) -> Option { selected_name(ui).filter(|n| !n.starts_with('[')) } +/// The selected swatch when it has an editor (any swatch but None and Registration). +fn editable(app: &VectorcraftApp, ui: &Ui) -> Option { + deletable(ui).filter(|n| app.session.active().is_some_and(|st| st.doc.swatch(n).is_some())) +} + fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) { let kind: Kind = pstate(ui.ctx(), "swatch-kind"); widgets::bottom_bar(ui, |ui| { @@ -364,7 +381,12 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) { } } }); - widgets::icon_button_enabled(ui, "dc-options", "Swatch Options (on the roadmap)", false, false, 24.0); + let opts = editable(app, ui); + if widgets::icon_button_enabled(ui, "dc-options", "Swatch Options", false, opts.is_some(), 24.0).clicked() + && let Some(n) = opts + { + app.run("ui.swatchOptions", json!({"name": n})).ok(); + } ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); if widgets::icon_button(ui, "dc-folder", "New Color Group", false, 24.0).clicked() { let names: Vec = deletable(ui).into_iter().collect(); @@ -426,7 +448,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } ui.separator(); - menu_item(ui, "Swatch Options…", false, false); + let opts = editable(app, ui); + if menu_item(ui, "Swatch Options…", opts.is_some(), false) + && let Some(n) = opts + { + app.run("ui.swatchOptions", json!({"name": n})).ok(); + } menu_item(ui, "Spot Colors…", false, false); ui.separator(); menu_item(ui, "Open Swatch Library", false, false); @@ -436,7 +463,92 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] mod tests { use super::*; + use crate::dialogs::swatch_options::KIND; + use egui::{Event, PointerButton, Pos2}; use vectorcraft_color::Color; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app + } + + /// One frame of `draw` on a persistent context (clicks need the previous frame's layout). + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui)) { + let input = + egui::RawInput { events, time: Some(time), screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| draw(app, ui)); + out.textures_delta.clear(); + } + + fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx + } + + fn double_click(at: Pos2) -> Vec { + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + vec![Event::PointerMoved(at), button(true), button(false), button(true), button(false)] + } + + /// Double-click the thumbnail of swatch `name` in the panel. + fn double_click_tile(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str) { + frame(app, ctx, vec![], 0.0, show); + let tile = ctx.read_response(egui::Id::new(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect; + frame(app, ctx, double_click(tile.center()), 1.0, show); + } + + fn dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context) { + frame(app, ctx, vec![], 2.0, |app, ui| crate::dialogs::show(app, ui.ctx())); + } + + #[test] + fn double_click_opens_swatch_options_and_ok_edits_linked_art() { + let mut app = app(); + app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Red"})).unwrap(); + let ctx = context(); + double_click_tile(&mut app, &ctx, "Red"); + let d = app.ui.dialog.as_ref().expect("double-click opens Swatch Options"); + assert_eq!((d.kind.as_str(), d.str("__swatch").as_str(), d.str("mode").as_str()), (KIND, "Red", "rgb")); + // The dialog previews live on the canvas; OK keeps the edit as one undo step. + let undo = app.session.doc().unwrap().history.undo.len(); + dialog_frame(&mut app, &ctx); + assert!(app.session.in_interaction(), "Preview runs as an interaction"); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("color".into(), json!("#00ff00")); + d.fields.insert("name".into(), json!("Signal")); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + let doc = &app.session.doc().unwrap().doc; + assert_eq!(doc.swatch("Signal").and_then(|w| w.paint.color()).map(|c| c.to_hex()), Some("#00ff00".into())); + let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + assert_eq!(fill, Paint::Solid { color: Color::from_hex("#00ff00").unwrap(), swatch: Some("Signal".into()) }); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + } + + #[test] + fn cancel_rolls_the_preview_back_and_other_kinds_open_their_editors() { + let mut app = app(); + let ctx = context(); + let before = app.session.doc().unwrap().doc.clone(); + app.run("ui.swatchOptions", json!({"name": "Bright Blue"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#000000")); + dialog_frame(&mut app, &ctx); + assert_ne!(app.session.doc().unwrap().doc, before, "previewed"); + crate::dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().doc, before); + // Gradients open the Gradient panel, patterns pattern editing, None nothing. + double_click_tile(&mut app, &ctx, "Sunset"); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.open_panel.as_deref(), Some("gradient")); + assert!(app.run("ui.swatchOptions", json!({"name": "[None]"})).is_err()); + assert!(app.run("ui.swatchOptions", json!({})).is_err()); + } #[test] fn kind_filter() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9f7d6c78..23e45355 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,3 +19,9 @@ | `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) | | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | + +Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch +(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object, +`preview`), which previews on the canvas while open; `ui.dialog.confirm` applies it with `swatch.edit` as one undo +step and `ui.dialog.cancel` rolls the preview back. Gradient swatches open the Gradient panel and pattern swatches +pattern editing instead. diff --git a/docs/mcp.md b/docs/mcp.md index 50b51256..a5919ada 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -71,6 +71,10 @@ or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name" Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with `isError: true` and a message. The model can read the message and retry. +Swatches go through `run_command`: `swatch.list` lists every swatch with its colour group, kind and colour; +`swatch.edit {name, newName?, color?, mode?, global?, spot?}` edits one (fills, strokes and text linked to a +global or spot swatch follow its colour and name, as one undo step). + ## Resources | URI | Content | From 3082be6ecf47b762ed636bcb7339351511187b05 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:15:46 +0700 Subject: [PATCH 4/8] =?UTF-8?q?M3.12:=20delete=20swatches=20and=20colour?= =?UTF-8?q?=20groups=20=E2=80=94=20Shift/Cmd-click=20selects=20several=20s?= =?UTF-8?q?watches=20and=20groups=20in=20the=20panel,=20Delete=20asks=20fi?= =?UTF-8?q?rst=20(Alt-click=20skips),=20swatch.delete=20{names}=20removes?= =?UTF-8?q?=20them=20in=20one=20undo=20step=20and=20art=20using=20a=20dele?= =?UTF-8?q?ted=20global=20swatch=20keeps=20its=20colour,=20unlinked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a shared confirmation dialog (`confirm`) that runs a command on OK. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/swatch.rs | 83 ++++++-- crates/engine/src/tests_swatches.rs | 36 ++++ crates/ui-egui/src/dialogs/confirm.rs | 42 ++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/panels/swatches.rs | 274 +++++++++++++++++++------- docs/control-protocol.md | 3 + docs/mcp.md | 3 +- 7 files changed, 349 insertions(+), 94 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/confirm.rs diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index f72c72e0..48dd4d8b 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -20,7 +20,15 @@ pub fn specs() -> Vec { has_doc, swatch_new ), - cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete), + cmd!( + "swatch.delete", + "Delete Swatch", + ["Window", "Swatches"], + None, + "{name? | names?: [swatch or colour group names] (a group goes with its swatches), unlink?: true (art using a deleted global swatch keeps its colour, unlinked; false keeps the stale link)} delete in one undo step → {deleted, unlinked: paints unlinked}", + has_doc, + swatch_delete + ), cmd!( "swatch.newGroup", "New Color Group", @@ -82,15 +90,42 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { } fn swatch_delete(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("swatch.delete", "missing name"))?.to_string(); - s.edit("Delete Swatch", |d, _| { - d.swatches.retain(|sw| sw.name != name); - for g in &mut d.swatch_groups { - g.swatches.retain(|sw| sw.name != name); + const C: &str = "swatch.delete"; + let mut names: Vec = vec![]; + for n in str_list(p, "names").into_iter().chain(str_param(p, "name").map(str::to_string)) { + if !names.contains(&n) { + names.push(n); } - Ok(()) + } + if names.is_empty() { + return Err(bad(C, "give `name` or `names`")); + } + let d = &s.doc()?.doc; + // Global swatches going away (named ones and those in named groups): their links are dropped. + let mut global: Vec = vec![]; + for n in &names { + let swatches: Vec<&Swatch> = match d.swatch_groups.iter().find(|g| g.name == *n) { + Some(g) => g.swatches.iter().collect(), + None => vec![d.swatch(n).ok_or_else(|| bad(C, format!("no swatch or colour group `{n}`")))?], + }; + if swatches.iter().any(|w| w.paint.is_none()) { + return Err(bad(C, format!("`{n}` can't be deleted"))); + } + global.extend(swatches.iter().filter(|w| w.global).map(|w| w.name.clone())); + } + if !bool_or(p, "unlink", true) { + global.clear(); + } + let mut unlink = |_: &mut Color, link: &mut Option| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some(); + let unlinked = s.edit("Delete Swatch", |d, _| { + d.swatch_groups.retain(|g| !names.contains(&g.name)); + for n in &names { + d.remove_swatch(n); + } + Ok(d.map_solid_paints(&mut unlink)) })?; - ok() + map_default_paints(s, &mut unlink); + Ok(json!({"deleted": names, "unlinked": unlinked})) } /// The default name of a new solid swatch: its values in its own colour model. @@ -120,8 +155,7 @@ fn free_name(d: &Document, base: &str) -> String { } fn swatch_new_group(s: &mut Session, p: &Value) -> Result { - let names: Vec = - p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + let names = str_list(p, "swatches"); let colors: Vec = p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default(); let requested = str_param(p, "name").map(str::to_string); let name = s.edit("New Color Group", |d, _| { @@ -162,6 +196,11 @@ fn swatch_duplicate(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": new})) } +/// A list-of-strings parameter (missing or non-string entries are skipped). +fn str_list(p: &Value, key: &str) -> Vec { + p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() +} + /// A trimmed, non-empty name parameter. fn name_param(p: &Value, key: &str) -> Option { str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) @@ -193,7 +232,7 @@ struct Relink { to: String, /// The new colour of a global swatch (linked paints take it). color: Option, - /// false: drop the links (the swatch stopped being global or is deleted); paints keep their colour. + /// false: drop the links (the swatch stopped being global); paints keep their colour. keep: bool, } @@ -218,16 +257,20 @@ impl Relink { renamed || recolored } - /// Apply to the default fill and stroke for new art, except during a live preview (Cancel rolls - /// the document back, not the session). fn defaults(&self, s: &mut Session) { - if s.in_interaction() { - return; - } - for p in [&mut s.paint.fill, &mut s.paint.stroke] { - if let Paint::Solid { color, swatch } = p { - self.apply(color, swatch); - } + map_default_paints(s, &mut |c, l| self.apply(c, l)); + } +} + +/// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live +/// preview (Cancel rolls the document back, not the session). +fn map_default_paints(s: &mut Session, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) { + if s.in_interaction() { + return; + } + for p in [&mut s.paint.fill, &mut s.paint.stroke] { + if let Paint::Solid { color, swatch } = p { + f(color, swatch); } } } diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs index aab7203f..9dc710fc 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests_swatches.rs @@ -218,3 +218,39 @@ fn swatch_list_reports_kinds_and_groups() { assert_eq!(grays["groups"][0]["name"], "Grays"); assert!(s.execute("swatch.list", &json!({"group": "Nope"})).is_err()); } + +#[test] +fn deleting_a_global_swatch_unlinks_art_that_keeps_its_colour() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true})); + let a = rect(&mut s); + run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Brand"})); + run(&mut s, "select.set", json!({"ids": []})); + run(&mut s, "paint.setFill", json!({"swatch": "Brand"})); + let before = doc(&s).clone(); + assert_eq!(run(&mut s, "swatch.delete", json!({"name": "Brand"})), json!({"deleted": ["Brand"], "unlinked": 1})); + assert!(doc(&s).swatch("Brand").is_none()); + assert_eq!(fill_of(&s, a), Paint::solid(Color::from_hex("#2a6fb0").unwrap())); + assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#2a6fb0").unwrap()), "the default fill is unlinked too"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before, "one undo step restores swatch and link"); + // unlink: false keeps the stale link. + assert_eq!(run(&mut s, "swatch.delete", json!({"name": "Brand", "unlink": false}))["unlinked"], 0); + assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Brand")); +} + +#[test] +fn deleting_several_swatches_and_groups() { + let mut s = session(); + let r = run(&mut s, "swatch.delete", json!({"names": ["Red", "Brights", "Sunset"]})); + assert_eq!(r["deleted"], json!(["Red", "Brights", "Sunset"])); + let d = doc(&s); + assert!(d.swatch("Red").is_none() && d.swatch("Sunset").is_none() && d.swatch("Bright Red").is_none()); + assert!(d.swatch_groups.iter().all(|g| g.name != "Brights") && d.swatch_groups.iter().any(|g| g.name == "Grays")); + assert!(s.execute("swatch.delete", &json!({"name": "[None]"})).is_err(), "None stays"); + assert!(s.execute("swatch.delete", &json!({"names": ["Orange", "Nope"]})).is_err(), "unknown names fail the whole call"); + assert!(doc(&s).swatch("Orange").is_some()); + assert!(s.execute("swatch.delete", &json!({})).is_err()); + run(&mut s, "edit.undo", json!({})); + assert!(doc(&s).swatch("Bright Red").is_some() && doc(&s).swatch("Red").is_some()); +} diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs new file mode 100644 index 00000000..10ac1155 --- /dev/null +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -0,0 +1,42 @@ +//! A question answered with OK or Cancel; OK runs a command ([`ask`]). Used for confirmations such +//! as deleting swatches. +//! +//! Fields: `message` (the question, shown as the heading), `detail` (an optional line under it), +//! `__command` and `__params` (the command OK runs). + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::theme::Tokens; + +/// The dialog kind of a confirmation. +pub const KIND: &str = "confirm"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |d| d.str("message"), + body: |_, ui, d| { + let detail = d.str("detail"); + if !detail.is_empty() { + ui.label(egui::RichText::new(detail).color(Tokens::get(ui.ctx()).text_dim)); + } + false + }, + confirm, + max_width: Some(420.0), + ..DialogSpec::FORM +}; + +/// Ask `message` (with an optional `detail` line); OK runs `command` with `params`. +pub fn ask(app: &mut VectorcraftApp, message: &str, detail: &str, command: &str, params: Value) { + let fields = json!({"message": message, "detail": detail, "__command": command, "__params": params}); + app.ui.dialog = Some(Dialog::new(KIND, fields)); +} + +/// Closes before running, so a dialog the command opens stays open. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let params = d.fields.get("__params").cloned().unwrap_or_else(|| json!({})); + app.ui.dialog = None; + app.run(&d.str("__command"), params) +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 36167848..1c62bbde 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -10,6 +10,7 @@ mod about; mod all_tools; mod artboard_options; mod command; +pub mod confirm; mod document_setup; mod effect; mod export_for_screens; @@ -129,6 +130,7 @@ registry! { Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)), FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)), SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC, + Confirm: [confirm::KIND] => confirm::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index f56bf834..e029cbf0 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -83,6 +83,15 @@ enum Entry { Folder(String), } +impl Entry { + fn name(&self) -> &str { + match self { + Entry::Registration => REGISTRATION, + Entry::Swatch { name, .. } | Entry::Folder(name) => name, + } + } +} + const REGISTRATION: &str = "[Registration]"; fn entries(app: &VectorcraftApp, kind: Kind) -> Vec { @@ -185,8 +194,41 @@ fn draw_folder(ui: &Ui, r: Rect) { icons::paint(ui, "dc-folder", r.expand(1.0), t.icon); } -fn selected_name(ui: &Ui) -> Option { - pstate::>(ui.ctx(), "swatch-selected") +/// The swatches and colour groups selected in the panel, in click order, without names that no +/// longer exist (deleted, renamed or undone). +fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let mut names: Vec = pstate(ui.ctx(), "swatch-selected"); + names.retain(|n| n == REGISTRATION || st.doc.swatch_name_taken(n)); + names +} + +/// The selection after a click on `name`: Cmd/Ctrl toggles it, Shift extends from the last clicked +/// name over `order` (the names as displayed), a plain click selects it alone. +fn click_selection(ui: &Ui, mut sel: Vec, order: &[&str], name: &str, m: egui::Modifiers) -> Vec { + let anchor: String = pstate(ui.ctx(), "swatch-anchor"); + let pos = |n: &str| order.iter().position(|o| *o == n); + if m.shift + && let (Some(a), Some(b)) = (pos(&anchor), pos(name)) + { + for n in &order[a.min(b)..=a.max(b)] { + if !sel.iter().any(|s| s == n) { + sel.push(n.to_string()); + } + } + return sel; + } + set_pstate(ui.ctx(), "swatch-anchor", name.to_string()); + if !m.command { + return vec![name.to_string()]; + } + match sel.iter().position(|s| s == name) { + Some(i) => { + sel.remove(i); + } + None => sel.push(name.to_string()), + } + sel } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -219,9 +261,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { Paint::None => Some("[None]".to_string()), _ => None, }; - let sel = selected_name(ui).or(active_swatch); + let selected = selection(app, ui); + // Without a selection the swatch of the active paint is highlighted. + let sel: Vec = if selected.is_empty() { active_swatch.into_iter().collect() } else { selected.clone() }; + let is_sel = |name: &str| sel.iter().any(|s| s == name); let (tile, pitch) = view.tile(); - let mut clicked: Option = None; + let mut clicked: Option<(Entry, egui::Modifiers)> = None; // Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing). let mut edit: Option = None; widgets::list_box(ui, |ui| { @@ -230,13 +275,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if view.is_list() { for e in &items { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), pitch), Sense::click()); - let name = match e { - Entry::Registration => REGISTRATION.to_string(), - Entry::Swatch { name, .. } => name.clone(), - Entry::Folder(n) => n.clone(), - }; - let is_sel = sel.as_deref() == Some(name.as_str()); - if is_sel { + let name = e.name(); + if is_sel(name) { ui.painter().rect_filled(r, 0.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); @@ -253,7 +293,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().text( pos2(chip.right() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, - &name, + name, egui::FontId::proportional(12.0), t.text, ); @@ -272,13 +312,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { )); } } - if let Entry::Swatch { name, .. } = e - && resp.double_clicked() - { - edit = Some(name.clone()); + if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() { + edit = Some(name.to_string()); } - if resp.on_hover_text(&name).clicked() && !matches!(e, Entry::Folder(_)) { - clicked = Some(e.clone()); + if resp.on_hover_text(name).clicked() { + clicked = Some((e.clone(), ui.input(|i| i.modifiers))); } } } else { @@ -304,18 +342,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for (c, e) in row.into_iter().enumerate() { let cell = Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); let resp = ui.interact(cell, tile_id(e), Sense::click()); - let (name, tip) = match e { - Entry::Registration => (REGISTRATION.to_string(), "[Registration]".to_string()), - Entry::Swatch { name, .. } => (name.clone(), name.clone()), - Entry::Folder(n) => (n.clone(), format!("Color Group: {n}")), - }; + let name = e.name(); match e { Entry::Registration => draw_registration(ui, cell), Entry::Swatch { paint, global, .. } => { - swatch_tile(ui, cell, paint, sel.as_deref() == Some(name.as_str()), resp.hovered()); + swatch_tile(ui, cell, paint, is_sel(name), resp.hovered()); pattern_thumb(app, ui, cell.shrink(1.0), paint); if resp.double_clicked() { - edit = Some(name.clone()); + edit = Some(name.to_string()); } if *global { let k = cell.shrink(1.0); @@ -326,10 +360,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { )); } } - Entry::Folder(_) => draw_folder(ui, cell), + Entry::Folder(_) => { + draw_folder(ui, cell); + if is_sel(name) { + ui.painter().rect_stroke(cell.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside); + } + } } - if resp.on_hover_text(tip).clicked() && !matches!(e, Entry::Folder(_)) { - clicked = Some(e.clone()); + let resp = match e { + Entry::Folder(n) => resp.on_hover_text(format!("Color Group: {n}")), + _ => resp.on_hover_text(name), + }; + if resp.clicked() { + clicked = Some((e.clone(), ui.input(|i| i.modifiers))); } } } @@ -339,37 +382,56 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(name) = edit { app.run("ui.swatchOptions", json!({"name": name})).ok(); } - if let Some(e) = clicked { - let name = match &e { - Entry::Registration => REGISTRATION.to_string(), - Entry::Swatch { name, .. } => name.clone(), - Entry::Folder(n) => n.clone(), - }; - set_pstate(ui.ctx(), "swatch-selected", Some(name)); - apply(app, ui, &e); - } - bottom(app, ui); + let selected = match clicked { + Some((e, m)) => { + let order: Vec<&str> = items.iter().map(Entry::name).collect(); + let sel = click_selection(ui, selected, &order, e.name(), m); + set_pstate(ui.ctx(), "swatch-selected", sel.clone()); + // A plain click also applies the swatch; modifier clicks only select. + if !(m.shift || m.command) { + apply(app, ui, &e); + } + sel + } + None => selected, + }; + bottom(app, ui, &selected); } /// The interaction id of a tile in the thumbnail views (stable per swatch or group name). fn tile_id(e: &Entry) -> egui::Id { - let name = match e { - Entry::Registration => REGISTRATION, - Entry::Swatch { name, .. } | Entry::Folder(name) => name, + egui::Id::new(("swatch-tile", e.name())) +} + +/// The selected swatches and groups that can be deleted (all but None and Registration). +fn deletable(sel: &[String]) -> Vec { + sel.iter().filter(|n| !n.starts_with('[')).cloned().collect() +} + +/// The selected swatch when exactly one swatch with an editor is selected (not None, Registration +/// or a group). +fn editable(app: &VectorcraftApp, sel: &[String]) -> Option { + match sel { + [n] if !n.starts_with('[') && app.session.active().is_some_and(|st| st.doc.swatch(n).is_some()) => Some(n.clone()), + _ => None, + } +} + +/// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click). +fn delete(app: &mut VectorcraftApp, names: Vec, now: bool) { + let params = json!({"names": names}); + if now { + app.run("swatch.delete", params).ok(); + return; + } + let message = match names.as_slice() { + [n] => format!("Delete “{n}”?"), + _ => format!("Delete these {} swatches and groups?", names.len()), }; - egui::Id::new(("swatch-tile", name)) + crate::dialogs::confirm::ask(app, &message, "Art using a deleted global swatch keeps its colour.", "swatch.delete", params); } -fn deletable(ui: &Ui) -> Option { - selected_name(ui).filter(|n| !n.starts_with('[')) -} - -/// The selected swatch when it has an editor (any swatch but None and Registration). -fn editable(app: &VectorcraftApp, ui: &Ui) -> Option { - deletable(ui).filter(|n| app.session.active().is_some_and(|st| st.doc.swatch(n).is_some())) -} - -fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) { +fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { let kind: Kind = pstate(ui.ctx(), "swatch-kind"); widgets::bottom_bar(ui, |ui| { widgets::icon_button_enabled(ui, "library", "Swatch Libraries (on the roadmap)", false, false, 24.0); @@ -381,7 +443,7 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) { } } }); - let opts = editable(app, ui); + let opts = editable(app, sel); if widgets::icon_button_enabled(ui, "dc-options", "Swatch Options", false, opts.is_some(), 24.0).clicked() && let Some(n) = opts { @@ -389,19 +451,16 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); if widgets::icon_button(ui, "dc-folder", "New Color Group", false, 24.0).clicked() { - let names: Vec = deletable(ui).into_iter().collect(); - app.run("swatch.newGroup", json!({"swatches": names})).ok(); + app.run("swatch.newGroup", json!({"swatches": deletable(sel)})).ok(); } // Ctrl/Cmd-click makes a spot colour (Alt-click skips the dialog). if widgets::icon_button(ui, "dc-new-item", "New Swatch", false, 24.0).clicked() { new_swatch(app, ui.input(|i| i.modifiers.command)); } - let del = deletable(ui); - if widgets::icon_button_enabled(ui, "trash-2", "Delete Swatch", false, del.is_some(), 24.0).clicked() - && let Some(n) = del - && app.run("swatch.delete", json!({"name": n})).is_ok() - { - set_pstate::>(ui.ctx(), "swatch-selected", None); + // Alt-click deletes without asking. + let del = deletable(sel); + if widgets::icon_button_enabled(ui, "trash-2", "Delete Swatch", false, !del.is_empty(), 24.0).clicked() { + delete(app, del, ui.input(|i| i.modifiers.alt)); } }); } @@ -415,23 +474,23 @@ fn new_swatch(app: &mut VectorcraftApp, spot: bool) { pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let view: View = pstate(ui.ctx(), "swatch-view"); - let sel = deletable(ui); + let selected = selection(app, ui); + let del = deletable(&selected); + let opts = editable(app, &selected); if menu_item(ui, "New Swatch…", true, false) { new_swatch(app, false); } if menu_item(ui, "New Color Group…", true, false) { - app.run("swatch.newGroup", json!({"swatches": sel.clone().into_iter().collect::>()})).ok(); + app.run("swatch.newGroup", json!({"swatches": del})).ok(); } - if menu_item(ui, "Duplicate Swatch", sel.is_some(), false) - && let Some(n) = &sel + if menu_item(ui, "Duplicate Swatch", opts.is_some(), false) + && let Some(n) = &opts { app.run("swatch.duplicate", json!({"name": n})).ok(); } menu_item(ui, "Merge Swatches", false, false); - if menu_item(ui, "Delete Swatch", sel.is_some(), false) - && let Some(n) = &sel - { - app.run("swatch.delete", json!({"name": n})).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); @@ -448,7 +507,6 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } ui.separator(); - let opts = editable(app, ui); if menu_item(ui, "Swatch Options…", opts.is_some(), false) && let Some(n) = opts { @@ -464,7 +522,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { mod tests { use super::*; use crate::dialogs::swatch_options::KIND; - use egui::{Event, PointerButton, Pos2}; + use egui::{Event, Modifiers, PointerButton, Pos2}; use vectorcraft_color::Color; use vectorcraft_engine::Session; @@ -476,8 +534,21 @@ mod tests { /// One frame of `draw` on a persistent context (clicks need the previous frame's layout). fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui)) { - let input = - egui::RawInput { events, time: Some(time), screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))), ..Default::default() }; + frame_with(app, ctx, events, Modifiers::NONE, time, draw); + } + + /// [`frame`] with `modifiers` held. + fn frame_with( + app: &mut VectorcraftApp, + ctx: &egui::Context, + events: Vec, + modifiers: Modifiers, + time: f64, + draw: fn(&mut VectorcraftApp, &mut Ui), + ) { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))); + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }; let mut out = ctx.run_ui(input, |ui| draw(app, ui)); out.textures_delta.clear(); } @@ -500,6 +571,18 @@ mod tests { frame(app, ctx, double_click(tile.center()), 1.0, show); } + /// Click the thumbnail of swatch or group `name` with `modifiers` held (frames at `time`, `time + 0.5`). + fn click_tile(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { + frame(app, ctx, vec![], time, show); + let at = ctx.read_response(egui::Id::new(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; + frame_with(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5, show); + } + + fn selected(ctx: &egui::Context) -> Vec { + pstate(ctx, "swatch-selected") + } + fn dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context) { frame(app, ctx, vec![], 2.0, |app, ui| crate::dialogs::show(app, ui.ctx())); } @@ -550,6 +633,51 @@ mod tests { assert!(app.run("ui.swatchOptions", json!({})).is_err()); } + #[test] + fn modifier_clicks_select_several_swatches_and_groups() { + let mut app = app(); + let ctx = context(); + click_tile(&mut app, &ctx, "Red", Modifiers::NONE, 0.0); + assert_eq!(selected(&ctx), ["Red"]); + assert_eq!(app.session.paint.fill.color().map(|c| c.to_hex()), Some("#ed1c24".into()), "a plain click applies"); + click_tile(&mut app, &ctx, "Amber", Modifiers::SHIFT, 2.0); + assert_eq!(selected(&ctx), ["Red", "Orange Red", "Orange", "Amber"], "Shift extends over the shown order"); + click_tile(&mut app, &ctx, "Orange", Modifiers::COMMAND, 4.0); + click_tile(&mut app, &ctx, "Brights", Modifiers::COMMAND, 6.0); + assert_eq!(selected(&ctx), ["Red", "Orange Red", "Amber", "Brights"], "Cmd toggles swatches and colour groups"); + assert_eq!(app.session.paint.fill.color().map(|c| c.to_hex()), Some("#ed1c24".into()), "modifier clicks only select"); + click_tile(&mut app, &ctx, "Grays", Modifiers::NONE, 8.0); + assert_eq!(selected(&ctx), ["Grays"], "a plain click on a group selects it alone"); + } + + #[test] + fn delete_asks_then_removes_the_selection_and_unlinks_art() { + let mut app = app(); + let ctx = context(); + app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Red"})).unwrap(); + delete(&mut app, vec!["Red".into(), "Brights".into()], false); + let d = app.ui.dialog.as_ref().expect("delete asks first"); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (crate::dialogs::confirm::KIND, "Delete these 2 swatches and groups?")); + dialog_frame(&mut app, &ctx); + assert!(app.session.doc().unwrap().doc.swatch("Red").is_some(), "nothing is deleted before OK"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let doc = &app.session.doc().unwrap().doc; + assert!(doc.swatch("Red").is_none() && doc.swatch("Bright Red").is_none() && doc.swatch_groups.iter().all(|g| g.name != "Brights")); + let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + assert_eq!(fill, Paint::solid(Color::from_hex("#ed1c24").unwrap()), "the art keeps its colour, unlinked"); + // Cancel keeps the swatch; Alt-click (now) deletes without asking. + delete(&mut app, vec!["Orange".into()], false); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("message"), "Delete “Orange”?"); + crate::dialogs::cancel(&mut app); + assert!(app.session.doc().unwrap().doc.swatch("Orange").is_some()); + delete(&mut app, vec!["Orange".into()], true); + assert!(app.ui.dialog.is_none() && app.session.doc().unwrap().doc.swatch("Orange").is_none()); + assert_eq!(deletable(&["[None]".into(), REGISTRATION.into(), "Grays".into()]), ["Grays"]); + } + #[test] fn kind_filter() { let solid = Paint::solid(Color::BLACK); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 23e45355..9dfda508 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -25,3 +25,6 @@ Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatc `preview`), which previews on the canvas while open; `ui.dialog.confirm` applies it with `swatch.edit` as one undo step and `ui.dialog.cancel` rolls the preview back. Gradient swatches open the Gradient panel and pattern swatches pattern editing instead. + +Confirmations: deleting swatches from the Swatches panel opens a `confirm` dialog (fields `message`, `detail`); +`ui.dialog.confirm` runs the command it asks about (here `swatch.delete`) and `ui.dialog.cancel` drops it. diff --git a/docs/mcp.md b/docs/mcp.md index a5919ada..84c4296a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -73,7 +73,8 @@ Errors (unknown tool, bad arguments, a disabled or failing command) come back as Swatches go through `run_command`: `swatch.list` lists every swatch with its colour group, kind and colour; `swatch.edit {name, newName?, color?, mode?, global?, spot?}` edits one (fills, strokes and text linked to a -global or spot swatch follow its colour and name, as one undo step). +global or spot swatch follow its colour and name, as one undo step); `swatch.delete {names, unlink?}` deletes +swatches and colour groups in one undo step (art using a deleted global swatch keeps its colour, unlinked). ## Resources From 7da0bfb1c62bc375a7a6fa1190ce965ac37c1784 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:26:24 +0700 Subject: [PATCH 5/8] =?UTF-8?q?M3.13:=20New=20Swatch=20and=20New=20Color?= =?UTF-8?q?=20Group=20dialogs=20=E2=80=94=20New=20Swatch=20reuses=20the=20?= =?UTF-8?q?Swatch=20Options=20fields=20prefilled=20from=20the=20active=20c?= =?UTF-8?q?olour=20(its=20default=20name=20follows=20the=20colour),=20goes?= =?UTF-8?q?=20into=20the=20selected=20colour=20group,=20Alt-click=20skips?= =?UTF-8?q?=20it=20and=20Cmd/Ctrl=20makes=20a=20spot=20colour;=20New=20Col?= =?UTF-8?q?or=20Group=20makes=20a=20group=20from=20the=20selected=20swatch?= =?UTF-8?q?es=20or=20the=20selected=20artwork's=20colours,=20optionally=20?= =?UTF-8?q?as=20global=20swatches=20the=20art=20links=20to=20and=20with=20?= =?UTF-8?q?swatches=20for=20tints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swatch.new takes mode and group; swatch.newGroup takes fromArtwork, toGlobal and includeTints and reports the group's swatches and the paints it linked. Adds a shared radio widget and the ui.newSwatch / ui.newColorGroup openers. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/swatches.rs | 30 +++++ crates/engine/src/cmd/swatch.rs | 126 ++++++++++++------ crates/engine/src/tests_swatches.rs | 82 +++++++++++- crates/ui-egui/src/dialogs/mod.rs | 4 + crates/ui-egui/src/dialogs/new_color_group.rs | 82 ++++++++++++ crates/ui-egui/src/dialogs/new_swatch.rs | 85 ++++++++++++ crates/ui-egui/src/dialogs/swatch_options.rs | 30 +++-- crates/ui-egui/src/menus.rs | 19 ++- crates/ui-egui/src/panels/swatches.rs | 96 +++++++++++-- crates/ui-egui/src/widgets.rs | 49 ++++--- docs/control-protocol.md | 5 + docs/mcp.md | 3 + 12 files changed, 529 insertions(+), 82 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/new_color_group.rs create mode 100644 crates/ui-egui/src/dialogs/new_swatch.rs diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index dc4c52c0..7f6f6c3b 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -33,6 +33,24 @@ impl Document { pub fn swatch_name_taken(&self, name: &str) -> bool { self.swatch(name).is_some() || self.swatch_groups.iter().any(|g| g.name == name) } + /// `base`, or the first of "base 2", "base 3"… no swatch or colour group uses. + pub fn free_swatch_name(&self, base: &str) -> String { + if !self.swatch_name_taken(base) { + return base.to_string(); + } + (2..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string()) + } + /// 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"… + pub fn new_swatch_name(&self, paint: &Paint) -> String { + let base = match paint { + Paint::Solid { color, .. } => return self.free_swatch_name(&color_name(*color)), + Paint::Gradient(_) => "New Gradient Swatch", + _ => "New Pattern Swatch", + }; + (1..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string()) + } /// Remove swatch `name` from wherever it lives. pub fn remove_swatch(&mut self, name: &str) -> Option { if let Some(i) = self.swatches.iter().position(|s| s.name == name) { @@ -83,6 +101,18 @@ impl Document { } } +/// 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 { + let pct = |v: f32| (v * 100.0).round(); + let byte = |v: f32| (v * 255.0).round(); + match c { + Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), + Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)), + Color::Gray { k } => format!("Gray K={}", pct(k)), + } +} + /// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes and text /// runs (with their link) and gradient stops (unlinked), in paint order. pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) { diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 48dd4d8b..e020a0d7 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -2,8 +2,9 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup}; -use vectorcraft_doc::Document; use vectorcraft_doc::pattern::pattern_paint; +use vectorcraft_doc::swatches::{color_name, node_colors}; +use vectorcraft_doc::{Document, NodeId}; use super::paint::paint_from; use super::*; @@ -16,7 +17,7 @@ pub fn specs() -> Vec { "New Swatch", ["Window", "Swatches"], None, - "{name?, color? | swatch? | gradient? | pattern?: name (default: the current fill), global?, spot? (a spot colour, always global)} save a colour, gradient or pattern as a swatch. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name}", + "{name?, color? | swatch? | gradient? | pattern?: name (default: the current fill), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name}", has_doc, swatch_new ), @@ -34,7 +35,7 @@ pub fn specs() -> Vec { "New Color Group", ["Window", "Swatches"], None, - "{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches)} → {name}", + "{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches), fromArtwork?: false (add the unique colours of the selected art: fills, strokes, text and gradient stops; a colour linked to a global swatch moves that swatch into the group), toGlobal?: true (with fromArtwork: the new swatches are global and the selected art's matching unlinked colours link to them), includeTints?: false (with fromArtwork: tints of global swatches also get swatches of their own)} → {name, swatches: [names in the group], linked: paints linked}", has_doc, swatch_new_group ), @@ -70,20 +71,31 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { }; let spot = bool_or(p, "spot", false); let global = spot || bool_or(p, "global", false); + let mode = str_param(p, "mode"); + let group = str_param(p, "group"); let paint = match paint { // The swatch holds the colour itself, not a link to the swatch it may have come from. - Paint::Solid { color, .. } => Paint::solid(color), + Paint::Solid { color, .. } => Paint::solid(match mode { + Some(m) => convert_to_mode(color, m, C)?, + None => color, + }), Paint::None => return Err(bad(C, "a swatch needs a colour, gradient or pattern")), - _ if spot => return Err(bad(C, "only solid colours can be spot colours")), + _ if spot || mode.is_some() || group.is_some() => return Err(bad(C, "spot, mode and group apply to solid colours only")), other => other, }; let requested = name_param(p, "name"); let name = s.edit("New Swatch", |d, _| { let name = match requested { - Some(n) => free_name(d, &n), - None => default_name(d, &paint), + Some(n) => d.free_swatch_name(&n), + None => d.new_swatch_name(&paint), }; - d.swatches.push(Swatch { name: name.clone(), paint, global, spot }); + let swatch = Swatch { name: name.clone(), paint, global, spot }; + match group { + Some(g) => { + d.swatch_groups.iter_mut().find(|x| x.name == g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?.swatches.push(swatch) + } + None => d.swatches.push(swatch), + } Ok(name) })?; Ok(json!({ "name": name })) @@ -128,38 +140,47 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { Ok(json!({"deleted": names, "unlinked": unlinked})) } -/// The default name of a new solid swatch: its values in its own colour model. -fn color_name(c: Color) -> String { - let pct = |v: f32| (v * 100.0).round(); - let byte = |v: f32| (v * 255.0).round(); - match c { - Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), - Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)), - Color::Gray { k } => format!("Gray K={}", pct(k)), +/// The colours of the art `ids` for a new colour group, in paint order: the swatches that linked +/// colours belong to (added to `swatches` once each) and the other colours (added to `colors` once +/// 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, colors: &mut Vec) { + 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) + { + swatches.push(w.name.clone()); + } + let own = sw.is_none_or(|w| tints && w.paint.color() != Some(*c)); + if own && !colors.contains(c) { + colors.push(*c); + } + }); } } -/// A free default name for a new swatch of `paint`: its colour values, or "New Gradient Swatch 1"… -fn default_name(d: &Document, paint: &Paint) -> String { - let base = match paint { - Paint::Solid { color, .. } => return free_name(d, &color_name(*color)), - Paint::Gradient(_) => "New Gradient Swatch", - _ => "New Pattern Swatch", - }; - (1..).map(|i| format!("{base} {i}")).find(|n| !d.swatch_name_taken(n)).unwrap_or_else(|| base.to_string()) -} - -/// A swatch or colour-group name not used yet in `d`. -fn free_name(d: &Document, base: &str) -> String { - unique_name(base, |n| d.swatch_name_taken(n)) -} - fn swatch_new_group(s: &mut Session, p: &Value) -> Result { - let names = str_list(p, "swatches"); + const C: &str = "swatch.newGroup"; + let mut names = str_list(p, "swatches"); let colors: Vec = p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default(); + let from_art = bool_or(p, "fromArtwork", false); + let to_global = from_art && bool_or(p, "toGlobal", true); + let tints = bool_or(p, "includeTints", false); let requested = str_param(p, "name").map(str::to_string); - let name = s.edit("New Color Group", |d, _| { - let name = free_name(d, requested.as_deref().unwrap_or("Color Group")); + let (name, members, linked) = s.edit("New Color Group", |d, sel| { + // New swatches: (colour, global). + let mut new: Vec<(Color, bool)> = colors.iter().map(|c| (*c, false)).collect(); + if from_art { + if sel.objects.is_empty() { + return Err(bad(C, "select artwork to make a group of its colours")); + } + let mut art = vec![]; + artwork_colors(d, &sel.objects, tints, &mut names, &mut art); + new.extend(art.into_iter().map(|c| (c, to_global))); + } + let name = d.free_swatch_name(requested.as_deref().unwrap_or("Color Group")); let mut group = SwatchGroup { name: name.clone(), swatches: vec![] }; // Colour groups hold solid colours only. for n in &names { @@ -169,21 +190,44 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { group.swatches.push(sw); } } - for c in &colors { - let nm = unique_name(&color_name(*c), |n| d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n)); - group.swatches.push(Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false }); + // The global swatches made from the art's colours, which its unlinked paints link to. + let mut links: Vec<(Color, String)> = vec![]; + for (c, global) in new { + let nm = unique_name(&color_name(c), |n| d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n)); + if global { + links.push((c, nm.clone())); + } + group.swatches.push(Swatch { name: nm, paint: Paint::solid(c), global, spot: false }); } + let members: Vec = group.swatches.iter().map(|w| w.name.clone()).collect(); d.swatch_groups.push(group); - Ok(name) + let linked = if links.is_empty() { + 0 + } else { + let live: Vec = 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, + } + }) + }; + Ok((name, members, linked)) })?; - Ok(json!({"name": name})) + Ok(json!({"name": name, "swatches": members, "linked": linked})) } fn swatch_duplicate(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string(); let new = s.edit("Duplicate Swatch", |d, _| { let src = d.swatch(&name).cloned().ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?; - let nm = free_name(d, &format!("{name} copy")); + let nm = d.free_swatch_name(&format!("{name} copy")); let copy = Swatch { name: nm.clone(), ..src }; if let Some(pos) = d.swatches.iter().position(|sw| sw.name == name) { d.swatches.insert(pos + 1, copy); @@ -297,7 +341,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result { } let spot = bool_or(p, "spot", sw.spot); let global = spot || bool_or(p, "global", sw.global); - let to = name_param(p, "newName").filter(|n| *n != name).map_or_else(|| name.clone(), |n| free_name(d, &n)); + let to = name_param(p, "newName").filter(|n| *n != name).map_or_else(|| name.clone(), |n| d.free_swatch_name(&n)); let relink = Relink { from: name.clone(), to: to.clone(), color: color.filter(|_| global), keep: global }; let relinked = s.edit("Swatch Options", |d, _| { let w = d.swatch_mut(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs index 9dc710fc..eeaca99a 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests_swatches.rs @@ -1,5 +1,6 @@ //! Swatches: colour groups as first-class swatch homes (colour mode, spot plates, PDF, Document -//! Info), naming and kinds, Swatch Options edits reaching linked art, deleting and new groups. +//! Info), naming and kinds, Swatch Options edits reaching linked art, deleting, new swatches in +//! groups and colour groups made from artwork. use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; @@ -24,12 +25,9 @@ fn doc(s: &Session) -> &vectorcraft_doc::Document { &s.doc().unwrap().doc } -/// Put a solid swatch into colour group `group` directly in the model (no command creates one in a -/// group before M3.13). +/// Add a global solid swatch to colour group `group`. fn grouped_swatch(s: &mut Session, group: &str, name: &str, color: Color) { - let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); - let g = d.swatch_groups.iter_mut().find(|g| g.name == group).unwrap(); - g.swatches.push(vectorcraft_color::Swatch { name: name.into(), paint: Paint::solid(color), global: true, spot: false }); + assert_eq!(run(s, "swatch.new", json!({"name": name, "color": color, "global": true, "group": group}))["name"], name); } #[test] @@ -254,3 +252,75 @@ fn deleting_several_swatches_and_groups() { run(&mut s, "edit.undo", json!({})); assert!(doc(&s).swatch("Bright Red").is_some() && doc(&s).swatch("Red").is_some()); } + +#[test] +fn new_swatches_go_into_groups_convert_modes_and_can_be_spot() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Ink", "color": "#ff0000", "spot": true, "mode": "cmyk", "group": "Brights"})); + let d = doc(&s); + let ink = d.swatch("Ink").unwrap(); + assert!(ink.spot && ink.global, "spot colours are global"); + assert!(matches!(ink.paint.color(), Some(Color::Cmyk { .. })), "mode converts the colour"); + assert_eq!(d.swatch_group_of("Ink").map(|g| d.swatch_groups[g].name.as_str()), Some("Brights")); + assert!(s.execute("swatch.new", &json!({"color": "#00ff00", "group": "Nope"})).is_err()); + assert!(s.execute("swatch.new", &json!({"swatch": "Sunset", "group": "Brights"})).is_err(), "groups hold solid colours only"); + assert!(s.execute("swatch.new", &json!({"color": "#00ff00", "mode": "lab"})).is_err()); +} + +/// Two rectangles: `a` filled red and stroked green, `b` filled red and stroked with global swatch +/// "Brand"; both selected. +fn art(s: &mut Session) -> (NodeId, NodeId) { + run(s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true})); + let (a, b) = (rect(s), rect(s)); + run(s, "paint.setFill", json!({"ids": [a.0, b.0], "color": "#ff0000"})); + run(s, "paint.setStroke", json!({"ids": [a.0], "color": "#00ff00"})); + run(s, "paint.setStroke", json!({"ids": [b.0], "swatch": "Brand"})); + run(s, "select.set", json!({"ids": [a.0, b.0]})); + (a, b) +} + +#[test] +fn a_group_from_artwork_holds_its_unique_colours_and_links_the_art() { + let mut s = session(); + let (a, b) = art(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + let r = run(&mut s, "swatch.newGroup", json!({"name": "Art", "fromArtwork": true})); + assert_eq!(r["swatches"], json!(["Brand", "R=255 G=0 B=0", "R=0 G=255 B=0"]), "the linked swatch moves in, the rest are new"); + assert_eq!(r["linked"], 3, "both red fills and the green stroke"); + let d = doc(&s); + assert!(d.swatch("R=255 G=0 B=0").unwrap().global); + assert_eq!(d.swatch_group_of("Brand").map(|g| d.swatch_groups[g].name.as_str()), Some("Art")); + assert_eq!(fill_of(&s, a), linked("#ff0000", "R=255 G=0 B=0")); + assert_eq!(fill_of(&s, b), linked("#ff0000", "R=255 G=0 B=0")); + assert_eq!(doc(&s).node(b).unwrap().appearance.stroke_paint(), linked("#2a6fb0", "Brand")); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Without Convert to Global the swatches are process colours and the art stays unlinked. + run(&mut s, "edit.undo", json!({})); + let r = run(&mut s, "swatch.newGroup", json!({"fromArtwork": true, "toGlobal": false})); + assert_eq!((r["name"].as_str(), r["linked"].as_u64()), (Some("Color Group"), Some(0))); + assert!(!doc(&s).swatch("R=0 G=255 B=0").unwrap().global); + assert_eq!(fill_of(&s, a), Paint::solid(Color::from_hex("#ff0000").unwrap())); + run(&mut s, "select.set", json!({"ids": []})); + assert!(s.execute("swatch.newGroup", &json!({"fromArtwork": true})).is_err(), "needs selected art"); +} + +#[test] +fn tints_of_global_swatches_get_swatches_only_when_asked() { + let mut s = session(); + let (_, b) = art(&mut s); + // A 50% tint of Brand on b's stroke. + let tint = Color::from_hex("#95b7d8").unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).map_solid_paints_in(&[b], &mut |c, l| { + let hit = l.as_deref() == Some("Brand"); + if hit { + *c = tint; + } + hit + }); + let without = run(&mut s, "swatch.newGroup", json!({"fromArtwork": true})); + assert_eq!(without["swatches"], json!(["Brand", "R=255 G=0 B=0", "R=0 G=255 B=0"]), "a tint brings its swatch"); + run(&mut s, "edit.undo", json!({})); + let with = run(&mut s, "swatch.newGroup", json!({"fromArtwork": true, "includeTints": true})); + assert_eq!(with["swatches"], json!(["Brand", "R=255 G=0 B=0", "R=0 G=255 B=0", "R=149 G=183 B=216"])); + assert_eq!(doc(&s).node(b).unwrap().appearance.stroke_paint(), Paint::Solid { color: tint, swatch: Some("Brand".into()) }, "tints stay linked"); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 1c62bbde..4c451a32 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -15,7 +15,9 @@ mod document_setup; mod effect; mod export_for_screens; mod form; +pub mod new_color_group; mod new_document; +pub mod new_swatch; mod path_ops; mod recolor; mod save_changes; @@ -131,6 +133,8 @@ registry! { FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)), SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC, Confirm: [confirm::KIND] => confirm::SPEC, + NewSwatch: [new_swatch::KIND] => new_swatch::SPEC, + NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/new_color_group.rs b/crates/ui-egui/src/dialogs/new_color_group.rs new file mode 100644 index 00000000..fd476259 --- /dev/null +++ b/crates/ui-egui/src/dialogs/new_color_group.rs @@ -0,0 +1,82 @@ +//! New Color Group: a name and what the group is made from, the swatches selected in the Swatches +//! panel or the colours of the selected artwork (whose process colours can become global swatches +//! the art links to, with swatches for tints too). OK runs `swatch.newGroup`. +//! +//! Fields: `name`, `fromArtwork`, `toGlobal`, `includeTints`, `swatches` (the selected swatch +//! names) and `__art` (artwork is selected). + +use serde_json::{Value, json}; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of New Color Group. +pub const KIND: &str = "newColorGroup"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "New Color Group".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM }; + +/// Open New Color Group for the swatches selected in the panel. It starts from the artwork when +/// art is selected and no swatches are. +pub fn open(app: &mut VectorcraftApp, swatches: Vec) -> Result { + let st = app.session.active().ok_or("no document open")?; + let art = !st.selection.is_empty(); + let fields = json!({ + "name": st.doc.free_swatch_name("Color Group"), + "fromArtwork": art && swatches.is_empty(), + "toGlobal": true, + "includeTints": false, + "swatches": swatches, + "__art": art, + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let art = d.bool("fromArtwork"); + let set = |d: &mut Dialog, key: &str, v: bool| { + d.fields.insert(key.into(), json!(v)); + }; + grid(ui, |ui| { + label(ui, "Name:"); + form::text(ui, d, "name", 190.0); + ui.end_row(); + label(ui, "Create From:"); + if widgets::radio(ui, "Selected Swatches", !art, true) { + set(d, "fromArtwork", false); + } + ui.end_row(); + ui.label(""); + if widgets::radio(ui, "Selected Artwork", art, d.bool("__art")) { + set(d, "fromArtwork", true); + } + ui.end_row(); + for (key, text) in [("toGlobal", "Convert Process to Global"), ("includeTints", "Include Swatches for Tints")] { + ui.label(""); + ui.horizontal(|ui| { + ui.add_space(18.0); + let on = d.bool(key); + if widgets::check(ui, text, on, art) { + set(d, key, !on); + } + }); + ui.end_row(); + } + }); + false +} + +/// `swatch.newGroup` parameters from the fields. +fn params(d: &Dialog) -> Value { + if d.bool("fromArtwork") { + json!({"name": d.str("name"), "fromArtwork": true, "toGlobal": d.bool("toGlobal"), "includeTints": d.bool("includeTints")}) + } else { + json!({"name": d.str("name"), "swatches": d.fields.get("swatches").cloned().unwrap_or_else(|| json!([]))}) + } +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + run_and_close(app, "swatch.newGroup", params(d)) +} diff --git a/crates/ui-egui/src/dialogs/new_swatch.rs b/crates/ui-egui/src/dialogs/new_swatch.rs new file mode 100644 index 00000000..3adfea6a --- /dev/null +++ b/crates/ui-egui/src/dialogs/new_swatch.rs @@ -0,0 +1,85 @@ +//! New Swatch: the Swatch Options fields (name, colour type, Global, mode, sliders and hex) +//! prefilled from the active fill or stroke; a gradient or pattern only takes a name. OK runs +//! `swatch.new`. +//! +//! Fields: `name`, `spot`, `global`, `mode` and `color` as in Swatch Options, `group` (the colour +//! group the colour goes into; empty for none), `__paint` (the `swatch.new` paint of a gradient or +//! pattern; absent for a colour) and `__auto` (the default name, which follows the colour until +//! the name is edited). + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; + +use super::swatch_options::{color_of, editor, grid, mode_id, name_row}; +use super::{DialogSpec, run_and_close}; +use crate::VectorcraftApp; +use crate::panels::color::Mode; +use crate::state::Dialog; + +/// The dialog kind of New Swatch. +pub const KIND: &str = "newSwatch"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "New Swatch".into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM }; + +/// Open New Swatch for the active paint: a spot colour with `spot`, going into colour `group`. +pub fn open(app: &mut VectorcraftApp, spot: bool, group: Option<&str>) -> Result { + let paint = crate::panels::active_paint(app); + let name = app.session.active().ok_or("no document open")?.doc.new_swatch_name(&paint); + let mut fields = match &paint { + Paint::Solid { color, .. } => { + json!({"color": color, "mode": mode_id(Mode::of(color)), "spot": spot, "global": spot, "group": group.unwrap_or_default()}) + } + Paint::None => return Err("pick a colour, gradient or pattern to save as a swatch".into()), + other => json!({"__paint": crate::panels::paint_params(other)}), + }; + fields["name"] = json!(name); + fields["__auto"] = json!(name); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + if d.fields.contains_key("__paint") { + grid(ui, |ui| name_row(ui, d)); + } else if editor(ui, d) + && d.str("name") == d.str("__auto") + && let Some(st) = app.session.active() + { + // The default name follows the colour until it is edited. + let auto = st.doc.new_swatch_name(&Paint::solid(color_of(d))); + d.fields.insert("name".into(), json!(auto)); + d.fields.insert("__auto".into(), json!(auto)); + } + false +} + +/// `swatch.new` parameters from the fields. +fn params(d: &Dialog) -> Value { + let mut p = match d.fields.get("__paint") { + Some(paint) => paint.clone(), + None => json!({"color": color_of(d), "mode": d.str("mode"), "spot": d.bool("spot"), "global": d.bool("global")}), + }; + p["name"] = json!(d.str("name")); + let group = d.str("group"); + if !group.is_empty() { + p["group"] = json!(group); + } + p +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + run_and_close(app, "swatch.new", params(d)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn params_carry_the_colour_or_the_paint() { + let d = Dialog::new(KIND, json!({"name": "Sky", "color": "#2a6fb0", "mode": "rgb", "spot": true, "global": true, "group": "Mine"})); + assert_eq!(params(&d), json!({"name": "Sky", "color": color_of(&d), "mode": "rgb", "spot": true, "global": true, "group": "Mine"})); + let d = Dialog::new(KIND, json!({"name": "Dots", "__paint": {"pattern": "Dots"}, "group": ""})); + assert_eq!(params(&d), json!({"name": "Dots", "pattern": "Dots"})); + } +} diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index 5b6622ee..0800c299 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -28,7 +28,7 @@ const MODES: [(Mode, &str); 5] = [(Mode::Grayscale, "gray"), (Mode::Rgb, "rgb"), const TYPES: [&str; 2] = ["Process Color", "Spot Color"]; -fn mode_id(m: Mode) -> &'static str { +pub(super) fn mode_id(m: Mode) -> &'static str { MODES.iter().find(|x| x.0 == m).map_or("rgb", |x| x.1) } @@ -38,7 +38,7 @@ fn mode_of(d: &Dialog) -> Mode { } /// The dialog's colour: a hex string or a serialized colour. -fn color_of(d: &Dialog) -> Color { +pub(super) fn color_of(d: &Dialog) -> Color { match d.fields.get("color") { Some(Value::String(s)) => Color::from_hex(s), Some(v) => serde_json::from_value(v.clone()).ok(), @@ -116,16 +116,30 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { run_and_close(app, "swatch.edit", edit_params(d)) } +/// The fields' grid of the swatch dialogs. +pub(super) fn grid(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + egui::Grid::new("swatch-options").num_columns(2).spacing([10.0, 8.0]).show(ui, add).inner +} + +/// A dimmed field label in the swatch dialogs' grid. +pub(super) fn label(ui: &mut egui::Ui, s: &str) { + ui.label(egui::RichText::new(s).color(Tokens::get(ui.ctx()).text_dim)); +} + +/// The Swatch Name row of the grid. Returns true when it changed. +pub(super) fn name_row(ui: &mut egui::Ui, d: &mut Dialog) -> bool { + label(ui, "Swatch Name:"); + let changed = form::text(ui, d, "name", 190.0); + ui.end_row(); + changed +} + /// The shared fields of Swatch Options and New Swatch: name, colour type, Global, mode, sliders and /// hex. Returns true when anything changed. pub(super) fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool { - let t = Tokens::get(ui.ctx()); - let label = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim)); let mut changed = false; - egui::Grid::new("swatch-options").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { - label(ui, "Swatch Name:"); - changed |= form::text(ui, d, "name", 190.0); - ui.end_row(); + grid(ui, |ui| { + changed |= name_row(ui, d); label(ui, "Color Type:"); let spot = d.bool("spot"); if let Some(i) = widgets::dropdown(ui, "swatch-type", TYPES[usize::from(spot)], &TYPES, 200.0) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c9a4a7b9..326640dd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -166,6 +166,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit), the Gradient panel for a gradient, pattern editing for a pattern", ), + ( + "ui.newSwatch", + "New Swatch…", + "", + "{spot?, group?: colour group name} open New Swatch for the active fill or stroke (dialog `newSwatch`, engine: swatch.new)", + ), + ( + "ui.newColorGroup", + "New Color Group…", + "", + "{swatches?: [names]} open New Color Group, from those swatches or the selected artwork (dialog `newColorGroup`, engine: swatch.newGroup)", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -492,6 +504,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::swatch_options::open(app, &name), None => Err("missing `name`".into()), }, + "ui.newSwatch" => crate::dialogs::new_swatch::open(app, p.get("spot").and_then(Value::as_bool).unwrap_or(false), s("group").as_deref()), + "ui.newColorGroup" => { + let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::new_color_group::open(app, names.unwrap_or_default()) + } _ => return None, }; Some(r) @@ -654,7 +671,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), "file.export.pdf" => app.session.active().is_some(), - "ui.swatchOptions" => app.session.active().is_some(), + "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), _ => true, } } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index e029cbf0..b769e871 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -451,11 +451,12 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { } ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); if widgets::icon_button(ui, "dc-folder", "New Color Group", false, 24.0).clicked() { - app.run("swatch.newGroup", json!({"swatches": deletable(sel)})).ok(); + app.run("ui.newColorGroup", json!({"swatches": deletable(sel)})).ok(); } - // Ctrl/Cmd-click makes a spot colour (Alt-click skips the dialog). + // Alt-click skips the dialog; Ctrl/Cmd-click makes a spot colour. if widgets::icon_button(ui, "dc-new-item", "New Swatch", false, 24.0).clicked() { - new_swatch(app, ui.input(|i| i.modifiers.command)); + let m = ui.input(|i| i.modifiers); + new_swatch(app, sel, m.command, m.alt); } // Alt-click deletes without asking. let del = deletable(sel); @@ -465,10 +466,31 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { }); } -/// Save the active paint as a new swatch (a spot colour with `spot`). -fn new_swatch(app: &mut VectorcraftApp, spot: bool) { - let mut params = super::paint_params(&active_paint(app)); +/// The colour group the selection points at: the last selected group, or the group of the last +/// selected swatch. +fn target_group(app: &VectorcraftApp, sel: &[String]) -> Option { + let d = &app.session.active()?.doc; + let last = sel.last()?; + if d.swatch_groups.iter().any(|g| g.name == *last) { + return Some(last.clone()); + } + d.swatch_group_of(last).map(|g| d.swatch_groups[g].name.clone()) +} + +/// New Swatch from the active paint (a colour goes into the selected colour group): opens the +/// dialog, or saves at once with `now` (Alt-click). `spot` (Cmd/Ctrl-click) makes a spot colour. +fn new_swatch(app: &mut VectorcraftApp, sel: &[String], spot: bool, now: bool) { + let paint = active_paint(app); + let group = paint.color().and_then(|_| target_group(app, sel)); + if !now { + app.run("ui.newSwatch", json!({"spot": spot, "group": group})).ok(); + return; + } + let mut params = super::paint_params(&paint); params["spot"] = json!(spot); + if let Some(g) = group { + params["group"] = json!(g); + } app.run("swatch.new", params).ok(); } @@ -478,10 +500,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let del = deletable(&selected); let opts = editable(app, &selected); if menu_item(ui, "New Swatch…", true, false) { - new_swatch(app, false); + new_swatch(app, &selected, false, false); } if menu_item(ui, "New Color Group…", true, false) { - app.run("swatch.newGroup", json!({"swatches": del})).ok(); + app.run("ui.newColorGroup", json!({"swatches": del})).ok(); } if menu_item(ui, "Duplicate Swatch", opts.is_some(), false) && let Some(n) = &opts @@ -678,6 +700,64 @@ mod tests { assert_eq!(deletable(&["[None]".into(), REGISTRATION.into(), "Grays".into()]), ["Grays"]); } + #[test] + fn new_swatch_dialog_prefills_the_active_colour_and_saves_into_the_selected_group() { + let mut app = app(); + let ctx = context(); + app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap(); + new_swatch(&mut app, &["Bright Red".into()], true, false); + let d = app.ui.dialog.as_ref().expect("New Swatch opens its dialog"); + assert_eq!( + (d.kind.as_str(), d.str("name").as_str(), d.str("group").as_str()), + (crate::dialogs::new_swatch::KIND, "R=255 G=128 B=0", "Brights") + ); + assert!(d.bool("spot") && d.bool("global"), "Cmd/Ctrl-click makes a spot colour"); + dialog_frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + let w = doc.swatch("R=255 G=128 B=0").expect("created"); + assert!(w.spot && doc.swatch_group_of(&w.name).is_some_and(|g| doc.swatch_groups[g].name == "Brights")); + // Alt-click saves at once; a gradient takes only a name and stays out of colour groups. + app.run("paint.setFill", json!({"swatch": "Sunset"})).unwrap(); + new_swatch(&mut app, &["Brights".into()], false, true); + assert!(app.ui.dialog.is_none() && app.session.doc().unwrap().doc.swatch("New Gradient Swatch 1").is_some()); + new_swatch(&mut app, &[], false, false); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("name").as_str(), d.fields.contains_key("__paint")), ("New Gradient Swatch 2", true)); + dialog_frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.swatch("New Gradient Swatch 2").is_some_and(|w| matches!(w.paint, Paint::Gradient(_)))); + } + + #[test] + fn new_color_group_dialog_makes_a_group_from_swatches_or_artwork() { + let mut app = app(); + let ctx = context(); + app.run("ui.newColorGroup", json!({"swatches": ["Red", "Sunset"]})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("name").as_str(), d.bool("fromArtwork")), (crate::dialogs::new_color_group::KIND, "Color Group", false)); + dialog_frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + let g = doc.swatch_groups.iter().find(|g| g.name == "Color Group").expect("group made"); + assert_eq!(g.swatches.iter().map(|w| w.name.as_str()).collect::>(), ["Red"], "gradients stay out"); + // With art selected (and no swatches) it starts from the artwork: its colours become global. + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "color": "#123456"})).unwrap(); + app.run("paint.setStroke", json!({"ids": [id], "none": true})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("ui.newColorGroup", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("name").as_str(), d.bool("fromArtwork"), d.bool("toGlobal")), ("Color Group 2", true, true)); + dialog_frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + let g = doc.swatch_groups.iter().find(|g| g.name == "Color Group 2").expect("group made"); + assert_eq!(g.swatches.iter().map(|w| (w.name.as_str(), w.global)).collect::>(), [("R=18 G=52 B=86", true)]); + let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()) }); + } + #[test] fn kind_filter() { let solid = Paint::solid(Color::BLACK); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 968b6e9b..02c7bac9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -356,35 +356,48 @@ pub fn link_label(ui: &mut Ui, text: &str) -> Response { resp } -/// Illustrator-style checkbox with a disabled state. Returns true when toggled. -pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { +/// The row of a checkbox or radio button: allocates a 13 pt box plus `label`, draws the label and +/// returns the box, the response and the box's border colour. +fn choice_row(ui: &mut Ui, label: &str, enabled: bool) -> (Rect, Response, Color32) { let t = Tokens::get(ui.ctx()); let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled }); let (rect, resp) = ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), if enabled { Sense::click() } else { Sense::hover() }); let bx = Rect::from_min_size(pos2(rect.left(), rect.center().y - 6.5), Vec2::splat(13.0)); + ui.painter().galley(pos2(bx.right() + 5.0, rect.center().y - galley.size().y / 2.0), galley, t.text); + let border = if !enabled { + t.divider + } else if resp.hovered() { + t.text + } else { + t.button_border + }; + (bx, resp, border) +} + +/// Panel-style checkbox with a disabled state. Returns true when toggled. +pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (bx, resp, border) = choice_row(ui, label, enabled); ui.painter().rect_filled(bx, CornerRadius::same(2), if value && enabled { t.accent_strong } else { t.input }); - ui.painter().rect_stroke( - bx, - CornerRadius::same(2), - Stroke::new( - 1.0, - if !enabled { - t.divider - } else if resp.hovered() { - t.text - } else { - t.button_border - }, - ), - StrokeKind::Inside, - ); + ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside); if value { let c = if enabled { Color32::WHITE } else { t.text_disabled }; ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c)); ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c)); } - ui.painter().galley(pos2(bx.right() + 5.0, rect.center().y - galley.size().y / 2.0), galley, t.text); + enabled && resp.clicked() +} + +/// Radio button in the style of [`check`], with a disabled state. Returns true when clicked. +pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (bx, resp, border) = choice_row(ui, label, enabled); + let c = bx.center(); + ui.painter().circle(c, 6.0, if selected && enabled { t.accent_strong } else { t.input }, Stroke::new(1.0, border)); + if selected { + ui.painter().circle_filled(c, 2.5, if enabled { Color32::WHITE } else { t.text_disabled }); + } enabled && resp.clicked() } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9dfda508..394cca9b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -28,3 +28,8 @@ pattern editing instead. Confirmations: deleting swatches from the Swatches panel opens a `confirm` dialog (fields `message`, `detail`); `ui.dialog.confirm` runs the command it asks about (here `swatch.delete`) and `ui.dialog.cancel` drops it. + +New swatches and colour groups: `ui.newSwatch {spot?, group?}` opens the `newSwatch` dialog prefilled from the active +fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern only takes `name`) and +`ui.newColorGroup {swatches?}` opens `newColorGroup` (fields `name`, `fromArtwork`, `toGlobal`, `includeTints`, +`swatches`); `ui.dialog.confirm` runs `swatch.new` / `swatch.newGroup`. diff --git a/docs/mcp.md b/docs/mcp.md index 84c4296a..99daf0f6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -75,6 +75,9 @@ Swatches go through `run_command`: `swatch.list` lists every swatch with its col `swatch.edit {name, newName?, color?, mode?, global?, spot?}` edits one (fills, strokes and text linked to a global or spot swatch follow its colour and name, as one undo step); `swatch.delete {names, unlink?}` deletes swatches and colour groups in one undo step (art using a deleted global swatch keeps its colour, unlinked). +`swatch.new {color?, mode?, spot?, global?, group?}` saves a colour (into a colour group with `group`), gradient +or pattern; `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected +art's colours (by default as global swatches the art links to). ## Resources From e9e4b94ed0b2dcfbffd9986f063e207958854ef9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:45:47 +0700 Subject: [PATCH 6/8] M3.10: default swatch names never read "-0" (components are clamped first); one numbering helper for free and new swatch names Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/swatches.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 7f6f6c3b..bba962c0 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -38,7 +38,11 @@ impl Document { if !self.swatch_name_taken(base) { return base.to_string(); } - (2..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string()) + self.numbered_swatch_name(base, 2) + } + /// The first of "base `from`", "base `from + 1`"… no swatch or colour group uses. + 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()) } /// 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", @@ -49,7 +53,7 @@ impl Document { Paint::Gradient(_) => "New Gradient Swatch", _ => "New Pattern Swatch", }; - (1..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string()) + self.numbered_swatch_name(base, 1) } /// Remove swatch `name` from wherever it lives. pub fn remove_swatch(&mut self, name: &str) -> Option { @@ -104,8 +108,9 @@ impl Document { /// 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 { - let pct = |v: f32| (v * 100.0).round(); - let byte = |v: f32| (v * 255.0).round(); + // Clamped first, so a conversion's tiny negative never reads "-0". + let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round(); + let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round(); match c { Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)), @@ -268,6 +273,17 @@ mod tests { assert!(d.swatch("Brand").is_none() && d.remove_swatch("Brand").is_none()); } + #[test] + fn default_names_follow_the_colour_model() { + assert_eq!(color_name(Color::cmyk(0.1, 0.2, 0.3, -1e-7)), "C=10 M=20 Y=30 K=0"); + assert_eq!(color_name(Color::rgb(1.0, 0.5, -1e-7)), "R=255 G=128 B=0"); + assert_eq!(color_name(Color::Gray { k: 0.4 }), "Gray K=40"); + let (d, _, _) = doc(); + assert_eq!(d.free_swatch_name("Brand"), "Brand 2"); + assert_eq!(d.free_swatch_name("Mine"), "Mine 2", "colour groups share the namespace"); + assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0"); + } + #[test] fn mapping_copies_only_changed_nodes() { let (mut d, a, b) = doc(); From d1d133bf095509f44dfd740cb8937a0300b00b53 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:45:49 +0700 Subject: [PATCH 7/8] M3.13: swatch.newGroup falls back to "Color Group" for a blank name, never gives a swatch its own group's name, and takes mesh point colours from the artwork Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/swatches.rs | 5 ++++- crates/engine/src/cmd/swatch.rs | 6 +++--- crates/engine/src/tests_swatches.rs | 24 ++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index bba962c0..6ff1c173 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -119,7 +119,7 @@ pub fn color_name(c: Color) -> String { } /// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes and text -/// runs (with their link) and gradient stops (unlinked), in paint order. +/// runs (with their link), then gradient stops and mesh points (unlinked), in paint order. pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) { n.walk(&mut |m| { for p in node_paints(m) { @@ -129,6 +129,9 @@ pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) { _ => {} } } + if let NodeKind::Mesh(mesh) = &m.kind { + mesh.points.iter().for_each(|p| f(&p.color, None)); + } }); } diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index e020a0d7..0aedd729 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -35,7 +35,7 @@ pub fn specs() -> Vec { "New Color Group", ["Window", "Swatches"], None, - "{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches), fromArtwork?: false (add the unique colours of the selected art: fills, strokes, text and gradient stops; a colour linked to a global swatch moves that swatch into the group), toGlobal?: true (with fromArtwork: the new swatches are global and the selected art's matching unlinked colours link to them), includeTints?: false (with fromArtwork: tints of global swatches also get swatches of their own)} → {name, swatches: [names in the group], linked: paints linked}", + "{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches), fromArtwork?: false (add the unique colours of the selected art: fills, strokes, text, gradient stops and mesh points; a colour linked to a global swatch moves that swatch into the group), toGlobal?: true (with fromArtwork: the new swatches are global and the selected art's matching unlinked colours link to them), includeTints?: false (with fromArtwork: tints of global swatches also get swatches of their own)} → {name, swatches: [names in the group], linked: paints linked}", has_doc, swatch_new_group ), @@ -168,7 +168,7 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { let from_art = bool_or(p, "fromArtwork", false); let to_global = from_art && bool_or(p, "toGlobal", true); let tints = bool_or(p, "includeTints", false); - let requested = str_param(p, "name").map(str::to_string); + let requested = name_param(p, "name"); let (name, members, linked) = s.edit("New Color Group", |d, sel| { // New swatches: (colour, global). let mut new: Vec<(Color, bool)> = colors.iter().map(|c| (*c, false)).collect(); @@ -193,7 +193,7 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { // The global swatches made from the art's colours, which its unlinked paints link to. let mut links: Vec<(Color, String)> = vec![]; for (c, global) in new { - let nm = unique_name(&color_name(c), |n| d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n)); + let nm = unique_name(&color_name(c), |n| n == name || d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n)); if global { links.push((c, nm.clone())); } diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs index eeaca99a..2062cfae 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests_swatches.rs @@ -324,3 +324,27 @@ fn tints_of_global_swatches_get_swatches_only_when_asked() { assert_eq!(with["swatches"], json!(["Brand", "R=255 G=0 B=0", "R=0 G=255 B=0", "R=149 G=183 B=216"])); assert_eq!(doc(&s).node(b).unwrap().appearance.stroke_paint(), Paint::Solid { color: tint, swatch: Some("Brand".into()) }, "tints stay linked"); } + +#[test] +fn group_names_are_never_blank_and_never_shared_with_their_swatches() { + let mut s = session(); + art(&mut s); + // A blank name falls back to the default; a swatch never takes its own group's name. + let r = run(&mut s, "swatch.newGroup", json!({"name": " ", "colors": ["#0000ff"]})); + assert_eq!(r["name"], "Color Group"); + let r = run(&mut s, "swatch.newGroup", json!({"name": "R=255 G=0 B=0", "fromArtwork": true})); + assert_eq!(r["name"], "R=255 G=0 B=0"); + assert_eq!(r["swatches"], json!(["Brand", "R=255 G=0 B=0 2", "R=0 G=255 B=0"])); +} + +#[test] +fn a_group_from_a_mesh_holds_its_point_colours() { + let mut s = session(); + let a = rect(&mut s); + run(&mut s, "paint.setFill", json!({"ids": [a.0], "color": "#ff0000"})); + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "object.mesh.create", json!({"rows": 2, "cols": 2, "appearance": "center", "highlight": 100})); + let r = run(&mut s, "swatch.newGroup", json!({"fromArtwork": true})); + let names = r["swatches"].as_array().unwrap(); + assert!(names.contains(&json!("R=255 G=0 B=0")) && names.contains(&json!("R=255 G=255 B=255")), "{r}"); +} From 8910f8fd1e79e328d692fb57e8421726f7e5bca9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:45:50 +0700 Subject: [PATCH 8/8] M3.12: the dialog Cancel button reuses dialogs::cancel; the Swatches panel no longer clones its selection every frame Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/mod.rs | 5 +---- crates/ui-egui/src/panels/swatches.rs | 3 ++- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 4c451a32..91e55b37 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -221,10 +221,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } app.ui.dialog = Some(d); if cancel { - if spec.preview { - let _ = app.session.cancel_interaction(); - } - app.ui.dialog = None; + self::cancel(app); } else if ok && let Err(e) = confirm(app) { app.status(e); } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index b769e871..957daf28 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -263,7 +263,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; let selected = selection(app, ui); // Without a selection the swatch of the active paint is highlighted. - let sel: Vec = if selected.is_empty() { active_swatch.into_iter().collect() } else { selected.clone() }; + let fallback: Vec = if selected.is_empty() { active_swatch.into_iter().collect() } else { vec![] }; + let sel = if selected.is_empty() { &fallback } else { &selected }; let is_sel = |name: &str| sel.iter().any(|s| s == name); let (tile, pitch) = view.tile(); let mut clicked: Option<(Entry, egui::Modifiers)> = None;