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:
parent
2f21fc3a66
commit
d272d32be3
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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]`.
|
||||
|
|
|
|||
|
|
@ -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()) };
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue