From 3a79e6d9229cf193c5c47022d63769f33caeb990 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:46:45 +0700 Subject: [PATCH] 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 --- crates/color/src/libraries.rs | 113 +++++++++++++++++-- crates/engine/src/cmd/swatch.rs | 25 +++- crates/engine/src/cmd/swatchlib.rs | 35 +++--- crates/engine/src/tests_swatchlib.rs | 25 ++++ crates/ui-egui/src/dialogs/swatch_options.rs | 32 ++++-- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/library_panel.rs | 19 +++- crates/ui-egui/src/panels/swatches.rs | 72 ++++++++++-- docs/control-protocol.md | 5 +- 9 files changed, 275 insertions(+), 53 deletions(-) diff --git a/crates/color/src/libraries.rs b/crates/color/src/libraries.rs index e48ff41e..df745b1d 100644 --- a/crates/color/src/libraries.rs +++ b/crates/color/src/libraries.rs @@ -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 { + SWATCH_LIBRARIES.iter().chain(GRADIENT_LIBRARIES) +} + +/// Built-in library `id` (swatch or gradient library), computed once. pub fn builtin_library(id: &str) -> Option> { static LIBS: OnceLock>> = 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, Vec) { /// 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 { + points.iter().map(|&(l, c, h)| oklch(l, c, h)).collect() +} + +/// Full-hue sweeps and hue-neighbour blends. +fn spectrum_gradients() -> (Vec, Vec) { + let wheel: Vec = (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, Vec) { + 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, Vec) { + 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, Vec) { + 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, Vec) { + 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> { - 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 { diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 1e6aeffb..d1a556a6 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "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 { 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 { 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))) })?; diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index b3b44cec..3edfd240 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -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 { "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 { + 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 { - 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)> { - 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)> { diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests_swatchlib.rs index d1bbfd11..ae6e59bc 100644 --- a/crates/engine/src/tests_swatchlib.rs +++ b/crates/engine/src/tests_swatchlib.rs @@ -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())); +} diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index 1c48608e..dcd9f93e 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -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 { 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 { 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 { } } -/// `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::(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); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a8e6a6dc..4fd22900 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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", diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index c22845b4..f6db6510 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -415,8 +415,9 @@ mod tests { assert!(ctx.read_response(tile_id::("#FF0033")).is_some()); assert!(ctx.read_response(tile_id::("#00FF00")).is_none()); let first = SWATCH_IDS[0]; - let last = SWATCH_IDS[SWATCH_IDS.len() - 1]; - assert_eq!(neighbour::(&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::(&app, first, -1), Some(last), "wraps around"); assert_eq!(neighbour::(&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 = vectorcraft_engine::cmd::swatchlib::libraries(&app.session).into_iter().map(|l| l.id).collect(); - assert_eq!(ids, listed.iter().map(|i| json!(i)).collect::>()); - 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::>(); + assert_eq!(ids, listed("builtIn")); + assert_eq!(ids, SWATCH_IDS.map(|i| json!(i))); + let gradients: Vec = 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"); } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 2bc86618..12f6fb42 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -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, target: Option, 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 }, + /// `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, 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, after: bool) -> Option { + fn of(d: &PanelDrag, target: Option, after: bool, replace: bool) -> Option { 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::().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::().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 { 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 { "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::(&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(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aa93fc56..e7aff157 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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.