From 7da0bfb1c62bc375a7a6fa1190ce965ac37c1784 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:26:24 +0700 Subject: [PATCH] =?UTF-8?q?M3.13:=20New=20Swatch=20and=20New=20Color=20Gro?= =?UTF-8?q?up=20dialogs=20=E2=80=94=20New=20Swatch=20reuses=20the=20Swatch?= =?UTF-8?q?=20Options=20fields=20prefilled=20from=20the=20active=20colour?= =?UTF-8?q?=20(its=20default=20name=20follows=20the=20colour),=20goes=20in?= =?UTF-8?q?to=20the=20selected=20colour=20group,=20Alt-click=20skips=20it?= =?UTF-8?q?=20and=20Cmd/Ctrl=20makes=20a=20spot=20colour;=20New=20Color=20?= =?UTF-8?q?Group=20makes=20a=20group=20from=20the=20selected=20swatches=20?= =?UTF-8?q?or=20the=20selected=20artwork's=20colours,=20optionally=20as=20?= =?UTF-8?q?global=20swatches=20the=20art=20links=20to=20and=20with=20swatc?= =?UTF-8?q?hes=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