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 |