M3.11: Swatch Options — double-click, the options button or the menu item opens a colour swatch's editor (name, process/spot, Global, Gray/RGB/HSB/CMYK/Web sliders, hex) with a live canvas preview; gradients open the Gradient panel and patterns pattern editing; swatch.edit recolours and renames every linked fill, stroke and text run in one undo step, spot forces Global, grouped swatches edit too; swatch.list query

ui.dialog.cancel over the control channel now rolls back a dialog's live preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 12:32:17 +07:00
parent 2f21fc3a66
commit d272d32be3
10 changed files with 679 additions and 20 deletions

View File

@ -32,6 +32,24 @@ pub fn specs() -> Vec<CommandSpec> {
),
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<Value> {
_ 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<Value> {
Ok(json!({"name": new}))
}
/// A trimmed, non-empty name parameter.
fn name_param(p: &Value, key: &str) -> Option<String> {
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<Color> {
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<Color>,
/// 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<String>) -> 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<Value> {
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<Value> {
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<Value> = ungrouped.chain(grouped).collect();
let groups: Vec<Value> =
groups.iter().map(|g| json!({"name": g.name, "swatches": g.swatches.iter().map(|w| &w.name).collect::<Vec<_>>()})).collect();
Ok(json!({"swatches": swatches, "groups": groups}))
}
fn swatch_sort(s: &mut Session, _: &Value) -> Result<Value> {
s.edit("Sort Swatches", |d, _| {
// [None] and other bracketed specials stay first, like the reference app.

View File

@ -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());
}

View File

@ -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" => {

View File

@ -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]`.

View File

@ -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()) };

View File

@ -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<Value, String> {
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<Value, String> {
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<f32> = 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<f32>)> = 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<Color> {
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::<String>(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"))
);
}
}

View File

@ -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<R
},
"file.closeAll" => 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,
}
}

View File

@ -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<Entry> = None;
let mut edit_pattern: Option<String> = None;
// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing).
let mut edit: Option<String> = 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<(usize, &Entry)>> = vec![vec![]];
for (i, e) in items.iter().enumerate() {
let mut rows: Vec<Vec<&Entry>> = 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<String> {
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<String> {
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<String> = 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<Event>, 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<Event> {
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() {

View File

@ -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.

View File

@ -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 |