M3.58: gradient libraries; Swatch Options renames gradient swatches; Alt-drop replaces a swatch

Window > Swatch Libraries > Gradients lists five generated gradient
libraries (Spectrum, Sky, Neutral incl. fades to transparent, Duotone,
Radial Glows); the library panel's menu lists them too.
Swatch Options on a gradient swatch shows its name and the gradient; OK
renames it. Alt-dropping an edited gradient (or a colour) on a swatch of its
kind in the Swatches panel replaces it, via swatch.edit {paint}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 00:46:45 +07:00
parent 786f2c05d3
commit 3a79e6d922
9 changed files with 275 additions and 53 deletions

View File

@ -67,19 +67,32 @@ pub const SWATCH_LIBRARIES: &[BuiltinLibrary] = &[
lib!("harmony-sets", "Harmony Sets", harmony_sets),
];
/// Built-in library `id`, computed once.
/// The built-in gradient libraries (Window → Swatch Libraries → Gradients), in menu order.
pub const GRADIENT_LIBRARIES: &[BuiltinLibrary] = &[
lib!("spectrum-gradients", "Spectrum Gradients", spectrum_gradients),
lib!("sky-gradients", "Sky Gradients", sky_gradients),
lib!("neutral-gradients", "Neutral Gradients", neutral_gradients),
lib!("duotone-gradients", "Duotone Gradients", duotone_gradients),
lib!("radial-glows", "Radial Glows", radial_glows),
];
/// Every built-in library: the swatch libraries, then the gradient libraries.
fn builtins() -> impl Iterator<Item = &'static BuiltinLibrary> {
SWATCH_LIBRARIES.iter().chain(GRADIENT_LIBRARIES)
}
/// Built-in library `id` (swatch or gradient library), computed once.
pub fn builtin_library(id: &str) -> Option<Arc<SwatchLibrary>> {
static LIBS: OnceLock<Vec<Arc<SwatchLibrary>>> = OnceLock::new();
let libs = LIBS.get_or_init(|| {
SWATCH_LIBRARIES
.iter()
builtins()
.map(|b| {
let (swatches, groups) = (b.make)();
Arc::new(SwatchLibrary { name: b.name.into(), swatches, groups })
})
.collect()
});
SWATCH_LIBRARIES.iter().position(|b| b.id == id).map(|i| libs[i].clone())
builtins().position(|b| b.id == id).map(|i| libs[i].clone())
}
// ---------- colour construction ----------
@ -287,6 +300,88 @@ fn metallic() -> (Vec<Swatch>, Vec<SwatchGroup>) {
/// OKLCH `(l, c, h°)` anchors a scale passes through.
type Anchors = &'static [(f64, f64, f64)];
/// A linear gradient through evenly spaced, opaque `colors`.
fn even(name: &str, kind: GradientKind, colors: &[Color]) -> Swatch {
let last = (colors.len() - 1).max(1) as f32;
let stops: Vec<(f32, Color, f32)> = colors.iter().enumerate().map(|(i, c)| (i as f32 / last, *c, 1.0)).collect();
gradient(name, kind, &stops)
}
/// OKLCH `(l, c, h°)` colours.
fn lch(points: &[(f64, f64, f64)]) -> Vec<Color> {
points.iter().map(|&(l, c, h)| oklch(l, c, h)).collect()
}
/// Full-hue sweeps and hue-neighbour blends.
fn spectrum_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let wheel: Vec<Color> = (0..=6).map(|i| oklch(0.72, 0.4, 25.0 + i as f64 * 60.0)).collect();
let mut s = vec![
even("Spectrum", GradientKind::Linear, &wheel),
even("Soft Spectrum", GradientKind::Linear, &lch(&[(0.88, 0.08, 25.0), (0.9, 0.08, 145.0), (0.86, 0.08, 265.0), (0.88, 0.08, 25.0)])),
];
// Each hue to the one two steps round the wheel.
s.extend((0..HUES.len()).map(|i| {
let ((a, ha), (b, hb)) = (HUES[i], HUES[(i + 2) % HUES.len()]);
even(&format!("{a} to {b}"), GradientKind::Linear, &[oklch(0.7, 0.4, ha), oklch(0.7, 0.4, hb)])
}));
(s, vec![])
}
fn sky_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let skies: [(&str, Anchors); 6] = [
("Dawn", &[(0.45, 0.08, 280.0), (0.72, 0.12, 350.0), (0.9, 0.09, 75.0)]),
("Noon", &[(0.55, 0.13, 250.0), (0.78, 0.09, 235.0), (0.95, 0.03, 220.0)]),
("Sunset", &[(0.35, 0.09, 300.0), (0.62, 0.18, 20.0), (0.85, 0.15, 70.0)]),
("Dusk", &[(0.25, 0.07, 275.0), (0.48, 0.1, 310.0), (0.7, 0.1, 30.0)]),
("Night", &[(0.15, 0.04, 270.0), (0.3, 0.07, 265.0)]),
("Overcast", &[(0.62, 0.015, 240.0), (0.86, 0.01, 230.0)]),
];
(skies.iter().map(|(n, p)| even(n, GradientKind::Linear, &lch(p))).collect(), vec![])
}
fn neutral_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let (white, black) = (Color::gray(0.0), Color::gray(1.0));
let s = vec![
even("White to Black", GradientKind::Linear, &[white, black]),
even("Light Grays", GradientKind::Linear, &[white, Color::gray(0.4)]),
even("Dark Grays", GradientKind::Linear, &[Color::gray(0.6), black]),
even("Warm Grays", GradientKind::Linear, &lch(&[(0.95, 0.012, 70.0), (0.3, 0.012, 70.0)])),
even("Cool Grays", GradientKind::Linear, &lch(&[(0.95, 0.015, 250.0), (0.3, 0.015, 250.0)])),
gradient("Fade to Transparent", GradientKind::Linear, &[(0.0, black, 1.0), (1.0, black, 0.0)]),
gradient("White Fade", GradientKind::Linear, &[(0.0, white, 1.0), (1.0, white, 0.0)]),
gradient("Vignette", GradientKind::Radial, &[(0.0, black, 0.0), (0.6, black, 0.0), (1.0, black, 0.7)]),
];
(s, vec![])
}
/// Pairs of hues from opposite sides of the wheel, at different lightness.
fn duotone_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let pairs = [
("Coral", 0.7, 30.0, "Navy", 0.32, 265.0),
("Plum", 0.38, 330.0, "Lime", 0.88, 125.0),
("Teal", 0.5, 190.0, "Gold", 0.85, 90.0),
("Indigo", 0.3, 280.0, "Peach", 0.86, 55.0),
("Forest", 0.35, 150.0, "Rose", 0.8, 0.0),
];
let s = pairs
.iter()
.map(|&(a, la, ha, b, lb, hb)| even(&format!("{a} and {b}"), GradientKind::Linear, &[oklch(la, 0.15, ha), oklch(lb, 0.15, hb)]))
.collect();
(s, vec![])
}
/// A bright centre fading to a transparent edge, per hue.
fn radial_glows() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let s = HUES
.iter()
.map(|(n, h)| {
let c = oklch(0.78, 0.2, *h);
gradient(&format!("{n} Glow"), GradientKind::Radial, &[(0.0, Color::WHITE, 1.0), (0.25, c, 1.0), (1.0, c, 0.0)])
})
.collect();
(s, vec![])
}
/// The sequential scales of [`perceptual`]: name and anchors of rising lightness.
const SEQUENTIAL: [(&str, Anchors); 5] = [
("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]),
@ -348,7 +443,7 @@ mod tests {
use super::*;
fn all() -> Vec<Arc<SwatchLibrary>> {
SWATCH_LIBRARIES.iter().map(|b| builtin_library(b.id).unwrap()).collect()
builtins().map(|b| builtin_library(b.id).unwrap()).collect()
}
/// Every component of every colour (solid or gradient stop).
@ -370,10 +465,10 @@ mod tests {
#[test]
fn libraries_are_deterministic_unique_and_in_range() {
let mut ids = std::collections::HashSet::new();
for (b, lib) in SWATCH_LIBRARIES.iter().zip(all()) {
for (b, lib) in builtins().zip(all()) {
assert!(ids.insert(b.id), "duplicate id {}", b.id);
assert_eq!(lib.name, b.name);
assert!(lib.len() >= 8, "{} has {}", b.id, lib.len());
assert!(lib.len() >= 5, "{} has {}", b.id, lib.len());
// Recomputing gives the same library.
let (s, g) = (b.make)();
assert_eq!((s, g), (lib.swatches.clone(), lib.groups.clone()), "{} is deterministic", b.id);
@ -420,6 +515,10 @@ mod tests {
#[test]
fn gradient_libraries_have_sorted_stops() {
for b in GRADIENT_LIBRARIES {
let lib = builtin_library(b.id).unwrap();
assert!(lib.iter().all(|s| matches!(s.paint, Paint::Gradient(_))), "{} holds gradients only", b.id);
}
for lib in all() {
for s in lib.iter() {
if let Paint::Gradient(g) = &s.paint {

View File

@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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}",
"{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour, gradient or pattern with one of the same kind} 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
),
@ -474,9 +474,21 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
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"));
}
// A replacement paint of the swatch's kind (a dropped colour or edited gradient).
let paint = match p.get("paint").filter(|v| !v.is_null()) {
Some(v) => match (paint_from(s, v)?, &sw.paint) {
(Some(Paint::Solid { color, .. }), Paint::Solid { .. }) => Some(Paint::solid(color)),
(Some(Paint::Gradient(g)), Paint::Gradient(_)) => {
Some(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { geom: None, swatch: None, ..*g })))
}
(Some(new @ Paint::Pattern { .. }), Paint::Pattern { .. }) => Some(new),
_ => return Err(bad(C, format!("`{name}` takes a paint of its own kind (colour, gradient or pattern)"))),
},
None => None,
};
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,
None => paint.as_ref().and_then(Paint::color).or(solid),
};
if let (Some(c), Some(m)) = (color, str_param(p, "mode")) {
color = Some(convert_to_mode(c, m, C)?);
@ -490,8 +502,13 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
w.name = to.clone();
w.spot = spot;
w.global = global;
if let (Some(c), Paint::Solid { color: wc, .. }) = (color, &mut w.paint) {
*wc = c;
match (color, &mut w.paint) {
(Some(c), Paint::Solid { color: wc, .. }) => *wc = c,
(_, wp) => {
if let Some(new) = paint {
*wp = new;
}
}
}
Ok(d.map_solid_paints(&mut |c, l| relink.apply(c, l)))
})?;

View File

@ -7,7 +7,7 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::libraries::{SWATCH_LIBRARIES, builtin_library};
use vectorcraft_color::libraries::{BuiltinLibrary, GRADIENT_LIBRARIES, SWATCH_LIBRARIES, builtin_library};
use vectorcraft_color::palette_io::{self, PaletteFormat};
use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches};
@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swatch Libraries",
[],
None,
"{} → {libraries: [{id, name, category: \"builtIn\"|\"user\" (User Defined: the library files in the user library folder, rescanned now)|\"loaded\" (swatch.library.load), count, path?}], userFolder} every library the library panel opens, in menu order",
"{} → {libraries: [{id, name, category: \"builtIn\"|\"gradients\"|\"user\" (User Defined: the library files in the user library folder, rescanned now)|\"loaded\" (swatch.library.load), count, path?}], userFolder} every library the library panel opens, in menu order",
always,
list
),
@ -156,25 +156,34 @@ fn stem(name: &str) -> &str {
pub struct LibraryInfo {
pub id: String,
pub name: String,
/// "builtIn", "user" or "loaded".
/// "builtIn", "gradients", "user" or "loaded".
pub category: &'static str,
}
/// Every library, in menu order: built-in, User Defined, loaded.
/// The built-in libraries with their categories.
fn builtins() -> impl Iterator<Item = (&'static str, &'static BuiltinLibrary)> {
SWATCH_LIBRARIES.iter().map(|b| ("builtIn", b)).chain(GRADIENT_LIBRARIES.iter().map(|b| ("gradients", b)))
}
fn builtin_info((category, b): (&'static str, &BuiltinLibrary)) -> LibraryInfo {
LibraryInfo { id: b.id.into(), name: b.name.into(), category }
}
/// Every library, in menu order: built-in, gradients, User Defined, loaded.
pub fn libraries(s: &Session) -> Vec<LibraryInfo> {
let builtin = SWATCH_LIBRARIES.iter().map(|b| LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" });
builtin.chain(s.swatch_libraries.extra.iter().map(|e| e.info.clone())).collect()
builtins().map(builtin_info).chain(s.swatch_libraries.extra.iter().map(|e| e.info.clone())).collect()
}
/// Library `key` (an id, or a name in any case) with its info.
pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<SwatchLibrary>)> {
let all = libraries(s);
let info = all.iter().find(|l| l.id == key).or_else(|| all.iter().find(|l| l.name.eq_ignore_ascii_case(key)))?.clone();
let lib = match s.swatch_libraries.extra.iter().find(|e| e.info.id == info.id) {
Some(e) => e.lib.clone(),
None => builtin_library(&info.id)?,
};
Some((info, lib))
if let Some(e) = s.swatch_libraries.extra.iter().find(|e| e.info.id == key) {
return Some((e.info.clone(), e.lib.clone()));
}
if let Some(b) = builtins().find(|(_, b)| b.id == key) {
return Some((builtin_info(b), builtin_library(key)?));
}
let id = libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key))?.id;
library(s, &id)
}
fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<SwatchLibrary>)> {

View File

@ -196,3 +196,28 @@ fn another_documents_swatches_load_as_a_library() {
assert!(lib.swatch("Signal").is_some() && lib.swatch("[None]").is_none());
assert!(s.execute("swatch.library.load", &json!({"data": "hello", "name": "x.txt"})).is_err());
}
#[test]
fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradients() {
let mut s = session();
let list = run(&mut s, "swatch.library.list", json!({}));
let grads: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "gradients").collect();
assert!(grads.len() >= 5 && grads.iter().any(|l| l["id"] == "sky-gradients"));
run(&mut s, "swatch.library.add", json!({"library": "sky-gradients", "names": ["Dawn"]}));
// Rename keeps the gradient.
let before = doc(&s).swatch("Dawn").unwrap().paint.clone();
run(&mut s, "swatch.edit", json!({"name": "Dawn", "newName": "Early"}));
assert_eq!(doc(&s).swatch("Early").unwrap().paint, before);
// An edited gradient replaces it, unplaced and unlinked; one undo step.
let undo = undo_len(&s);
let g =
json!({"gradient": {"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "swatch": "Early"}});
run(&mut s, "swatch.edit", json!({"name": "Early", "paint": g}));
let Paint::Gradient(gp) = &doc(&s).swatch("Early").unwrap().paint else { panic!("a gradient") };
assert_eq!((gp.gradient.kind, gp.gradient.stops.len(), gp.swatch.clone(), gp.geom), (vectorcraft_color::GradientKind::Radial, 2, None, None));
assert_eq!(undo_len(&s), undo + 1);
// Kinds don't mix; a colour replaces a colour.
assert!(s.execute("swatch.edit", &json!({"name": "Early", "paint": {"color": "#00ff00"}})).is_err());
run(&mut s, "swatch.edit", json!({"name": "Red", "paint": {"color": "#00ff00"}}));
assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into()));
}

View File

@ -1,10 +1,12 @@
//! 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`]).
//! for a solid colour; for a gradient it shows the name and the gradient; patterns open 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`.
//! `{"model": "cmyk", "c": 0.1, "m": 0.2, "y": 0.3, "k": 0}`) and `preview`; a gradient swatch has
//! `name`, `preview` and `__gradient` (the gradient shown) only.
use egui::vec2;
use serde_json::{Value, json};
@ -51,8 +53,8 @@ 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.
/// Open the editor of swatch `name`: Swatch Options for a colour or a gradient (its name only),
/// 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 {
@ -64,9 +66,9 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
Paint::Gradient(_) => {
app.run(crate::panels::proxy_cmd(app, false), json!({"swatch": name}))?;
app.ui.open_panel = Some("gradient".into());
Paint::Gradient(g) => {
let fields = json!({"__swatch": name, "name": name, "__gradient": g.gradient, "preview": true});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
Paint::Pattern { pattern, .. } => {
@ -78,8 +80,11 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
}
}
/// `swatch.edit` parameters from the fields.
/// `swatch.edit` parameters from the fields (a gradient swatch only renames).
fn edit_params(d: &Dialog) -> Value {
if d.fields.contains_key("__gradient") {
return json!({"name": d.str("__swatch"), "newName": d.str("name")});
}
json!({
"name": d.str("__swatch"),
"newName": d.str("name"),
@ -91,7 +96,16 @@ fn edit_params(d: &Dialog) -> Value {
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let changed = editor(ui, d);
let changed = match d.fields.get("__gradient").and_then(|g| serde_json::from_value::<vectorcraft_color::Gradient>(g.clone()).ok()) {
Some(g) => {
let changed = grid(ui, |ui| name_row(ui, d));
ui.add_space(6.0);
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), egui::Sense::hover());
widgets::gradient_chip(ui, r, &g);
changed
}
None => editor(ui, d),
};
ui.add_space(8.0);
let mut pv = d.bool("preview");
let pv_changed = widgets::check(ui, "Preview", pv, true);

View File

@ -169,7 +169,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"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",
"{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit) or a gradient (the same dialog with `name` only; it shows the gradient), pattern editing for a pattern",
),
(
"ui.newSwatch",

View File

@ -415,8 +415,9 @@ mod tests {
assert!(ctx.read_response(tile_id::<SwatchLibraries>("#FF0033")).is_some());
assert!(ctx.read_response(tile_id::<SwatchLibraries>("#00FF00")).is_none());
let first = SWATCH_IDS[0];
let last = SWATCH_IDS[SWATCH_IDS.len() - 1];
assert_eq!(neighbour::<SwatchLibraries>(&app, first, -1).as_deref(), Some(last), "wraps around");
let last = SwatchLibraries::list(&app).pop().unwrap().id;
assert_eq!(last, "radial-glows", "gradient libraries come after the swatch libraries");
assert_eq!(neighbour::<SwatchLibraries>(&app, first, -1), Some(last), "wraps around");
assert_eq!(neighbour::<SwatchLibraries>(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1]));
}
@ -440,9 +441,17 @@ mod tests {
.filter(|e| e.path == ["Window", "Swatch Libraries"] && e.command.as_deref() == Some("window.swatchLibrary"))
.map(|e| e.params["library"].clone())
.collect();
let listed: Vec<String> = vectorcraft_engine::cmd::swatchlib::libraries(&app.session).into_iter().map(|l| l.id).collect();
assert_eq!(ids, listed.iter().map(|i| json!(i)).collect::<Vec<_>>());
assert_eq!(listed, SWATCH_IDS);
let all = vectorcraft_engine::cmd::swatchlib::libraries(&app.session);
let listed = |category: &str| all.iter().filter(|l| l.category == category).map(|l| json!(l.id)).collect::<Vec<_>>();
assert_eq!(ids, listed("builtIn"));
assert_eq!(ids, SWATCH_IDS.map(|i| json!(i)));
let gradients: Vec<Value> = crate::menus::menu_entries(&app)
.into_iter()
.filter(|e| e.path == ["Window", "Swatch Libraries", "Gradients"])
.map(|e| e.params["library"].clone())
.collect();
assert!(gradients.len() >= 5);
assert_eq!(gradients, listed("gradients"));
let defaults = crate::menus::menu_entries(&app).into_iter().any(|e| e.command.as_deref() == Some("swatch.resetDefaults"));
assert!(defaults, "Default Swatches is listed");
}

View File

@ -9,7 +9,7 @@ use std::sync::Arc;
use egui::{Color32, Rect, Response, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_color::libraries::SWATCH_LIBRARIES;
use vectorcraft_color::libraries::{BuiltinLibrary, GRADIENT_LIBRARIES, SWATCH_LIBRARIES};
use vectorcraft_color::{Color, GradientKind, Paint, Swatch, SwatchLibrary};
use vectorcraft_doc::Document;
use vectorcraft_engine::cmd::swatchlib;
@ -343,17 +343,18 @@ enum Drop {
/// at the end (`None`).
Move { names: Vec<String>, target: Option<String>, after: bool },
/// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row
/// `target` (or between rows) becomes a swatch.
New { paint: Paint, target: Option<String> },
/// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch
/// `target` when that is of its kind.
New { paint: Paint, target: Option<String>, replace: bool },
}
impl Drop {
/// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows
/// it carries, or make a swatch of a paint dragged from elsewhere (appearances: nothing).
fn of(d: &PanelDrag, target: Option<String>, after: bool) -> Option<Self> {
fn of(d: &PanelDrag, target: Option<String>, after: bool, replace: bool) -> Option<Self> {
match d {
PanelDrag::Paint { rows: Some(r), .. } => Some(Drop::Move { names: r.names.clone(), target, after }),
PanelDrag::Paint { paint, .. } => Some(Drop::New { paint: paint.clone(), target }),
PanelDrag::Paint { paint, .. } => Some(Drop::New { paint: paint.clone(), target, replace }),
PanelDrag::Appearance(_) => None,
}
}
@ -440,7 +441,7 @@ fn tile_drop(ui: &Ui, resp: &Response, e: &Entry, list: bool, ev: &mut TileEvent
ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent));
}
if resp.dnd_release_payload::<PanelDrag>().is_some() {
ev.drop = Drop::of(&d, Some(name.to_string()), after);
ev.drop = Drop::of(&d, Some(name.to_string()), after, super::alt_held(ui));
}
}
@ -452,7 +453,7 @@ fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) {
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
}
if zone.dnd_release_payload::<PanelDrag>().is_some() {
ev.drop = Drop::of(&d, None, false);
ev.drop = Drop::of(&d, None, false, false);
}
}
@ -497,6 +498,11 @@ fn move_params(d: &Document, names: &[String], target: Option<&str>, after: bool
(!movable.is_empty()).then(|| json!({"names": movable, "to": to, "group": group}))
}
/// Are both paints colours, gradients or patterns?
fn same_kind(a: &Paint, b: &Paint) -> bool {
std::mem::discriminant(a) == std::mem::discriminant(b) && !a.is_none()
}
/// Run a drop on the panel: move the swatches or make a swatch of the proxy's paint (a colour
/// dropped on a folder or a grouped swatch joins that group).
fn apply_drop(app: &mut VectorcraftApp, drop: Drop) {
@ -506,7 +512,11 @@ fn apply_drop(app: &mut VectorcraftApp, drop: Drop) {
Some(p) => ("swatch.move", p),
None => return,
},
Drop::New { paint, target } => {
// Alt: an edited gradient (or colour) replaces the swatch it is dropped on.
Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| same_kind(&w.paint, &paint)) => {
("swatch.edit", json!({"name": t, "paint": super::paint_params(&paint)}))
}
Drop::New { paint, target, .. } => {
let mut p = super::paint_params(&paint);
if paint.color().is_some()
&& let Some(g) = target.and_then(|t| group_of(d, &t))
@ -908,6 +918,7 @@ impl LibraryKind for SwatchLibraries {
fn list(app: &VectorcraftApp) -> Vec<LibraryRef> {
let submenu = |category: &str| match category {
"gradients" => Some("Gradients"),
"user" => Some("User Defined"),
"loaded" => Some("Other Libraries"),
_ => None,
@ -1035,9 +1046,11 @@ pub(crate) fn window_menu() -> Vec<Item> {
"window.userSwatchLibrary9",
"window.userSwatchLibrary10",
];
let open = |b: &'static BuiltinLibrary| Item::Cmd(b.name, "window.swatchLibrary", json!({ "library": b.id }));
let mut items = vec![Item::Cmd("Default Swatches", "swatch.resetDefaults", Value::Null), Item::Sep];
items.extend(SWATCH_LIBRARIES.iter().map(|b| Item::Cmd(b.name, "window.swatchLibrary", json!({ "library": b.id }))));
items.extend(SWATCH_LIBRARIES.iter().map(open));
items.extend([
Item::Sub("Gradients", GRADIENT_LIBRARIES.iter().map(open).collect()),
Item::Sep,
Item::Sub("User Defined", SLOTS.iter().map(|id| Item::Cmd("User Library", id, Value::Null)).collect()),
Item::Sep,
@ -1154,10 +1167,15 @@ mod tests {
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.
// A gradient opens Swatch Options with its name (and the gradient shown): OK renames it.
double_click_tile(&mut app, &ctx, "Sunset");
assert!(app.ui.dialog.is_none());
assert_eq!(app.ui.open_panel.as_deref(), Some("gradient"));
let d = app.ui.dialog.as_mut().expect("Swatch Options for a gradient");
assert!(d.kind == KIND && d.fields.contains_key("__gradient") && !d.fields.contains_key("color"));
d.fields.insert("name".into(), json!("Evening"));
dialog_frame(&mut app, &ctx);
crate::dialogs::confirm(&mut app).unwrap();
assert!(matches!(app.session.doc().unwrap().doc.swatch("Evening").map(|w| &w.paint), Some(Paint::Gradient(_))));
// Patterns open pattern editing, None nothing.
assert!(app.run("ui.swatchOptions", json!({"name": "[None]"})).is_err());
assert!(app.run("ui.swatchOptions", json!({})).is_err());
}
@ -1372,6 +1390,36 @@ mod tests {
assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none());
}
#[test]
fn alt_dropping_a_gradient_on_a_gradient_swatch_replaces_it() {
let mut app = app();
let ctx = context();
frame(&mut app, &ctx, vec![], 0.0, show);
let grad = |c: &str| {
let stops = vec![
vectorcraft_color::GradientStop { offset: 0.0, color: Color::from_hex(c).unwrap(), opacity: 1.0, midpoint: 0.5 },
vectorcraft_color::GradientStop { offset: 1.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
];
Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient { kind: GradientKind::Radial, stops })))
};
let drop_on = |app: &mut VectorcraftApp, name: &str, paint: Paint, m: Modifiers, time: f64| {
let at = tile_center(&ctx, name);
egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(paint));
let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m };
frame_with(app, &ctx, vec![Event::PointerMoved(at), release], m, time, show);
};
let count = app.session.doc().unwrap().doc.swatches_iter().count();
drop_on(&mut app, "Ocean", grad("#ff0000"), Modifiers::ALT, 1.0);
let d = &app.session.doc().unwrap().doc;
let Some(Paint::Gradient(g)) = d.swatch("Ocean").map(|w| &w.paint) else { panic!("Ocean is a gradient") };
assert_eq!((g.gradient.kind, d.swatches_iter().count()), (GradientKind::Radial, count), "replaced, not added");
// Without Alt, or on a swatch of another kind, it becomes a new swatch.
frame(&mut app, &ctx, vec![], 2.0, show);
drop_on(&mut app, "Red", grad("#00ff00"), Modifiers::ALT, 3.0);
assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 1);
assert!(app.session.doc().unwrap().doc.swatch("Red").is_some_and(|w| w.paint.color().is_some()));
}
#[test]
fn menu_targets_follow_the_panel_selection() {
let app = app();

View File

@ -24,8 +24,9 @@
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.
step and `ui.dialog.cancel` rolls the preview back. For a gradient swatch the dialog has `name` only (it shows the
gradient) and OK renames it; pattern swatches open pattern editing instead. Dropping a gradient (or colour) on a
swatch of its kind in the Swatches panel with Alt held replaces it (`swatch.edit {name, paint}`).
Confirmations: deleting swatches from the Swatches panel opens a `confirm` dialog (fields `message`, `detail`);
`ui.dialog.confirm` runs the command it asks about (here `swatch.delete`) and `ui.dialog.cancel` drops it.