From e7f63281884bda48b6d1cec975c6132486fd4a2a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:19:18 +0700 Subject: [PATCH 1/3] M3.56: swatch library panel with code-generated swatch libraries Window > Swatch Libraries and the Swatches panel's library button open a read-only, floating library panel (thumbnail and list views, find field, libraries menu, previous/next arrows). Clicking a swatch adds it to the document and applies it as one undo step; Shift/Cmd-clicks select swatches and groups for Add to Swatches. Default Swatches restores missing defaults. Libraries are computed in code (OKLCH, rounded to 8-bit sRGB): Web Safe 216, Grays and Neutrals, Earth Tones, Skin Tone Ramps, Pastels, Brights, Metallic Gradients, Perceptual Scales, Harmony Sets. Commands: swatch.library.list, swatch.library.get, swatch.library.add {library, names?, apply?, focus?}, swatch.resetDefaults {replace?}; UI command window.swatchLibrary {library}. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/lib.rs | 2 + crates/color/src/libraries.rs | 433 ++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/swatch.rs | 6 +- crates/engine/src/cmd/swatchlib.rs | 233 +++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests_swatchlib.rs | 131 ++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 9 +- crates/ui-egui/src/panels/library_panel.rs | 449 +++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/swatches.rs | 268 ++++++++---- crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/tests_labels.rs | 2 +- docs/control-protocol.md | 6 + docs/mcp.md | 15 + 16 files changed, 1483 insertions(+), 81 deletions(-) create mode 100644 crates/color/src/libraries.rs create mode 100644 crates/engine/src/cmd/swatchlib.rs create mode 100644 crates/engine/src/tests_swatchlib.rs create mode 100644 crates/ui-egui/src/panels/library_panel.rs diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index a3dc3692..26f3fd34 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -8,10 +8,12 @@ pub mod blend; pub mod cms; pub mod gradient; pub mod harmony; +pub mod libraries; pub mod swatch; pub use blend::BlendMode; pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; +pub use libraries::SwatchLibrary; pub use swatch::{Swatch, SwatchGroup, default_swatches}; use serde::{Deserialize, Serialize}; diff --git a/crates/color/src/libraries.rs b/crates/color/src/libraries.rs new file mode 100644 index 00000000..e48ff41e --- /dev/null +++ b/crates/color/src/libraries.rs @@ -0,0 +1,433 @@ +//! Swatch libraries: read-only sets of swatches the library panel opens and adds from. Every +//! built-in library is computed here (names and palettes are our own; nothing is copied from any +//! other application or colour system), so the colours are exact and the same on every run. +//! +//! Colours are designed in OKLCH (perceptual lightness, chroma and hue), mapped into sRGB by +//! lowering chroma until they fit, and rounded to 8-bit RGB. + +use std::sync::{Arc, OnceLock}; + +use serde::{Deserialize, Serialize}; + +use crate::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch, SwatchGroup}; + +/// A library: its name, its ungrouped swatches and its colour groups. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SwatchLibrary { + pub name: String, + #[serde(default)] + pub swatches: Vec, + #[serde(default)] + pub groups: Vec, +} + +impl SwatchLibrary { + /// Every swatch: the ungrouped ones first, then each group's in order. + pub fn iter(&self) -> impl Iterator { + self.swatches.iter().chain(self.groups.iter().flat_map(|g| g.swatches.iter())) + } + /// The number of swatches. + pub fn len(&self) -> usize { + self.swatches.len() + self.groups.iter().map(|g| g.swatches.len()).sum::() + } + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + pub fn swatch(&self, name: &str) -> Option<&Swatch> { + self.iter().find(|s| s.name == name) + } + pub fn group(&self, name: &str) -> Option<&SwatchGroup> { + self.groups.iter().find(|g| g.name == name) + } +} + +/// A built-in library: a stable id, its menu name and the function that computes it. +pub struct BuiltinLibrary { + pub id: &'static str, + pub name: &'static str, + make: fn() -> (Vec, Vec), +} + +macro_rules! lib { + ($id:literal, $name:literal, $make:expr) => { + BuiltinLibrary { id: $id, name: $name, make: $make } + }; +} + +/// The built-in swatch libraries, in menu order. +pub const SWATCH_LIBRARIES: &[BuiltinLibrary] = &[ + lib!("web-safe-216", "Web Safe 216", web_safe), + lib!("grays-neutrals", "Grays and Neutrals", grays_neutrals), + lib!("earth-tones", "Earth Tones", earth_tones), + lib!("skin-tone-ramps", "Skin Tone Ramps", skin_tones), + lib!("pastels", "Pastels", pastels), + lib!("brights", "Brights", brights), + lib!("metallic-gradients", "Metallic Gradients", metallic), + lib!("perceptual-scales", "Perceptual Scales", perceptual), + lib!("harmony-sets", "Harmony Sets", harmony_sets), +]; + +/// Built-in library `id`, computed once. +pub fn builtin_library(id: &str) -> Option> { + static LIBS: OnceLock>> = OnceLock::new(); + let libs = LIBS.get_or_init(|| { + SWATCH_LIBRARIES + .iter() + .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()) +} + +// ---------- colour construction ---------- + +/// sRGB (0..=1, gamma encoded) of OKLab `(l, a, b)`, unclamped. +fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { + let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b; + let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b; + let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b; + let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_); + let lin = [ + 4.076_741_662_1 * l3 - 3.307_711_591_3 * m3 + 0.230_969_929_2 * s3, + -1.268_438_004_6 * l3 + 2.609_757_401_1 * m3 - 0.341_319_396_5 * s3, + -0.004_196_086_3 * l3 - 0.703_418_614_7 * m3 + 1.707_614_701_0 * s3, + ]; + lin.map(|v| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }) +} + +/// OKLab `[l, a, b]` of a colour's display RGB. +pub fn to_oklab(c: &Color) -> [f64; 3] { + let [r, g, b] = c.to_rgb_uncalibrated().map(|v| { + let v = f64::from(v); + if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }); + let l = (0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b).cbrt(); + let m = (0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b).cbrt(); + let s = (0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b).cbrt(); + [ + 0.210_454_255_3 * l + 0.793_617_785_0 * m - 0.004_072_046_8 * s, + 1.977_998_495_1 * l - 2.428_592_205_0 * m + 0.450_593_709_9 * s, + 0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766_0 * s, + ] +} + +/// The 8-bit sRGB colour of OKLCH `(l, c, h°)`, with the chroma lowered until it fits in sRGB. +fn oklch(l: f64, c: f64, h: f64) -> Color { + let (sin, cos) = h.to_radians().sin_cos(); + let at = |c: f64| oklab_to_srgb(l, c * cos, c * sin); + let fits = |rgb: [f64; 3]| rgb.iter().all(|v| (-1e-4..=1.0001).contains(v)); + let rgb = if fits(at(c)) { + at(c) + } else { + // Bisect the largest chroma in gamut. + let (mut lo, mut hi) = (0.0, c); + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + if fits(at(mid)) { lo = mid } else { hi = mid } + } + at(lo) + }; + let q = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color::rgb8(q(rgb[0]), q(rgb[1]), q(rgb[2])) +} + +/// OKLab interpolation of `a` → `b` at `t`, as an 8-bit sRGB colour. +fn mix(a: [f64; 3], b: [f64; 3], t: f64) -> Color { + let p = [0, 1, 2].map(|i| a[i] + (b[i] - a[i]) * t); + let (c, h) = (p[1].hypot(p[2]), p[2].atan2(p[1]).to_degrees()); + oklch(p[0], c, h) +} + +/// OKLab `[l, a, b]` of OKLCH `(l, c, h°)`. +fn lab(l: f64, c: f64, h: f64) -> [f64; 3] { + let (sin, cos) = h.to_radians().sin_cos(); + [l, c * cos, c * sin] +} + +fn solid(name: impl Into, color: Color) -> Swatch { + Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false } +} + +fn group(name: &str, swatches: Vec) -> SwatchGroup { + SwatchGroup { name: name.into(), swatches } +} + +/// A gradient swatch through `stops` of (offset, colour, opacity). +pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch { + let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { offset, color, opacity, midpoint: 0.5 }).collect(); + Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))), global: false, spot: false } +} + +/// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1), +/// named "`name` 1"… +fn ramp(name: &str, n: usize, (l0, l1): (f64, f64), c: impl Fn(f64) -> f64, h: impl Fn(f64) -> f64) -> SwatchGroup { + let swatches = (0..n) + .map(|i| { + let t = i as f64 / (n - 1).max(1) as f64; + solid(format!("{name} {}", i + 1), oklch(l0 + (l1 - l0) * t, c(t), h(t))) + }) + .collect(); + group(name, swatches) +} + +/// Twelve named OKLCH hues around the wheel. +const HUES: [(&str, f64); 12] = [ + ("Red", 25.0), + ("Orange", 55.0), + ("Amber", 75.0), + ("Yellow", 100.0), + ("Lime", 125.0), + ("Green", 145.0), + ("Teal", 180.0), + ("Cyan", 205.0), + ("Azure", 235.0), + ("Blue", 265.0), + ("Violet", 295.0), + ("Magenta", 330.0), +]; + +/// One group of the twelve hues at OKLCH lightness `l` and chroma `c`, named "`prefix` Red"… +fn hue_row(prefix: &str, l: f64, c: f64) -> SwatchGroup { + group(prefix, HUES.iter().map(|(n, h)| solid(format!("{prefix} {n}"), oklch(l, c, *h))).collect()) +} + +// ---------- the libraries ---------- + +/// The 216 colours whose channels are multiples of 0x33, named by their hex value. +fn web_safe() -> (Vec, Vec) { + let mut s = Vec::with_capacity(216); + for r in 0..6u8 { + for g in 0..6u8 { + for b in 0..6u8 { + let c = Color::rgb8(r * 0x33, g * 0x33, b * 0x33); + s.push(solid(c.to_hex().to_uppercase(), c)); + } + } + } + (s, vec![]) +} + +fn grays_neutrals() -> (Vec, Vec) { + let grays = (0..=20).map(|i| solid(format!("K={}", i * 5), Color::gray(i as f32 / 20.0))).collect(); + let tinted = |name: &str, c: f64, h: f64| ramp(name, 9, (0.95, 0.25), |_| c, |_| h); + let neutrals = [ + ("Ivory", 0.96, 0.02, 95.0), + ("Linen", 0.92, 0.02, 70.0), + ("Oat", 0.86, 0.035, 80.0), + ("Greige", 0.72, 0.02, 75.0), + ("Stone", 0.66, 0.015, 95.0), + ("Taupe", 0.56, 0.03, 50.0), + ("Slate", 0.48, 0.025, 250.0), + ("Charcoal", 0.30, 0.01, 260.0), + ]; + let neutrals = neutrals.iter().map(|&(n, l, c, h)| solid(n, oklch(l, c, h))).collect(); + let groups = vec![group("Grays", grays), tinted("Warm Gray", 0.012, 70.0), tinted("Cool Gray", 0.015, 250.0), group("Neutrals", neutrals)]; + (vec![], groups) +} + +fn earth_tones() -> (Vec, Vec) { + let families = [ + ("Clay", 0.10, 40.0), + ("Terracotta", 0.13, 35.0), + ("Ochre", 0.11, 80.0), + ("Umber", 0.06, 60.0), + ("Sand", 0.05, 85.0), + ("Olive", 0.08, 115.0), + ("Moss", 0.07, 130.0), + ]; + // Chroma peaks mid-ramp, as earth pigments do. + let groups = families.iter().map(|&(n, c, h)| ramp(n, 6, (0.86, 0.34), |t| c * (0.6 + 0.4 * (1.0 - (2.0 * t - 1.0).powi(2))), |_| h)).collect(); + (vec![], groups) +} + +fn skin_tones() -> (Vec, Vec) { + let ramps = [("Rosy", 0.075, 35.0), ("Neutral", 0.07, 55.0), ("Golden", 0.08, 70.0), ("Olive", 0.06, 85.0)]; + // Light tones are paler; deep tones keep warmth but lose a little chroma. + let groups = ramps.iter().map(|&(n, c, h)| ramp(n, 8, (0.93, 0.32), |t| c * (0.45 + 0.75 * t - 0.35 * t * t), |t| h - 8.0 * t)).collect(); + (vec![], groups) +} + +fn pastels() -> (Vec, Vec) { + (vec![], vec![hue_row("Pastel", 0.91, 0.055), hue_row("Soft", 0.84, 0.085)]) +} + +fn brights() -> (Vec, Vec) { + // Chroma beyond sRGB: each hue lands on the gamut edge at that lightness. + (vec![], vec![hue_row("Vivid", 0.72, 0.4), hue_row("Deep", 0.52, 0.4)]) +} + +fn metallic() -> (Vec, Vec) { + let metals: [(&str, f64, f64, f64); 10] = [ + ("Gold", 0.78, 0.12, 85.0), + ("Silver", 0.80, 0.005, 250.0), + ("Bronze", 0.62, 0.09, 65.0), + ("Copper", 0.64, 0.12, 45.0), + ("Brass", 0.76, 0.10, 95.0), + ("Chrome", 0.72, 0.012, 230.0), + ("Rose Gold", 0.76, 0.07, 30.0), + ("Gunmetal", 0.45, 0.015, 250.0), + ("Platinum", 0.86, 0.008, 90.0), + ("Pewter", 0.60, 0.01, 200.0), + ]; + // Light, base, shadow, highlight, base: the banding of polished metal. + let bands: [(f32, f64); 5] = [(0.0, 0.16), (0.3, 0.0), (0.5, -0.2), (0.72, 0.12), (1.0, -0.06)]; + let s = metals + .iter() + .map(|&(n, l, c, h)| { + let stops: Vec<(f32, Color, f32)> = bands.iter().map(|&(at, dl)| (at, oklch((l + dl).clamp(0.05, 0.98), c, h), 1.0)).collect(); + gradient(n, GradientKind::Linear, &stops) + }) + .collect(); + (s, vec![]) +} + +/// OKLCH `(l, c, h°)` anchors a scale passes through. +type Anchors = &'static [(f64, f64, f64)]; + +/// 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)]), + ("Lagoon", &[(0.25, 0.07, 265.0), (0.60, 0.12, 200.0), (0.93, 0.12, 130.0)]), + ("Dusk", &[(0.20, 0.08, 280.0), (0.60, 0.15, 330.0), (0.92, 0.07, 60.0)]), + ("Moss", &[(0.25, 0.05, 150.0), (0.92, 0.07, 110.0)]), + ("Ink", &[(0.20, 0.02, 260.0), (0.96, 0.01, 260.0)]), +]; + +/// `n` colours along OKLab segments between `anchors`. +fn scale(name: &str, n: usize, anchors: &[(f64, f64, f64)]) -> SwatchGroup { + let pts: Vec<[f64; 3]> = anchors.iter().map(|&(l, c, h)| lab(l, c, h)).collect(); + let segs = (pts.len() - 1) as f64; + let swatches = (0..n) + .map(|i| { + let t = i as f64 / (n - 1) as f64 * segs; + let k = (t.floor() as usize).min(pts.len() - 2); + solid(format!("{name} {}", i + 1), mix(pts[k], pts[k + 1], t - k as f64)) + }) + .collect(); + group(name, swatches) +} + +/// Scales whose lightness rises evenly (sequential) plus two diverging ones. +fn perceptual() -> (Vec, Vec) { + let mut groups: Vec = SEQUENTIAL.iter().map(|(n, a)| scale(n, 9, a)).collect(); + groups.push(scale("Clay to Teal", 9, &[(0.45, 0.12, 40.0), (0.96, 0.01, 80.0), (0.45, 0.09, 200.0)])); + groups.push(scale("Plum to Olive", 9, &[(0.40, 0.11, 330.0), (0.96, 0.01, 90.0), (0.45, 0.09, 120.0)])); + (vec![], groups) +} + +/// Colour-wheel harmonies of a few base colours (the Color Guide's rules). +fn harmony_sets() -> (Vec, Vec) { + use crate::harmony::Harmony; + let bases = [("Coral", Color::from_hsb(8.0, 0.68, 0.94)), ("Teal", Color::from_hsb(176.0, 0.72, 0.62))]; + let rules = [Harmony::Complementary, Harmony::SplitComplementary, Harmony::Analogous, Harmony::Triad, Harmony::Tetrad, Harmony::Pentagram]; + let mut groups = vec![]; + for (bn, base) in bases { + for rule in rules { + let name = format!("{} {bn}", rule.label()); + // Rounded to 8 bits like the other libraries. + let swatches = rule + .apply(base) + .into_iter() + .enumerate() + .map(|(i, c)| { + let [r, g, b, _] = c.to_rgba8(1.0); + solid(format!("{name} {}", i + 1), Color::rgb8(r, g, b)) + }) + .collect(); + groups.push(SwatchGroup { name, swatches }); + } + } + (vec![], groups) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn all() -> Vec> { + SWATCH_LIBRARIES.iter().map(|b| builtin_library(b.id).unwrap()).collect() + } + + /// Every component of every colour (solid or gradient stop). + fn components(lib: &SwatchLibrary) -> Vec { + let comps = |c: &Color| match *c { + Color::Rgb { r, g, b } => vec![r, g, b], + Color::Cmyk { c, m, y, k } => vec![c, m, y, k], + Color::Gray { k } => vec![k], + }; + lib.iter() + .flat_map(|s| match &s.paint { + Paint::Solid { color, .. } => comps(color), + Paint::Gradient(g) => g.gradient.stops.iter().flat_map(|st| comps(&st.color)).collect(), + _ => vec![], + }) + .collect() + } + + #[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()) { + assert!(ids.insert(b.id), "duplicate id {}", b.id); + assert_eq!(lib.name, b.name); + assert!(lib.len() >= 8, "{} 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); + // Swatch and group names share one namespace, as in a document. + let mut names = std::collections::HashSet::new(); + for n in lib.iter().map(|s| &s.name).chain(lib.groups.iter().map(|g| &g.name)) { + assert!(names.insert(n.clone()), "{}: duplicate name {n}", b.id); + } + assert!(components(&lib).iter().all(|v| (0.0..=1.0).contains(v)), "{} out of range", b.id); + assert!(lib.iter().all(|s| !s.paint.is_none() && !s.global && !s.spot)); + } + } + + #[test] + fn web_safe_has_the_216_web_colours() { + let lib = builtin_library("web-safe-216").unwrap(); + assert_eq!(lib.len(), 216); + let mut hex: Vec = lib.iter().map(|s| s.paint.color().unwrap().to_hex()).collect(); + hex.dedup(); + assert_eq!(hex.len(), 216); + assert!(lib.iter().all(|s| s.paint.color().unwrap().to_rgba8(1.0)[..3].iter().all(|v| v % 0x33 == 0))); + assert_eq!(lib.swatches[0].name, "#000000"); + assert_eq!(lib.swatches[215].name, "#FFFFFF"); + } + + #[test] + fn sequential_scales_rise_in_lightness() { + let lib = builtin_library("perceptual-scales").unwrap(); + for (name, _) in SEQUENTIAL { + let l: Vec = lib.group(name).unwrap().swatches.iter().map(|s| to_oklab(&s.paint.color().unwrap())[0]).collect(); + assert!(l.windows(2).all(|w| w[1] > w[0] + 0.03), "{name}: {l:?}"); + } + } + + #[test] + fn oklch_round_trips_in_gamut_colours() { + let c = oklch(0.7, 0.1, 150.0); + let [l, a, b] = to_oklab(&c); + assert!((l - 0.7).abs() < 0.01 && (a.hypot(b) - 0.1).abs() < 0.01, "{l} {a} {b}"); + // Out-of-gamut chroma is clipped to the gamut edge, keeping lightness. + let v = oklch(0.72, 0.4, 25.0); + assert!((to_oklab(&v)[0] - 0.72).abs() < 0.02); + } + + #[test] + fn gradient_libraries_have_sorted_stops() { + for lib in all() { + for s in lib.iter() { + if let Paint::Gradient(g) = &s.paint { + let st = &g.gradient.stops; + assert!(st.len() >= 2 && st.windows(2).all(|w| w[0].offset <= w[1].offset), "{}", s.name); + assert!(st.iter().all(|s| (0.0..=1.0).contains(&s.offset) && (0.0..=1.0).contains(&s.opacity))); + } + } + } + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 59c83b10..0b2c998b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -37,6 +37,7 @@ mod select; mod stroke; mod style; mod swatch; +pub mod swatchlib; pub(crate) mod tabs; mod textedit; pub mod textstyles; @@ -194,6 +195,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(distortcmds::specs()); v.extend(paint::proxy_specs()); v.extend(overprint::specs()); + v.extend(swatchlib::specs()); v }) } diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 3be1648b..1e6aeffb 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -383,7 +383,7 @@ fn swatch_duplicate(s: &mut Session, p: &Value) -> Result { } /// A list-of-strings parameter (missing or non-string entries are skipped). -fn str_list(p: &Value, key: &str) -> Vec { +pub(super) fn str_list(p: &Value, key: &str) -> Vec { p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() } @@ -450,7 +450,7 @@ impl Relink { /// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live /// preview (Cancel rolls the document back, not the session). -fn map_default_paints(s: &mut Session, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) { +pub(super) fn map_default_paints(s: &mut Session, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) { if s.in_interaction() { return; } @@ -499,7 +499,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": to, "relinked": relinked})) } -fn swatch_json(sw: &Swatch, group: Option<&str>) -> Value { +pub(super) 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"), diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs new file mode 100644 index 00000000..11d629b6 --- /dev/null +++ b/crates/engine/src/cmd/swatchlib.rs @@ -0,0 +1,233 @@ +//! Swatch libraries (Window → Swatch Libraries, the Swatches panel's library button): read-only +//! sets of swatches the library panel shows. The built-in libraries are computed in +//! [`vectorcraft_color::libraries`]; `swatch.library.add` copies swatches into the document. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::libraries::{SWATCH_LIBRARIES, builtin_library}; +use vectorcraft_color::{Swatch, SwatchGroup, SwatchLibrary, default_swatches}; + +use super::menucmds::squash; +use super::swatch::{map_default_paints, str_list, swatch_json}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "swatch.library.list", + "Swatch Libraries", + [], + None, + "{} → {libraries: [{id, name, category: \"builtIn\", count}]} every library the library panel opens, in menu order", + always, + list + ), + cmd!( + query "swatch.library.get", + "Swatch Library", + [], + None, + "{library: id or name} → {id, name, category, swatches: [{name, kind, group, global, spot, color?, hex?, gradient?}] (as swatch.list), groups: [{name, swatches: [names]}]}", + always, + get + ), + cmd!( + "swatch.library.add", + "Add to Swatches", + ["Window", "Swatch Libraries"], + None, + "{library: id or name, names?: [swatch or colour group names in the library] (default: all of it; a group comes as a colour group, a single swatch ungrouped), apply?: \"fill\"|\"stroke\" (also apply the first one to the selection and the defaults, as paint.setFill / paint.setStroke), focus?: true (with apply: false keeps the active proxy)} copy library swatches into the document as one undo step. A swatch the document already has (same name and paint) isn't added again; a name taken by another swatch gets a number (\"Clay 1 2\") → {library, added: [names in the document], existing: [names already there], applied?: name}", + has_doc, + add + ), + cmd!( + "swatch.resetDefaults", + "Default Swatches", + ["Window", "Swatch Libraries"], + None, + "{replace?: false} bring back the default swatches and colour groups of the document's colour mode that are missing (by name), as one undo step; replace: true makes the swatches exactly the defaults (art linked to a removed global swatch keeps its colour, unlinked) → {added: [names]}", + has_doc, + reset_defaults + ), + ] +} + +/// A library as the menus and the library panel list it. +#[derive(Clone, Debug, PartialEq)] +pub struct LibraryInfo { + pub id: String, + pub name: String, + /// "builtIn". + pub category: &'static str, +} + +/// Every library, in menu order. +pub fn libraries(_s: &Session) -> Vec { + SWATCH_LIBRARIES.iter().map(|b| LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" }).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 info = libraries(s).into_iter().find(|l| l.id == key).or_else(|| libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key)))?; + let lib = builtin_library(&info.id)?; + Some((info, lib)) +} + +fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc)> { + let key = str_param(p, "library").ok_or_else(|| bad(cmd, "missing `library` (see swatch.library.list)"))?; + library(s, key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)"))) +} + +fn list(s: &mut Session, _: &Value) -> Result { + let libs: Vec = libraries(s) + .into_iter() + .map(|l| { + let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); + json!({"id": l.id, "name": l.name, "category": l.category, "count": count}) + }) + .collect(); + Ok(json!({ "libraries": libs })) +} + +fn get(s: &mut Session, p: &Value) -> Result { + let (info, lib) = library_param(s, p, "swatch.library.get")?; + let ungrouped = lib.swatches.iter().map(|w| swatch_json(w, None)); + let grouped = lib.groups.iter().flat_map(|g| g.swatches.iter().map(|w| swatch_json(w, Some(&g.name)))); + let groups: Vec = + lib.groups.iter().map(|g| json!({"name": g.name, "swatches": g.swatches.iter().map(|w| &w.name).collect::>()})).collect(); + Ok( + json!({"id": info.id, "name": info.name, "category": info.category, "swatches": ungrouped.chain(grouped).collect::>(), "groups": groups}), + ) +} + +/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`: +/// ungrouped): a group's name brings the group, a swatch's name the swatch alone; no names, all. +fn picked<'a>(lib: &'a SwatchLibrary, names: &[String], cmd: &str) -> Result, &'a Swatch)>> { + let mut out: Vec<(Option<&str>, &Swatch)> = vec![]; + let mut push = |g: Option<&'a str>, w: &'a Swatch| { + if !out.iter().any(|(_, x)| x.name == w.name) { + out.push((g, w)); + } + }; + if names.is_empty() { + lib.swatches.iter().for_each(|w| push(None, w)); + lib.groups.iter().for_each(|g| g.swatches.iter().for_each(|w| push(Some(&g.name), w))); + } + for n in names { + match (lib.group(n), lib.swatch(n)) { + (Some(g), _) => g.swatches.iter().for_each(|w| push(Some(&g.name), w)), + (None, Some(w)) => push(None, w), + (None, None) => return Err(bad(cmd, format!("no swatch or colour group `{n}` in `{}`", lib.name))), + } + } + Ok(out) +} + +fn add(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.library.add"; + let (info, lib) = library_param(s, p, C)?; + let apply = match str_param(p, "apply") { + None => None, + Some("fill") => Some("paint.setFill"), + Some("stroke") => Some("paint.setStroke"), + Some(other) => return Err(bad(C, format!("apply is \"fill\" or \"stroke\", not `{other}`"))), + }; + let picked = picked(&lib, &str_list(p, "names"), C)?; + // Swatches the document already has (same name and paint) are reused, not added again. + let d = &s.doc()?.doc; + let (existing, fresh): (Vec<_>, Vec<_>) = picked.iter().partition(|(_, w)| d.swatch(&w.name).is_some_and(|x| x.paint == w.paint)); + let existing: Vec = existing.iter().map(|(_, w)| w.name.clone()).collect(); + let undo_before = s.doc()?.history.undo.len(); + // Library name → the name it got in the document. + let added: Vec<(String, String)> = if fresh.is_empty() { + vec![] + } else { + s.edit("Add to Swatches", |d, _| { + let mut out = vec![]; + for (group, w) in fresh { + let name = d.free_swatch_name(&w.name); + let sw = Swatch { name: name.clone(), ..(*w).clone() }; + match group { + Some(g) => { + let i = match d.swatch_groups.iter().position(|x| x.name == *g) { + Some(i) => i, + None => { + let name = d.free_swatch_name(g); + d.swatch_groups.push(SwatchGroup { name, swatches: vec![] }); + d.swatch_groups.len() - 1 + } + }; + d.swatch_groups[i].swatches.push(sw); + } + None => d.swatches.push(sw), + } + out.push((w.name.clone(), name)); + } + Ok(out) + })? + }; + let mut out = json!({"library": info.id, "added": added.iter().map(|(_, n)| n).collect::>(), "existing": existing}); + if let (Some(cmd), Some((_, first))) = (apply, picked.first()) { + let name = added.iter().find(|(l, _)| *l == first.name).map_or(first.name.clone(), |(_, n)| n.clone()); + s.execute(cmd, &json!({"swatch": name, "focus": bool_or(p, "focus", true)}))?; + // Adding and applying undo together. + squash(s, undo_before, "Add to Swatches"); + out["applied"] = json!(name); + } + Ok(out) +} + +fn reset_defaults(s: &mut Session, p: &Value) -> Result { + let replace = bool_or(p, "replace", false); + let model = s.doc()?.doc.color_mode.model(); + let (defaults, groups) = default_swatches(model); + // With replace, links to swatches that are no longer global ones are dropped (the art keeps + // its colour); filled in by the edit. + let mut live: Vec = vec![]; + let added = s.edit("Default Swatches", |d, _| { + let mut added = vec![]; + if replace { + let before: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); + (d.swatches, d.swatch_groups) = (defaults, groups); + added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone())); + live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone())); + d.map_solid_paints(&mut |_, l| unlink_dead(&live, l)); + return Ok(added); + } + for (i, w) in defaults.into_iter().enumerate() { + if !d.swatch_name_taken(&w.name) { + added.push(w.name.clone()); + // None leads, as in a new document; the others go after the existing swatches. + let at = if i == 0 && w.paint.is_none() { 0 } else { d.swatches.len() }; + d.swatches.insert(at, w); + } + } + for g in groups { + let i = match d.swatch_groups.iter().position(|x| x.name == g.name) { + Some(i) => i, + None if d.swatch_name_taken(&g.name) => continue, + None => { + d.swatch_groups.push(SwatchGroup { name: g.name.clone(), swatches: vec![] }); + d.swatch_groups.len() - 1 + } + }; + for w in g.swatches { + if !d.swatch_name_taken(&w.name) { + added.push(w.name.clone()); + d.swatch_groups[i].swatches.push(w); + } + } + } + Ok(added) + })?; + if replace { + map_default_paints(s, &mut |_, l| unlink_dead(&live, l)); + } + Ok(json!({ "added": added })) +} + +/// Drop a link to a swatch not in `live`; true when it did. +fn unlink_dead(live: &[String], link: &mut Option) -> bool { + link.as_ref().is_some_and(|n| !live.contains(n)) && link.take().is_some() +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4c407247..55cd5d75 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -802,6 +802,8 @@ mod tests_swatchcmds; #[cfg(test)] mod tests_swatches; #[cfg(test)] +mod tests_swatchlib; +#[cfg(test)] mod tests_textedit; #[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests_swatchlib.rs new file mode 100644 index 00000000..c30a79e7 --- /dev/null +++ b/crates/engine/src/tests_swatchlib.rs @@ -0,0 +1,131 @@ +//! Swatch libraries: listing, reading, adding to the document (deduped, one undo step, applied) +//! and Default Swatches. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn doc(s: &Session) -> &vectorcraft_doc::Document { + &s.doc().unwrap().doc +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn list_and_get_every_builtin_library() { + let mut s = Session::new(); + let libs = run(&mut s, "swatch.library.list", json!({})); + let libs = libs["libraries"].as_array().unwrap(); + let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); + for id in [ + "web-safe-216", + "grays-neutrals", + "earth-tones", + "skin-tone-ramps", + "pastels", + "brights", + "metallic-gradients", + "perceptual-scales", + "harmony-sets", + ] { + assert!(ids.contains(&id), "{id} missing from {ids:?}"); + } + let web = libs.iter().find(|l| l["id"] == "web-safe-216").unwrap(); + assert_eq!((web["name"].as_str(), web["count"].as_u64()), (Some("Web Safe 216"), Some(216))); + // By id or by name in any case; groups list their swatches. + let g = run(&mut s, "swatch.library.get", json!({"library": "earth tones"})); + assert_eq!(g["id"], "earth-tones"); + let clay = g["groups"].as_array().unwrap().iter().find(|x| x["name"] == "Clay").unwrap(); + assert_eq!(clay["swatches"].as_array().unwrap().len(), 6); + assert!(g["swatches"].as_array().unwrap().iter().all(|w| w["kind"] == "color" && w["group"].is_string())); + let m = run(&mut s, "swatch.library.get", json!({"library": "metallic-gradients"})); + assert!(m["swatches"].as_array().unwrap().iter().all(|w| w["kind"] == "gradient")); + assert!(s.execute("swatch.library.get", &json!({"library": "nope"})).is_err()); +} + +#[test] +fn add_dedupes_as_one_undo_step() { + let mut s = session(); + let before = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Clay", "Ochre 2", "Clay 3"]})); + // The Clay group comes whole (its third swatch once); Ochre 2 comes ungrouped. + let added: Vec<&str> = r["added"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); + assert_eq!(added.len(), 7, "{added:?}"); + assert_eq!(undo_len(&s), before + 1, "one undo step"); + let d = doc(&s); + assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Clay").map(|g| g.swatches.len()), Some(6)); + assert!(d.swatches.iter().any(|w| w.name == "Ochre 2")); + // Adding again finds them all and changes nothing (no undo step). + let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Clay", "Ochre 2"]})); + assert_eq!((r["added"].as_array().unwrap().len(), r["existing"].as_array().unwrap().len()), (0, 7)); + assert_eq!(undo_len(&s), before + 1); + // A name taken by a different colour gets a number. + run(&mut s, "swatch.new", json!({"name": "Ochre 3", "color": "#123456"})); + let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Ochre 3"]})); + assert_eq!(r["added"], json!(["Ochre 3 2"])); + // Undo removes the whole first add. + for _ in 0..3 { + run(&mut s, "edit.undo", json!({})); + } + assert!(doc(&s).swatch_groups.iter().all(|g| g.name != "Clay") && doc(&s).swatch("Ochre 2").is_none()); + assert!(s.execute("swatch.library.add", &json!({"library": "earth-tones", "names": ["Nope"]})).is_err()); +} + +#[test] +fn add_with_apply_paints_the_selection_in_the_same_undo_step() { + let mut s = session(); + let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let before = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "web-safe-216", "names": ["#FF6600"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["#FF6600"]), json!("#FF6600"))); + let fill = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + assert_eq!(fill.color().map(|c| c.to_hex()), Some("#ff6600".into())); + assert_eq!(undo_len(&s), before + 1, "added and applied as one step"); + run(&mut s, "edit.undo", json!({})); + assert!(doc(&s).swatch("#FF6600").is_none()); + // A gradient swatch applied to the stroke; an existing swatch is applied without an add. + run(&mut s, "swatch.library.add", json!({"library": "metallic-gradients", "names": ["Gold"], "apply": "stroke"})); + let n = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + assert!(matches!(n.appearance.stroke_paint(), Paint::Gradient(_))); + let r = run(&mut s, "swatch.library.add", json!({"library": "metallic-gradients", "names": ["Gold"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!([]), json!("Gold"))); +} + +#[test] +fn reset_defaults_restores_missing_swatches_or_replaces_them() { + let mut s = session(); + let defaults: Vec = doc(&s).swatches_iter().map(|w| w.name.clone()).collect(); + run(&mut s, "swatch.delete", json!({"names": ["Red", "Grays"]})); + run(&mut s, "swatch.new", json!({"name": "Mine", "color": "#abcdef"})); + let r = run(&mut s, "swatch.resetDefaults", json!({})); + assert!(r["added"].as_array().unwrap().iter().any(|n| n == "Red")); + let d = doc(&s); + assert!(d.swatch("Mine").is_some(), "the user's swatches stay"); + assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Grays").map(|g| g.swatches.len()), Some(9)); + assert!(defaults.iter().all(|n| d.swatch(n).is_some())); + // Replace: exactly the defaults; art linked to a removed global swatch keeps its colour. + run(&mut s, "swatch.edit", json!({"name": "Mine", "global": true})); + let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [id]})); + run(&mut s, "paint.setFill", json!({"swatch": "Mine"})); + assert_eq!(s.paint.fill, Paint::Solid { color: Color::from_hex("#abcdef").unwrap(), swatch: Some("Mine".into()) }); + run(&mut s, "swatch.resetDefaults", json!({"replace": true})); + let d = doc(&s); + assert_eq!(d.swatches_iter().map(|w| w.name.clone()).collect::>(), defaults); + let fill = d.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + assert_eq!(fill, Paint::solid(Color::from_hex("#abcdef").unwrap())); + assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#abcdef").unwrap()), "the default fill is unlinked too"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4fea0cde..673c65bd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -517,6 +517,7 @@ impl VectorcraftApp { canvas::show(self, ui); }); dock::floating_panel(self, &ctx); + panels::library_panel::show_window(self, &ctx); dialogs::show(self, &ctx); palette::show(self, &ctx); self.perf.frame_ms = now_ms() - t0; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 149c0d61..ade9d4e2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -219,6 +219,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", ), + ( + "window.swatchLibrary", + "Swatch Library", + "", + "{library: id or name (see swatch.library.list) | null (close)} open the read-only library panel on a swatch library (UI state `library_panel`); clicking a swatch there runs swatch.library.add with apply → {open, name, count}", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -552,6 +558,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::swatches::open_library(app, p), _ => return None, }; Some(r) @@ -1305,7 +1312,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, sub("Brush Libraries", library_placeholders()), sub("Graphic Style Libraries", library_placeholders()), - sub("Swatch Libraries", library_placeholders()), + sub("Swatch Libraries", crate::panels::swatches::window_menu()), sub("Symbol Libraries", library_placeholders()), ], ), diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs new file mode 100644 index 00000000..452d2a64 --- /dev/null +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -0,0 +1,449 @@ +//! The library panel: a floating, read-only view of one library (swatch libraries; any kind of +//! library implementing [`LibraryKind`]) with thumbnail and list views, a find field, the +//! libraries menu and previous / next arrows. A plain click on an item adds it to the document and +//! applies it; Shift and Cmd/Ctrl-clicks select items (and folders) for Add to …. +//! +//! Which library is open lives in `UiState::library_panel` (`window.swatchLibrary` opens one), so +//! agents can open and read it; the view, find text and selection are panel state. + +use egui::{CornerRadius, Rect, Sense, Ui, vec2}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use super::swatches::{View, click_selection, folder_tile, list_row, list_row_name, tile_grid}; +use super::{pstate, set_pstate}; +use crate::theme::{self, Tokens}; +use crate::widgets::{self, menu_item}; +use crate::{VectorcraftApp, icons}; + +/// The library open in the library panel. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OpenLibrary { + /// The kind of library ([`LibraryKind::KIND`], e.g. "swatches"). + pub kind: String, + /// The library's id. + pub id: String, +} + +/// A library as the libraries menu lists it. +pub(crate) struct LibraryRef { + pub id: String, + pub name: String, + /// The submenu it is listed under (`None`: the top level). + pub submenu: Option<&'static str>, +} + +/// A row of the panel: an item, or the folder of a group of items (`item` None). +pub(crate) struct Row<'a, I> { + pub name: &'a str, + pub item: Option<&'a I>, +} + +/// One kind of library the panel shows. +pub(crate) trait LibraryKind { + /// `OpenLibrary::kind`, and the prefix of the panel's state keys. + const KIND: &'static str; + /// The UI command that opens a library in the panel (`{library: id}`). + const OPEN: &'static str; + /// The libraries menu's tooltip ("Swatch Libraries Menu"). + const MENU: &'static str; + /// What adding the selection is called ("Add to Swatches"). + const ADD: &'static str; + /// A library's contents, cheap to clone. + type Lib; + type Item; + /// Every library, in menu order. + fn list(app: &VectorcraftApp) -> Vec; + /// Library `id`: its name and contents. + fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)>; + /// The rows matching the find field's `query` (lower case; empty: all). + fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec>; + /// An item's thumbnail in `r`. + fn draw(app: &VectorcraftApp, ui: &Ui, r: Rect, item: &Self::Item, selected: bool, hovered: bool); + /// The icons at the right of list row `r`. + fn row_icons(ui: &Ui, r: Rect, item: &Self::Item); + /// An item described in words, for list tooltips. + fn describe(item: &Self::Item) -> String; + /// A plain click on item `name` of library `id`: add it to the document and apply it. + fn click(app: &mut VectorcraftApp, ui: &Ui, id: &str, name: &str); + /// Add the items and folders `names` of library `id` to the document. + fn add(app: &mut VectorcraftApp, id: &str, names: Vec); + /// Items heading the libraries menu. + fn menu_head(_app: &mut VectorcraftApp, _ui: &mut Ui) {} + /// Items closing the libraries menu. + fn menu_tail(_app: &mut VectorcraftApp, _ui: &mut Ui) {} +} + +/// The state key `key` of `K`'s panel. +fn key(key: &str) -> String { + format!("{}-library-{key}", K::KIND) +} + +/// The interaction id of the tile or row of `name`. +pub(crate) fn tile_id(name: &str) -> egui::Id { + egui::Id::new(("library-tile", K::KIND, name)) +} + +/// Draw the open library panel, if any. +pub fn show_window(app: &mut VectorcraftApp, ctx: &egui::Context) { + let Some(open) = app.ui.library_panel.clone() else { return }; + match open.kind.as_str() { + super::swatches::SwatchLibraries::KIND => window::(app, ctx, &open.id), + _ => app.ui.library_panel = None, + } +} + +/// The panel's window: a tab strip with the library's name, the panel menu and a close button, then +/// the body. A library that no longer exists closes it. +fn window(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str) { + let Some((name, lib)) = K::get(app, id) else { + app.ui.library_panel = None; + return; + }; + let t = Tokens::get(ctx); + let mut open = true; + // Left of the icon panels' pop-out, movable. + let at = egui::pos2((ctx.content_rect().right() - 880.0).max(8.0), 140.0); + let area = + egui::Area::new(egui::Id::new(("library-panel", K::KIND))).order(egui::Order::Foreground).default_pos(at).movable(true).constrain(true); + area.show(ctx, |ui| { + egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| { + ui.set_width(256.0); + let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover()); + ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker); + let label = egui::RichText::new(&name).font(theme::semibold(12.0)); + let galley = ui.painter().layout_no_wrap(name.clone(), theme::semibold(12.0), t.text); + let tab = Rect::from_min_size(strip.min, vec2((galley.size().x + 24.0).min(190.0), 26.0)); + ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel); + ui.put(tab.shrink2(vec2(12.0, 0.0)), egui::Label::new(label.color(t.text)).truncate().selectable(false)); + let close = Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0)); + let cr = ui.interact(close, ui.id().with("close-library"), Sense::click()).on_hover_text("Close"); + icons::paint(ui, "x", close, if cr.hovered() { t.text } else { t.text_dim }); + open = !cr.clicked(); + let menu = Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0)); + let mr = ui.interact(menu, ui.id().with("library-menu"), Sense::click()).on_hover_text("Panel menu"); + icons::paint(ui, "menu", menu.shrink(1.0), if mr.hovered() { t.text_strong } else { t.text_dim }); + egui::Popup::menu(&mr).show(|ui| { + ui.set_min_width(200.0); + panel_menu::(app, ui, id); + }); + egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { + ui.set_width(236.0); + body::(app, ui, id, &lib); + }); + }); + }); + if !open { + app.ui.library_panel = None; + } +} + +/// The selected items and folders of library `id` (a selection in another library is dropped). +fn selection(ctx: &egui::Context, id: &str) -> Vec { + let (lib, sel): (String, Vec) = pstate(ctx, &key::("selected")); + if lib == id { sel } else { vec![] } +} + +fn set_selection(ctx: &egui::Context, id: &str, sel: Vec) { + set_pstate(ctx, &key::("selected"), (id.to_string(), sel)); +} + +/// Find field, thumbnails or list rows, and the bottom bar. +fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K::Lib) { + let view: View = pstate(ui.ctx(), &key::("view")); + let query = if pstate::(ui.ctx(), &key::("hide-find")) { + String::new() + } else { + widgets::search_field(ui, egui::Id::new(key::("find")), "Find") + }; + ui.add_space(4.0); + let rows = K::rows(lib, &query.trim().to_lowercase()); + let selected = selection::(ui.ctx(), id); + let is_sel = |n: &str| selected.iter().any(|s| s == n); + let mut clicked: Option<(String, bool, egui::Modifiers)> = None; + let mut click = |ui: &Ui, resp: egui::Response, row: &Row, tip: &dyn Fn() -> String| { + if resp.on_hover_text(tip()).clicked() { + clicked = Some((row.name.to_string(), row.item.is_none(), ui.input(|i| i.modifiers))); + } + }; + widgets::list_box(ui, |ui| { + let max_height = if view == View::LargeThumb { 280.0 } else { 220.0 }; + egui::ScrollArea::vertical().id_salt(key::("scroll")).max_height(max_height).show(ui, |ui| { + ui.set_width(ui.available_width()); + if rows.is_empty() { + widgets::dim_label(ui, if query.trim().is_empty() { "This library is empty." } else { "No matches." }); + } else if view.is_list() { + for row in &rows { + let (r, resp, chip) = list_row(ui, tile_id::(row.name), view, is_sel(row.name)); + match row.item { + Some(item) => { + K::draw(app, ui, chip, item, false, false); + K::row_icons(ui, r, item); + } + None => super::swatches::draw_folder(ui, chip), + } + list_row_name(ui, r, chip, row.name); + let tip = || row.item.map_or_else(|| row.name.to_string(), |i| format!("{} ({})", row.name, K::describe(i))); + click(ui, resp, row, &tip); + } + } else { + for (row, cell) in tile_grid(ui, &rows, view, |r| r.item.is_none()) { + let resp = ui.interact(cell, tile_id::(row.name), Sense::click()); + match row.item { + Some(item) => K::draw(app, ui, cell, item, is_sel(row.name), resp.hovered()), + None => folder_tile(ui, cell, is_sel(row.name)), + } + click(ui, resp, row, &|| row.name.to_string()); + } + } + }); + }); + if let Some((name, folder, m)) = clicked { + // A plain click on an item adds and applies it; folders and modifier clicks select. + let only_select = folder || m.shift || m.command; + let order: Vec<&str> = rows.iter().map(|r| r.name).collect(); + let sel = if only_select { click_selection(ui, &key::("anchor"), selected.clone(), &order, &name, m) } else { vec![name.clone()] }; + set_selection::(ui.ctx(), id, sel); + if !only_select { + K::click(app, ui, id, &name); + } + } + bottom::(app, ui, id); +} + +/// The library `step` places after (or before, when negative) `id` in menu order, wrapping. +fn neighbour(app: &VectorcraftApp, id: &str, step: isize) -> Option { + let all = K::list(app); + let i = all.iter().position(|l| l.id == id)? as isize; + let n = all.len() as isize; + all.into_iter().nth((i + step).rem_euclid(n) as usize).map(|l| l.id) +} + +/// Open library `id` in the panel. +fn open(app: &mut VectorcraftApp, id: &str) { + if let Err(e) = app.run(K::OPEN, json!({ "library": id })) { + app.status(e); + } +} + +/// Libraries menu, previous / next library, and Add to … for the selection. +fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { + let selected = selection::(ui.ctx(), id); + widgets::bottom_bar(ui, |ui| { + let r = widgets::icon_button(ui, "library", K::MENU, false, 24.0); + egui::Popup::menu(&r).show(|ui| { + ui.set_min_width(200.0); + library_menu::(app, ui); + }); + for (icon, tip, step) in [("chevron-left", "Previous Library", -1), ("chevron-right", "Next Library", 1)] { + if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() + && let Some(next) = neighbour::(app, id, step) + { + open::(app, &next); + } + } + ui.add_space((ui.available_width() - 28.0).max(0.0)); + if widgets::icon_button_enabled(ui, "dc-new-item", K::ADD, false, !selected.is_empty(), 24.0).clicked() { + K::add(app, id, selected.clone()); + } + }); +} + +/// The library panel's (≡) menu. +fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { + let selected = selection::(ui.ctx(), id); + if menu_item(ui, K::ADD, !selected.is_empty(), false) { + K::add(app, id, selected); + } + ui.separator(); + let view: View = pstate(ui.ctx(), &key::("view")); + for (v, label) in View::ALL { + if menu_item(ui, label, true, v == view) { + set_pstate(ui.ctx(), &key::("view"), v); + } + } + ui.separator(); + let hidden: bool = pstate(ui.ctx(), &key::("hide-find")); + if menu_item(ui, "Show Find Field", true, !hidden) { + set_pstate(ui.ctx(), &key::("hide-find"), !hidden); + } + ui.separator(); + for (label, step) in [("Previous Library", -1), ("Next Library", 1)] { + if menu_item(ui, label, true, false) + && let Some(next) = neighbour::(app, id, step) + { + open::(app, &next); + } + } + if menu_item(ui, "Close Library", true, false) { + app.ui.library_panel = None; + } +} + +/// The libraries menu (the panel's library button, the Swatches panel's): every library by +/// submenu, the open one checked, between `K`'s head and tail items. +pub(crate) fn library_menu(app: &mut VectorcraftApp, ui: &mut Ui) { + K::menu_head(app, ui); + let current = app.ui.library_panel.as_ref().filter(|o| o.kind == K::KIND).map(|o| o.id.clone()); + let all = K::list(app); + let mut chosen = None; + let mut item = |ui: &mut Ui, l: &LibraryRef| { + if menu_item(ui, &l.name, true, current.as_deref() == Some(l.id.as_str())) { + chosen = Some(l.id.clone()); + } + }; + for l in all.iter().filter(|l| l.submenu.is_none()) { + item(ui, l); + } + let mut subs: Vec<&str> = vec![]; + for s in all.iter().filter_map(|l| l.submenu) { + if !subs.contains(&s) { + subs.push(s); + } + } + for s in subs { + ui.menu_button(s, |ui| { + for l in all.iter().filter(|l| l.submenu == Some(s)) { + item(ui, l); + } + }); + } + if let Some(id) = chosen { + open::(app, &id); + } + K::menu_tail(app, ui); +} + +#[cfg(test)] +mod tests { + use egui::{Event, Modifiers, PointerButton, Pos2}; + use serde_json::Value; + use vectorcraft_color::Paint; + use vectorcraft_engine::Session; + + use super::*; + use crate::panels::swatches::SwatchLibraries; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app + } + + fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx + } + + /// One frame of the library panel with `events` and `modifiers` held. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, modifiers: Modifiers, time: f64) { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))); + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| show_window(app, ui.ctx())); + out.textures_delta.clear(); + } + + /// Click the tile or row of `name` with `modifiers` (frames from `time` to `time + 0.5`; a new + /// window takes a sizing frame first). + fn click(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { + frame(app, ctx, vec![], Modifiers::NONE, time); + frame(app, ctx, vec![], Modifiers::NONE, time + 0.25); + let at = ctx.read_response(tile_id::(name)).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; + frame(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5); + } + + fn undo_len(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() + } + + #[test] + fn clicking_a_library_swatch_adds_and_applies_it_in_one_step() { + let mut app = app(); + let ctx = context(); + let r = app.run("window.swatchLibrary", json!({"library": "Pastels"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("pastels"), Some(24))); + assert_eq!(app.ui.library_panel, Some(OpenLibrary { kind: "swatches".into(), id: "pastels".into() })); + let undo = undo_len(&app); + click(&mut app, &ctx, "Pastel Teal", Modifiers::NONE, 0.0); + let d = &app.session.doc().unwrap().doc; + let added = d.swatch("Pastel Teal").expect("added to the document"); + assert_eq!(app.session.paint.fill, Paint::solid(added.paint.color().unwrap()), "applied to the fill"); + assert_eq!(undo_len(&app), undo + 1); + // Alt-click paints the stroke (the inactive proxy). + click(&mut app, &ctx, "Soft Blue", Modifiers::ALT, 2.0); + let blue = app.session.doc().unwrap().doc.swatch("Soft Blue").and_then(|w| w.paint.color()).unwrap(); + assert_eq!(app.session.paint.stroke.color(), Some(blue)); + assert!(app.session.fill_active, "Alt keeps the active proxy"); + // A library that doesn't exist closes the panel; null closes it. + assert!(app.run("window.swatchLibrary", json!({"library": "nope"})).is_err()); + app.run("window.swatchLibrary", json!({"library": null})).unwrap(); + assert!(app.ui.library_panel.is_none()); + } + + #[test] + fn modifier_clicks_select_and_add_to_swatches_adds_the_selection() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "earth-tones"})).unwrap(); + click(&mut app, &ctx, "Clay 1", Modifiers::COMMAND, 0.0); + click(&mut app, &ctx, "Ochre", Modifiers::NONE, 2.0); + assert_eq!(selection::(&ctx, "earth-tones"), ["Ochre"], "a folder click selects its group"); + click(&mut app, &ctx, "Clay 3", Modifiers::COMMAND, 4.0); + assert!(app.session.doc().unwrap().doc.swatch("Clay 3").is_none(), "modifier clicks only select"); + let sel = selection::(&ctx, "earth-tones"); + SwatchLibraries::add(&mut app, "earth-tones", sel); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Ochre").map(|g| g.swatches.len()), Some(6)); + assert!(d.swatches.iter().any(|w| w.name == "Clay 3")); + // The selection belongs to its library. + app.run("window.swatchLibrary", json!({"library": "brights"})).unwrap(); + assert!(selection::(&ctx, "brights").is_empty()); + } + + #[test] + fn find_filters_list_view_and_arrows_step_through_the_libraries() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "web-safe-216"})).unwrap(); + set_pstate(&ctx, &key::("view"), View::SmallList); + ctx.data_mut(|d| d.insert_temp(egui::Id::new(key::("find")), "#FF00".to_string())); + frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.0); + frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.5); + 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"); + assert_eq!(neighbour::(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); + } + + const SWATCH_IDS: [&str; 9] = [ + "web-safe-216", + "grays-neutrals", + "earth-tones", + "skin-tone-ramps", + "pastels", + "brights", + "metallic-gradients", + "perceptual-scales", + "harmony-sets", + ]; + + #[test] + fn the_window_submenu_lists_every_library() { + let app = app(); + let ids: Vec = crate::menus::menu_entries(&app) + .into_iter() + .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 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/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0b0613ed..ef5c9b5b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -19,6 +19,7 @@ pub mod history; pub mod image_trace; pub mod info; pub mod layers; +pub mod library_panel; pub mod magic_wand; pub mod navigator; pub mod opentype; diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 886b1426..7d66e493 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -5,17 +5,23 @@ //! proxy's paint command with the object's id) and onto the Gradient panel's ramp; a Fill/Stroke //! proxy or the Gradient panel's thumbnail dropped on the panel becomes a swatch (`swatch.new`). +use std::sync::Arc; + use egui::{Color32, Rect, Response, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_color::{Color, GradientKind, Paint}; +use vectorcraft_color::libraries::SWATCH_LIBRARIES; +use vectorcraft_color::{Color, GradientKind, Paint, Swatch, SwatchLibrary}; use vectorcraft_doc::Document; +use vectorcraft_engine::cmd::swatchlib; +use super::library_panel::{self, LibraryKind, LibraryRef, OpenLibrary, Row}; use super::{active_paint, pstate, set_pstate}; +use crate::menus::Item; use crate::theme::Tokens; use crate::widgets::{self, PanelDrag, SwatchRows, menu_item, swatch_tile}; use crate::{VectorcraftApp, icons}; -/// View modes of the swatch list. +/// View modes of the swatch list (and of the library panel). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum View { SmallThumb, @@ -27,18 +33,18 @@ pub enum View { } impl View { - const ALL: [(View, &'static str); 5] = [ + pub(crate) const ALL: [(View, &'static str); 5] = [ (View::SmallThumb, "Small Thumbnail View"), (View::MediumThumb, "Medium Thumbnail View"), (View::LargeThumb, "Large Thumbnail View"), (View::SmallList, "Small List View"), (View::LargeList, "Large List View"), ]; - fn is_list(self) -> bool { + pub(crate) fn is_list(self) -> bool { matches!(self, View::SmallList | View::LargeList) } /// (tile, pitch) in points. Medium is the measured 15.5 / 17 pt. - fn tile(self) -> (f32, f32) { + pub(crate) fn tile(self) -> (f32, f32) { match self { View::SmallThumb => (11.0, 12.5), View::MediumThumb => (15.5, 17.0), @@ -202,7 +208,7 @@ fn draw_registration(ui: &Ui, r: Rect) { ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); } -fn draw_folder(ui: &Ui, r: Rect) { +pub(crate) fn draw_folder(ui: &Ui, r: Rect) { let t = Tokens::get(ui.ctx()); icons::paint(ui, "dc-folder", r.expand(1.0), t.icon); } @@ -212,7 +218,7 @@ const CMYK_INKS: [Color32; 4] = [Color32::from_rgb(0, 174, 239), Color32::from_r const RGB_BARS: [Color32; 3] = [Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 200, 0), Color32::from_rgb(0, 0, 255)]; /// A swatch's kind in words, for list tooltips ("Global Process Color, CMYK"). -fn describe(paint: &Paint, global: bool, spot: bool) -> String { +pub(crate) fn describe(paint: &Paint, global: bool, spot: bool) -> String { match paint { Paint::Solid { color, .. } => { let kind = if spot { @@ -233,7 +239,7 @@ fn describe(paint: &Paint, global: bool, spot: bool) -> String { /// The kind and colour-mode icons at the right of list row `r`: spot (a dot in a ring), global (a /// square with a filled corner) or process colours (a square), gradients and patterns; then a /// colour's model (CMYK as four ink quarters, RGB as three bars, Gray as a grey square). -fn list_icons(ui: &Ui, r: Rect, paint: &Paint, global: bool, spot: bool) { +pub(crate) fn list_icons(ui: &Ui, r: Rect, paint: &Paint, global: bool, spot: bool) { let t = Tokens::get(ui.ctx()); let p = ui.painter(); let mode = Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(10.0, 10.0)); @@ -303,9 +309,10 @@ fn highlighted<'a>(d: &'a Document, sel: &'a [String]) -> Vec<&'a str> { } /// The selection after a click on `name`: Cmd/Ctrl toggles it, Shift extends from the last clicked -/// name over `order` (the names as displayed), a plain click selects it alone. -fn click_selection(ui: &Ui, mut sel: Vec, order: &[&str], name: &str, m: egui::Modifiers) -> Vec { - let anchor: String = pstate(ui.ctx(), "swatch-anchor"); +/// name (kept in panel state `anchor_key`) over `order` (the names as displayed), a plain click +/// selects it alone. +pub(crate) fn click_selection(ui: &Ui, anchor_key: &str, mut sel: Vec, order: &[&str], name: &str, m: egui::Modifiers) -> Vec { + let anchor: String = pstate(ui.ctx(), anchor_key); let pos = |n: &str| order.iter().position(|o| *o == n); if m.shift && let (Some(a), Some(b)) = (pos(&anchor), pos(name)) @@ -317,7 +324,7 @@ fn click_selection(ui: &Ui, mut sel: Vec, order: &[&str], name: &str, m: } return sel; } - set_pstate(ui.ctx(), "swatch-anchor", name.to_string()); + set_pstate(ui.ctx(), anchor_key, name.to_string()); if !m.command { return vec![name.to_string()]; } @@ -547,7 +554,6 @@ fn find_id() -> egui::Id { } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); if app.session.active().is_none() { super::empty_state(ui, "swatch-book", "No document", "Open a document to see its swatches."); return; @@ -583,7 +589,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let sel = if selected.is_empty() { &fallback } else { &selected }; let lit = app.session.active().map(|st| highlighted(&st.doc, sel)).unwrap_or_default(); let is_sel = |name: &str| lit.contains(&name); - let (tile, pitch) = view.tile(); let mut ev = TileEvents::default(); widgets::list_box(ui, |ui| { let max_height = if view == View::LargeThumb { 200.0 } else { 150.0 }; @@ -591,15 +596,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.set_width(ui.available_width()); if view.is_list() { for e in &items { - let (_, r) = ui.allocate_space(vec2(ui.available_width(), pitch)); - let resp = ui.interact(r, tile_id(e), Sense::click_and_drag()); let name = e.name(); - if is_sel(name) { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); - } - let chip = Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile)); + let (r, resp, chip) = list_row(ui, tile_id(e), view, is_sel(name)); match e { Entry::Registration => draw_registration(ui, chip), Entry::Swatch { paint, global, spot, .. } => { @@ -609,63 +607,25 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } Entry::Folder(_) => draw_folder(ui, chip), } - ui.painter().text( - pos2(chip.right() + 8.0, r.center().y), - egui::Align2::LEFT_CENTER, - name, - egui::FontId::proportional(12.0), - t.text, - ); + list_row_name(ui, r, chip, name); tile_input(ui, resp, e, &items, sel, true, &mut ev); } } else { - let w = ui.available_width(); - let per_row = ((w - 2.0) / pitch).floor().max(1.0) as usize; - let mut col = 0usize; - let mut rows: Vec> = vec![vec![]]; - for e in &items { - // A group folder starts a new row. - if e.is_folder() && col > 0 { - rows.push(vec![]); - col = 0; - } - if col == per_row { - rows.push(vec![]); - col = 0; - } - 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, 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, tile_id(e), Sense::click_and_drag()); - let name = e.name(); - match e { - Entry::Registration => draw_registration(ui, cell), - Entry::Swatch { paint, global, spot, .. } => { - swatch_tile(ui, cell, paint, is_sel(name), resp.hovered()); - pattern_thumb(app, ui, cell.shrink(1.0), paint); - // Global colours get a white corner; spot colours a dot in it. - if *global { - let k = cell.shrink(1.0).right_bottom(); - let corner = vec![k, k - vec2(5.0, 0.0), k - vec2(0.0, 5.0)]; - ui.painter().add(Shape::convex_polygon(corner, Color32::WHITE, Stroke::NONE)); - if *spot { - ui.painter().circle_filled(k - vec2(1.6, 1.6), 1.0, Color32::BLACK); - } - } - } - Entry::Folder(_) => { - draw_folder(ui, cell); - if is_sel(name) { - ui.painter().rect_stroke(cell.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside); - } + for (e, cell) in tile_grid(ui, &items, view, Entry::is_folder) { + let resp = ui.interact(cell, tile_id(e), Sense::click_and_drag()); + let name = e.name(); + match e { + Entry::Registration => draw_registration(ui, cell), + Entry::Swatch { paint, global, spot, .. } => { + swatch_tile(ui, cell, paint, is_sel(name), resp.hovered()); + pattern_thumb(app, ui, cell.shrink(1.0), paint); + if *global { + global_mark(ui, cell, *spot); } } - tile_input(ui, resp, e, &items, sel, false, &mut ev); + Entry::Folder(_) => folder_tile(ui, cell, is_sel(name)), } + tile_input(ui, resp, e, &items, sel, false, &mut ev); } } }); @@ -681,7 +641,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let selected = match ev.clicked { Some((e, m)) => { let order: Vec<&str> = items.iter().map(Entry::name).collect(); - let sel = click_selection(ui, selected, &order, e.name(), m); + let sel = click_selection(ui, "swatch-anchor", selected, &order, e.name(), m); set_pstate(ui.ctx(), "swatch-selected", sel.clone()); // A plain click also applies the swatch; modifier clicks only select. if !(m.shift || m.command) { @@ -694,6 +654,68 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { bottom(app, ui, &selected); } +/// The thumbnail cells of `items` for `view` in the full available width, row by row; a folder +/// (`is_folder`) starts a new row. +pub(crate) fn tile_grid<'a, T>(ui: &mut Ui, items: &'a [T], view: View, is_folder: impl Fn(&T) -> bool) -> Vec<(&'a T, Rect)> { + let (tile, pitch) = view.tile(); + let w = ui.available_width(); + let per_row = ((w - 2.0) / pitch).floor().max(1.0) as usize; + let mut rows: Vec> = vec![vec![]]; + for e in items { + let len = rows.last().map_or(0, Vec::len); + if len == per_row || (len > 0 && is_folder(e)) { + rows.push(vec![]); + } + rows.last_mut().expect("a row").push(e); + } + let mut out = Vec::with_capacity(items.len()); + for row in rows { + let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover()); + let cell = |c: usize| Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); + out.extend(row.into_iter().enumerate().map(|(c, e)| (e, cell(c)))); + } + out +} + +/// A list-view row for `view` taking clicks and drags as `id`, shaded when `selected` or hovered +/// → (the row, its response, the chip at its left). +pub(crate) fn list_row(ui: &mut Ui, id: egui::Id, view: View, selected: bool) -> (Rect, Response, Rect) { + let t = Tokens::get(ui.ctx()); + let (tile, pitch) = view.tile(); + let (_, r) = ui.allocate_space(vec2(ui.available_width(), pitch)); + let resp = ui.interact(r, id, Sense::click_and_drag()); + if selected { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + (r, resp, Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile))) +} + +/// A list row's name, right of its `chip`. +pub(crate) fn list_row_name(ui: &Ui, r: Rect, chip: Rect, name: &str) { + let t = Tokens::get(ui.ctx()); + ui.painter().text(pos2(chip.right() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); +} + +/// The white corner of a global colour's tile; a spot colour has a dot in it. +pub(crate) fn global_mark(ui: &Ui, cell: Rect, spot: bool) { + let k = cell.shrink(1.0).right_bottom(); + let corner = vec![k, k - vec2(5.0, 0.0), k - vec2(0.0, 5.0)]; + ui.painter().add(Shape::convex_polygon(corner, Color32::WHITE, Stroke::NONE)); + if spot { + ui.painter().circle_filled(k - vec2(1.6, 1.6), 1.0, Color32::BLACK); + } +} + +/// A colour group's folder in the thumbnail view, outlined when `selected`. +pub(crate) fn folder_tile(ui: &Ui, cell: Rect, selected: bool) { + draw_folder(ui, cell); + if selected { + ui.painter().rect_stroke(cell.expand(1.0), 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Outside); + } +} + /// The interaction id of a tile or list row (stable per swatch or group name). fn tile_id(e: &Entry) -> egui::Id { egui::Id::new(("swatch-tile", e.name())) @@ -742,7 +764,12 @@ fn delete(app: &mut VectorcraftApp, names: Vec, now: bool) { fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { let kind: Kind = pstate(ui.ctx(), "swatch-kind"); widgets::bottom_bar(ui, |ui| { - widgets::icon_button_enabled(ui, "library", "Swatch Libraries (on the roadmap)", false, false, 24.0); + let open = app.ui.library_panel.as_ref().is_some_and(|o| o.kind == SwatchLibraries::KIND); + let lr = widgets::icon_button(ui, "library", SwatchLibraries::MENU, open, 24.0); + egui::Popup::menu(&lr).show(|ui| { + ui.set_min_width(200.0); + library_panel::library_menu::(app, ui); + }); let kr = widgets::icon_button(ui, "dc-swatch-kinds", "Show Swatch Kinds", kind != Kind::All, 24.0); egui::Popup::menu(&kr).show(|ui| { for (k, label) in Kind::ALL { @@ -862,10 +889,99 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } menu_item(ui, "Spot Colors…", false, false); ui.separator(); - menu_item(ui, "Open Swatch Library", false, false); + ui.menu_button("Open Swatch Library", |ui| library_panel::library_menu::(app, ui)); menu_item(ui, "Save Swatch Library…", false, false); } +/// Swatch libraries in the library panel (engine: `swatch.library.*`). +pub(crate) struct SwatchLibraries; + +impl LibraryKind for SwatchLibraries { + const KIND: &'static str = "swatches"; + const OPEN: &'static str = "window.swatchLibrary"; + const MENU: &'static str = "Swatch Libraries Menu"; + const ADD: &'static str = "Add to Swatches"; + type Lib = Arc; + type Item = Swatch; + + fn list(app: &VectorcraftApp) -> Vec { + swatchlib::libraries(&app.session).into_iter().map(|l| LibraryRef { id: l.id, name: l.name, submenu: None }).collect() + } + fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { + swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib)) + } + /// Ungrouped swatches, then each colour group's folder and swatches (a group whose name + /// matches shows all of them), as in the Swatches panel. + fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); + let row = |w: &'a Swatch| Row { name: &w.name, item: Some(w) }; + let mut out: Vec> = lib.swatches.iter().filter(|w| found(&w.name)).map(row).collect(); + for g in &lib.groups { + let all = found(&g.name); + let mut items = g.swatches.iter().filter(|w| all || found(&w.name)).map(row).peekable(); + if items.peek().is_some() { + out.push(Row { name: &g.name, item: None }); + out.extend(items); + } + } + out + } + fn draw(_: &VectorcraftApp, ui: &Ui, r: Rect, w: &Swatch, selected: bool, hovered: bool) { + swatch_tile(ui, r, &w.paint, selected, hovered); + if w.global { + global_mark(ui, r, w.spot); + } + } + fn row_icons(ui: &Ui, r: Rect, w: &Swatch) { + list_icons(ui, r, &w.paint, w.global, w.spot); + } + fn describe(w: &Swatch) -> String { + describe(&w.paint, w.global, w.spot) + } + /// Adds the swatch and applies it to the active proxy (Alt: the inactive one), as one step. + fn click(app: &mut VectorcraftApp, ui: &Ui, id: &str, name: &str) { + let alt = super::alt_held(ui); + let apply = if super::proxy_cmd(app, alt) == "paint.setFill" { "fill" } else { "stroke" }; + add_from_library(app, json!({"library": id, "names": [name], "apply": apply, "focus": !alt})); + } + fn add(app: &mut VectorcraftApp, id: &str, names: Vec) { + add_from_library(app, json!({"library": id, "names": names})); + } + fn menu_head(app: &mut VectorcraftApp, ui: &mut Ui) { + if menu_item(ui, "Default Swatches", app.session.active().is_some(), false) { + app.run("swatch.resetDefaults", json!({})).ok(); + } + ui.separator(); + } +} + +fn add_from_library(app: &mut VectorcraftApp, params: Value) { + if let Err(e) = app.run("swatch.library.add", params) { + app.status(e); + } +} + +/// `window.swatchLibrary {library}`: open a swatch library in the library panel (`library` null +/// closes it). +pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result { + let Some(key) = p.get("library").filter(|v| !v.is_null()) else { + app.ui.library_panel = None; + return Ok(json!({ "open": null })); + }; + let key = key.as_str().ok_or("`library` is a library id or name (see swatch.library.list)")?; + let (info, lib) = swatchlib::library(&app.session, key).ok_or_else(|| format!("no swatch library `{key}` (see swatch.library.list)"))?; + app.ui.library_panel = Some(OpenLibrary { kind: SwatchLibraries::KIND.into(), id: info.id.clone() }); + Ok(json!({"open": info.id, "name": info.name, "count": lib.len()})) +} + +/// Window → Swatch Libraries. +pub(crate) fn window_menu() -> Vec { + 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([Item::Sep, Item::Todo("User Defined", ""), Item::Sep, Item::Todo("Other Library…", "")]); + items +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3ccc56c0..0e1c38e9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -249,6 +249,9 @@ pub struct UiState { /// Color Guide panel: variation kind, steps and amount (Color Guide Options). #[serde(default)] pub color_guide: vectorcraft_color::harmony::GuideOptions, + /// The library open in the library panel (Window → Swatch Libraries). + #[serde(default)] + pub library_panel: Option, } impl UiState { @@ -301,6 +304,7 @@ impl Default for UiState { recent_fonts: vec![], engine_prefs: Value::Null, color_guide: Default::default(), + library_panel: None, } } } diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index f2f6ea21..ee71d88e 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -49,7 +49,7 @@ fn effect_menu_has_a_vector_effects_section() { fn library_submenus_are_disabled_placeholders() { let app = app(); let entries = menus::menu_entries(&app); - for lib in ["Brush Libraries", "Graphic Style Libraries", "Swatch Libraries", "Symbol Libraries"] { + for lib in ["Brush Libraries", "Graphic Style Libraries", "Symbol Libraries"] { let items: Vec<_> = entries.iter().filter(|e| e.path == ["Window", lib]).collect(); let labels: Vec<&str> = items.iter().map(|e| e.label.as_str()).collect(); assert_eq!(labels, ["Built-in Libraries", "User Defined", "Other Library…"], "{lib}"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f648f91c..1ca07f8f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -77,3 +77,9 @@ Edit Colors dialogs: `ui.colorBalanceDialog` opens Adjust Colors (`colorBalance` canvas while open; `ui.dialog.confirm` keeps the result as one undo step (`edit.colors.adjustBalance` / `edit.colors.saturate`) and `ui.dialog.cancel` rolls it back. Global mode answers with an error until tints of global and spot colours exist; the dialog then stays open. + +Library panel: `engine.execute {command: "window.swatchLibrary", params: {library}}` opens the read-only library +panel on a swatch library (`ui.inspect` shows it as `library_panel: {kind, id}`; `library: null` closes it). +Clicking a swatch there runs `swatch.library.add {library, names: [name], apply}` (the active proxy, or the other +one with Alt), so one undo step adds and applies it; Shift/Cmd-clicks select swatches and colour groups for +Add to Swatches. diff --git a/docs/mcp.md b/docs/mcp.md index 457983f9..a7bc1d82 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -303,4 +303,19 @@ filter on the whole group. {"name":"run_command","arguments":{"command":"appearance.moveItem","params":{"ids":[7],"from":"contents","to":1}}} {"name":"run_command","arguments":{"command":"effect.apply","params":{"ids":[2],"effect":"stylize.dropShadow"}}} {"name":"run_command","arguments":{"command":"appearance.addStroke","params":{"ids":[7],"target":"contents"}}} + +## Swatch libraries + +Swatch libraries are read-only sets of swatches computed in code (Web Safe 216, Grays and Neutrals, Earth Tones, +Skin Tone Ramps, Pastels, Brights, Metallic Gradients, Perceptual Scales, Harmony Sets). `swatch.library.list` lists +them (`id`, `name`, `category`, `count`) and `swatch.library.get {library}` returns one's swatches and colour groups +in the `swatch.list` shape (`library` is an id or a name). `swatch.library.add {library, names?}` copies swatches +into the document as one undo step: a colour group's name brings the whole group, a swatch's name the swatch alone, +no names the whole library; swatches the document already has (same name and paint) are reported under `existing` +and not added again. `apply: "fill"|"stroke"` also applies the first one, in the same undo step (what clicking a +swatch in the library panel does). `swatch.resetDefaults {replace?}` brings back the missing default swatches. + +```json +{"name":"run_command","arguments":{"command":"swatch.library.get","params":{"library":"earth-tones"}}} +{"name":"run_command","arguments":{"command":"swatch.library.add","params":{"library":"earth-tones","names":["Clay"]}}} ``` From 786f2c05d32b8b0e20c81c2ed032fc649a5524a8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:32:01 +0700 Subject: [PATCH 2/3] M3.57: save swatch libraries as .vcswatches, .gpl or CSS and load them back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save Swatch Library… (Swatches panel menu, libraries menu, Window menu) opens a dialog: name, format and destination. .vcswatches is lossless JSON (colour models, global, spot, gradients, groups), .gpl an RGB palette with groups as comment headers, CSS custom properties with escaped names. Libraries saved to the user library folder show under User Defined. Other Library… (and opening a .vcswatches/.gpl file) loads a library or another document's swatches into the library panel. Commands: swatch.library.save {path?, format?, names?, name?, user?} (no path returns {data}), swatch.library.load {path?|data?|dataBase64?, name?}; UI: window.swatchLibrary.other, ui.saveSwatchLibrary (dialog saveSwatchLibrary), window.userSwatchLibrary1..10. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/main.rs | 3 + crates/color/Cargo.toml | 1 + crates/color/src/lib.rs | 1 + crates/color/src/palette_io.rs | 324 ++++++++++++++++++ crates/engine/src/cmd/fileio/mod.rs | 19 +- crates/engine/src/cmd/fileio/tests.rs | 2 +- crates/engine/src/cmd/swatchlib.rs | 218 +++++++++++- crates/engine/src/lib.rs | 3 + crates/engine/src/tests_swatchlib.rs | 67 ++++ crates/ui-egui/src/dialogs/mod.rs | 2 + .../src/dialogs/save_swatch_library.rs | 97 ++++++ crates/ui-egui/src/io.rs | 14 +- crates/ui-egui/src/menus.rs | 46 ++- crates/ui-egui/src/panels/library_panel.rs | 74 ++++ crates/ui-egui/src/panels/swatches.rs | 71 +++- docs/control-protocol.md | 4 + docs/mcp.md | 5 + 17 files changed, 924 insertions(+), 27 deletions(-) create mode 100644 crates/color/src/palette_io.rs create mode 100644 crates/ui-egui/src/dialogs/save_swatch_library.rs diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 9ae909c4..8d35e65d 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -161,6 +161,9 @@ fn main() -> eframe::Result { Box::new(move |cc| { let mut app = VectorcraftApp::new(Session::new(), services()); load_prefs(&mut app); + // User Defined swatch libraries live next to the preferences. + let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string())); + app.session.swatch_libraries.set_user_dir(swatches); app.integrated_titlebar = cfg!(target_os = "macos"); if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml index 010c0475..a007ede3 100644 --- a/crates/color/Cargo.toml +++ b/crates/color/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] kurbo = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } moxcms = "0.8" diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 26f3fd34..816a8789 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -9,6 +9,7 @@ pub mod cms; pub mod gradient; pub mod harmony; pub mod libraries; +pub mod palette_io; pub mod swatch; pub use blend::BlendMode; diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs new file mode 100644 index 00000000..160383c0 --- /dev/null +++ b/crates/color/src/palette_io.rs @@ -0,0 +1,324 @@ +//! Swatch library files: the native `.vcswatches` JSON (colour models, global, spot, gradients +//! and colour groups exactly), `.gpl` palettes (8-bit RGB; colour groups as `# Group:` comment +//! headers) and CSS custom properties (written only). + +use serde::{Deserialize, Serialize}; + +use crate::{Color, GradientKind, Paint, Swatch, SwatchGroup, SwatchLibrary}; + +/// A swatch library file format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PaletteFormat { + /// `.vcswatches`: JSON, lossless. + Native, + /// `.gpl`: a plain-text RGB palette many paint and design tools read. + Gpl, + /// `.css`: custom properties on `:root` (colours and gradients; written only). + Css, +} + +impl PaletteFormat { + pub const ALL: [PaletteFormat; 3] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Css]; + + /// The format's id, which is also its extension. + pub fn id(self) -> &'static str { + match self { + PaletteFormat::Native => "vcswatches", + PaletteFormat::Gpl => "gpl", + PaletteFormat::Css => "css", + } + } + pub fn label(self) -> &'static str { + match self { + PaletteFormat::Native => "VectorCraft Swatches (.vcswatches)", + PaletteFormat::Gpl => "GPL Palette (.gpl)", + PaletteFormat::Css => "CSS Custom Properties (.css)", + } + } + /// The format with id or extension `s` (any case, leading dot allowed). + pub fn parse(s: &str) -> Option { + let s = s.trim_start_matches('.').to_ascii_lowercase(); + Self::ALL.into_iter().find(|f| f.id() == s) + } + /// Can libraries be read back from this format? + pub fn readable(self) -> bool { + self != PaletteFormat::Css + } +} + +/// The native file: a header around the library. +#[derive(Serialize, Deserialize)] +struct NativeFile { + format: String, + version: u32, + #[serde(flatten)] + library: SwatchLibrary, +} + +const NATIVE_FORMAT: &str = "vcswatches"; +const GPL_HEADER: &str = "GIMP Palette"; +const GPL_GROUP: &str = "# Group:"; + +/// Write `lib` in `format`. Pattern swatches (their tiles live in a document) and None are left +/// out; `.gpl` keeps solid colours only, as 8-bit RGB. +pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String { + let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_)); + match format { + PaletteFormat::Native => { + let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::>(); + let library = SwatchLibrary { + name: lib.name.clone(), + swatches: strip(&lib.swatches), + groups: lib.groups.iter().map(|g| SwatchGroup { name: g.name.clone(), swatches: strip(&g.swatches) }).collect(), + }; + let file = NativeFile { format: NATIVE_FORMAT.into(), version: 1, library }; + serde_json::to_string_pretty(&file).unwrap_or_default() + } + PaletteFormat::Gpl => write_gpl(lib), + PaletteFormat::Css => write_css(lib, keep), + } +} + +/// One line of text: no line breaks or tabs. +fn one_line(s: &str) -> String { + s.split(['\n', '\r', '\t']).collect::>().join(" ").trim().to_string() +} + +fn write_gpl(lib: &SwatchLibrary) -> String { + let mut out = format!("{GPL_HEADER}\nName: {}\nColumns: 0\n#\n", one_line(&lib.name)); + let colors = |out: &mut String, list: &[Swatch]| { + for w in list { + if let Some(c) = w.paint.color() { + let [r, g, b, _] = c.to_rgba8(1.0); + out.push_str(&format!("{r:3} {g:3} {b:3}\t{}\n", one_line(&w.name))); + } + } + }; + colors(&mut out, &lib.swatches); + for g in &lib.groups { + out.push_str(&format!("{GPL_GROUP} {}\n", one_line(&g.name))); + colors(&mut out, &g.swatches); + } + out +} + +/// `name` as a CSS custom property (`--` and an escaped identifier): lower case, spaces as +/// hyphens, other characters an identifier can't hold escaped with a backslash. +pub fn css_property(name: &str) -> String { + let mut out = String::from("--"); + for word in name.split_whitespace() { + if out.len() > 2 { + out.push('-'); + } + for ch in word.chars().flat_map(char::to_lowercase) { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || !ch.is_ascii() { + out.push(ch); + } else if ch.is_ascii_graphic() { + out.push('\\'); + out.push(ch); + } else { + out.push_str(&format!("\\{:x} ", ch as u32)); + } + } + } + if out.len() == 2 { + out.push_str("swatch"); + } + out +} + +/// A colour as CSS (`#rrggbb`, or `#rrggbbaa` below full opacity). +fn css_color(c: &Color, opacity: f32) -> String { + let [r, g, b, a] = c.to_rgba8(opacity); + if a == 255 { format!("#{r:02x}{g:02x}{b:02x}") } else { format!("#{r:02x}{g:02x}{b:02x}{a:02x}") } +} + +/// A comment's text: `*/` can't end it early. +fn css_comment(s: &str) -> String { + one_line(s).replace("*/", "* /") +} + +fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { + let mut out = format!("/* {} */\n:root {{\n", css_comment(&lib.name)); + let mut taken: Vec = vec![]; + let mut props = |out: &mut String, list: &[Swatch]| { + for w in list.iter().filter(&keep) { + let value = match &w.paint { + Paint::Solid { color, .. } => css_color(color, 1.0), + Paint::Gradient(g) if g.gradient.kind != GradientKind::Freeform => { + let stops: Vec = + g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), (s.offset * 100.0).round())).collect(); + match g.gradient.kind { + GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")), + _ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")), + } + } + _ => continue, + }; + // Names differing only in case or punctuation map to one property: number the others. + let base = css_property(&w.name); + let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base}-{i}") }).find(|n| !taken.contains(n)).unwrap_or(base); + taken.push(name.clone()); + let note = if w.spot { " /* spot */" } else { "" }; + out.push_str(&format!(" {name}: {value};{note}\n")); + } + }; + props(&mut out, &lib.swatches); + for g in &lib.groups { + out.push_str(&format!(" /* {} */\n", css_comment(&g.name))); + props(&mut out, &g.swatches); + } + out.push_str("}\n"); + out +} + +/// Read a `.vcswatches` or `.gpl` library (detected from its content). `name` names it when the +/// file doesn't. +pub fn read(text: &str, name: &str) -> Result { + let text = text.trim_start_matches('\u{feff}').trim_start(); + if text.starts_with(GPL_HEADER) { + return Ok(read_gpl(text, name)); + } + if text.starts_with('{') { + let f: NativeFile = serde_json::from_str(text).map_err(|e| format!("not a swatch library: {e}"))?; + if f.format != NATIVE_FORMAT { + return Err(format!("not a swatch library (format `{}`)", f.format)); + } + let mut lib = f.library; + if lib.name.trim().is_empty() { + lib.name = name.into(); + } + return Ok(lib); + } + Err("not a swatch library (.vcswatches or .gpl)".into()) +} + +/// Is `text` a library [`read`] understands? +pub fn sniff(text: &str) -> bool { + let t = text.trim_start_matches('\u{feff}').trim_start(); + t.starts_with(GPL_HEADER) || (t.starts_with('{') && t.contains(NATIVE_FORMAT)) +} + +/// `.gpl`: `Name:` names the library, `# Group: name` starts a colour group, other `#` lines and +/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colours are +/// named by their values; repeated names get a number. +fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary { + let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() }; + let mut names: Vec = vec![]; + for line in text.lines().skip(1) { + let line = line.trim(); + if let Some(g) = line.strip_prefix(GPL_GROUP) { + lib.groups.push(SwatchGroup { name: g.trim().to_string(), swatches: vec![] }); + continue; + } + if let Some(n) = line.strip_prefix("Name:") { + lib.name = n.trim().to_string(); + continue; + } + // Comments, headers (`Columns: 4`) and anything else that isn't a colour are skipped. + let words: Vec<&str> = line.split_whitespace().collect(); + let rgb: Vec = words.iter().take(3).map_while(|v| v.parse().ok()).collect(); + let [r, g, b] = rgb[..] else { continue }; + let rest = words[3..].join(" "); + let base = if rest.is_empty() { format!("R={r} G={g} B={b}") } else { rest }; + let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base} {i}") }).find(|n| !names.contains(n)).unwrap_or(base); + names.push(name.clone()); + let w = Swatch { name, paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false }; + match lib.groups.last_mut() { + Some(grp) => grp.swatches.push(w), + None => lib.swatches.push(w), + } + } + lib +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Gradient, GradientPaint, GradientStop}; + + fn sample() -> SwatchLibrary { + let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient { + kind: GradientKind::Linear, + stops: vec![ + GradientStop { offset: 0.0, color: Color::cmyk(0.1, 0.2, 0.3, 0.0), opacity: 1.0, midpoint: 0.4 }, + GradientStop { offset: 1.0, color: Color::rgb8(255, 0, 0), opacity: 0.5, midpoint: 0.5 }, + ], + }))); + SwatchLibrary { + name: "Brand */ Colours".into(), + swatches: vec![ + Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.2)), global: true, spot: true }, + Swatch { name: "Fade".into(), paint: grad, global: false, spot: false }, + Swatch { name: "Tiles".into(), paint: Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }, global: false, spot: false }, + ], + groups: vec![SwatchGroup { + name: "Neutrals".into(), + swatches: vec![ + Swatch { name: "R=255 G=0 B=0".into(), paint: Paint::solid(Color::rgb8(255, 0, 0)), global: false, spot: false }, + Swatch { name: "Mist".into(), paint: Paint::solid(Color::gray(0.25)), global: true, spot: false }, + ], + }], + } + } + + #[test] + fn native_round_trip_keeps_cmyk_spot_and_groups() { + let lib = sample(); + let text = write(&lib, PaletteFormat::Native); + assert!(sniff(&text)); + let back = read(&text, "fallback").unwrap(); + let mut expected = lib.clone(); + expected.swatches.retain(|w| !matches!(w.paint, Paint::Pattern { .. })); + assert_eq!(back, expected, "everything but the pattern comes back exactly"); + assert!(back.swatches[0].spot && back.swatches[0].global); + assert_eq!(back.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2))); + assert!(read("{\"format\": \"other\", \"version\": 1, \"name\": \"x\"}", "x").is_err()); + } + + #[test] + fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() { + let text = write(&sample(), PaletteFormat::Gpl); + assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n")); + assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}"); + assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours"); + let back = read(&text, "x").unwrap(); + assert_eq!(back.name, "Brand */ Colours"); + assert_eq!(back.swatches.len(), 1); + assert_eq!(back.groups[0].swatches[1].paint.color(), Some(Color::rgb8(191, 191, 191)), "grey as 8-bit RGB"); + // Another tool's palette: comments, a Columns header, blank lines, unnamed and repeated names. + let other = "GIMP Palette\r\nName: Sunset\r\nColumns: 4\r\n# a comment\r\n\r\n 0 128 255\tSky Blue\r\n10 20 30\r\n0 0 0 Black\r\n1 1 1 Black\r\nbad line\r\n"; + let lib = read(other, "file").unwrap(); + assert_eq!(lib.name, "Sunset"); + let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]); + assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255))); + assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback"); + } + + #[test] + fn css_escapes_names_and_writes_gradients() { + assert_eq!(css_property("Sky Blue"), "--sky-blue"); + assert_eq!(css_property("R=255 G=0 B=0"), "--r\\=255-g\\=0-b\\=0"); + assert_eq!(css_property("#FF00CC"), "--\\#ff00cc"); + assert_eq!(css_property("Café 50%"), "--café-50\\%"); + assert_eq!(css_property(" "), "--swatch"); + let text = write(&sample(), PaletteFormat::Css); + assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}"); + assert!(text.contains(" --ink: #"), "{text}"); + assert!(text.contains("; /* spot */")); + assert!(text.contains(" --fade: linear-gradient(90deg, #"), "{text}"); + assert!(text.contains("#ff000080 100%)"), "half-transparent stop: {text}"); + assert!(text.contains(" /* Neutrals */\n --r\\=255-g\\=0-b\\=0: #ff0000;\n")); + assert!(!text.contains("tiles")); + // Names that collide once escaped are numbered. + let lib = SwatchLibrary { + name: "x".into(), + swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(), + groups: vec![], + }; + let text = write(&lib, PaletteFormat::Css); + assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;")); + assert!(read(&text, "x").is_err(), "CSS is written only"); + } +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index b8505e5c..95832f6f 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -242,9 +242,12 @@ pub const FORMATS: &[Format] = &[ pub const OPEN_EXTS: &[&str] = &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"]; -/// Open-dialog filters: "All readable files" first, then one per readable format. +/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch +/// libraries (which open in the library panel). pub fn open_filters() -> impl Iterator { - std::iter::once(("All readable files", OPEN_EXTS)).chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions))) + std::iter::once(("All readable files", OPEN_EXTS)) + .chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions))) + .chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS))) } /// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`). @@ -291,17 +294,17 @@ fn formats(_: &mut Session, _: &Value) -> Result { // ---------- the file system (none on the web, where commands take and return bytes) ---------- #[cfg(not(target_arch = "wasm32"))] -fn read_file(path: &str) -> Result> { +pub(crate) fn read_file(path: &str) -> Result> { std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(not(target_arch = "wasm32"))] -fn write_file(path: &str, bytes: &[u8]) -> Result<()> { +pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(not(target_arch = "wasm32"))] -fn create_dir(path: &str) -> Result<()> { +pub(crate) fn create_dir(path: &str) -> Result<()> { std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -311,17 +314,17 @@ fn no_fs(path: &str) -> EngineError { } #[cfg(target_arch = "wasm32")] -fn read_file(path: &str) -> Result> { +pub(crate) fn read_file(path: &str) -> Result> { Err(no_fs(path)) } #[cfg(target_arch = "wasm32")] -fn write_file(path: &str, _: &[u8]) -> Result<()> { +pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> { Err(no_fs(path)) } #[cfg(target_arch = "wasm32")] -fn create_dir(path: &str) -> Result<()> { +pub(crate) fn create_dir(path: &str) -> Result<()> { Err(no_fs(path)) } diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index ea31f52b..ec67c7c6 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -148,7 +148,7 @@ fn open_exts_cover_every_readable_format() { } let filters: Vec<_> = open_filters().collect(); assert_eq!(filters[0], ("All readable files", OPEN_EXTS)); - assert_eq!(filters.len(), 1 + FORMATS.iter().filter(|f| f.read).count()); + assert_eq!(filters.len(), 2 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries"); } #[test] diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 11d629b6..b3b44cec 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -1,13 +1,17 @@ //! Swatch libraries (Window → Swatch Libraries, the Swatches panel's library button): read-only //! sets of swatches the library panel shows. The built-in libraries are computed in -//! [`vectorcraft_color::libraries`]; `swatch.library.add` copies swatches into the document. +//! [`vectorcraft_color::libraries`]; User Defined ones are the library files in the user library +//! folder ([`Libraries`]), and Other Library… loads more from files. `swatch.library.add` copies +//! swatches into the document; `swatch.library.save` writes the document's ([`palette_io`]). use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::libraries::{SWATCH_LIBRARIES, builtin_library}; -use vectorcraft_color::{Swatch, SwatchGroup, SwatchLibrary, default_swatches}; +use vectorcraft_color::palette_io::{self, PaletteFormat}; +use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches}; +use super::fileio::{create_dir, read_file, write_file}; use super::menucmds::squash; use super::swatch::{map_default_paints, str_list, swatch_json}; use super::*; @@ -19,7 +23,7 @@ pub fn specs() -> Vec { "Swatch Libraries", [], None, - "{} → {libraries: [{id, name, category: \"builtIn\", count}]} every library the library panel opens, in menu order", + "{} → {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", always, list ), @@ -50,27 +54,126 @@ pub fn specs() -> Vec { has_doc, reset_defaults ), + cmd!( + "swatch.library.save", + "Save Swatch Library…", + ["Window", "Swatches"], + None, + "{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}", + has_doc, + save + ), + cmd!( + "swatch.library.load", + "Other Library…", + ["Window", "Swatch Libraries"], + None, + "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches or .gpl library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}", + always, + load + ), ] } +/// The libraries beyond the built-in ones: User Defined (the library files in the user library +/// folder, as last scanned) and loaded ones (`swatch.library.load`). +#[derive(Clone, Debug, Default)] +pub struct Libraries { + /// The user library folder. The desktop app sets it; without one (the web, headless sessions) + /// there are no User Defined libraries. + user_dir: Option, + extra: Vec, +} + +#[derive(Clone, Debug)] +struct Extra { + info: LibraryInfo, + path: Option, + lib: Arc, +} + +/// The extensions of library files [`palette_io::read`] reads. +pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl"]; + +impl Libraries { + pub fn user_dir(&self) -> Option<&str> { + self.user_dir.as_deref() + } + + /// Set the user library folder and list its libraries. + pub fn set_user_dir(&mut self, dir: Option) { + self.user_dir = dir; + self.rescan(); + } + + /// Re-read the library files of the user library folder (unreadable ones are skipped). + pub fn rescan(&mut self) { + self.extra.retain(|e| e.info.category != "user"); + let Some(dir) = self.user_dir.clone() else { return }; + for path in library_files(&dir) { + let file = file_name(&path); + let Some(lib) = read_file(&path).ok().and_then(|b| palette_io::read(&String::from_utf8_lossy(&b), stem(&file)).ok()) else { continue }; + let info = LibraryInfo { id: format!("user/{file}"), name: lib.name.clone(), category: "user" }; + self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) }); + } + } + + /// Add (or replace, by id) a loaded library. + fn put(&mut self, e: Extra) { + self.extra.retain(|x| x.info.id != e.info.id); + self.extra.push(e); + } +} + +/// The library files in folder `dir`, sorted by name. +#[cfg(not(target_arch = "wasm32"))] +fn library_files(dir: &str) -> Vec { + let Ok(rd) = std::fs::read_dir(dir) else { return vec![] }; + let mut files: Vec = rd + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| LIBRARY_EXTS.contains(&x.to_string_lossy().to_ascii_lowercase().as_str()))) + .map(|p| p.to_string_lossy().to_string()) + .collect(); + files.sort_by_key(|f| f.to_lowercase()); + files +} + +#[cfg(target_arch = "wasm32")] +fn library_files(_: &str) -> Vec { + vec![] +} + +fn file_name(path: &str) -> String { + std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) +} + +fn stem(name: &str) -> &str { + name.rsplit_once('.').map_or(name, |(s, _)| s) +} + /// A library as the menus and the library panel list it. #[derive(Clone, Debug, PartialEq)] pub struct LibraryInfo { pub id: String, pub name: String, - /// "builtIn". + /// "builtIn", "user" or "loaded". pub category: &'static str, } -/// Every library, in menu order. -pub fn libraries(_s: &Session) -> Vec { - SWATCH_LIBRARIES.iter().map(|b| LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" }).collect() +/// Every library, in menu order: built-in, 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() } /// Library `key` (an id, or a name in any case) with its info. pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc)> { - let info = libraries(s).into_iter().find(|l| l.id == key).or_else(|| libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key)))?; - let lib = builtin_library(&info.id)?; + 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)) } @@ -80,14 +183,107 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc< } fn list(s: &mut Session, _: &Value) -> Result { + s.swatch_libraries.rescan(); + let path = |id: &str| s.swatch_libraries.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.clone()); let libs: Vec = libraries(s) .into_iter() .map(|l| { let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); - json!({"id": l.id, "name": l.name, "category": l.category, "count": count}) + json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": path(&l.id)}) }) .collect(); - Ok(json!({ "libraries": libs })) + Ok(json!({ "libraries": libs, "userFolder": s.swatch_libraries.user_dir() })) +} + +/// The document's swatches `names` (swatches or colour groups; empty: all) as a library named +/// `name`, without None and patterns. +fn document_library(d: &vectorcraft_doc::Document, names: &[String], name: String, cmd: &str) -> Result { + if let Some(n) = names.iter().find(|n| !d.swatch_name_taken(n)) { + return Err(bad(cmd, format!("no swatch or colour group `{n}`"))); + } + let want = |n: &str| names.is_empty() || names.iter().any(|x| x == n); + let savable = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_)); + let swatches = d.swatches.iter().filter(savable).filter(|w| want(&w.name)).cloned().collect(); + let groups = d + .swatch_groups + .iter() + .filter_map(|g| { + let all = want(&g.name); + let swatches: Vec = g.swatches.iter().filter(savable).filter(|w| all || want(&w.name)).cloned().collect(); + (!swatches.is_empty() || (all && !names.is_empty())).then(|| SwatchGroup { name: g.name.clone(), swatches }) + }) + .collect(); + Ok(SwatchLibrary { name, swatches, groups }) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.library.save"; + let path = str_param(p, "path"); + let format = match str_param(p, "format").or_else(|| path.map(|p| p.rsplit_once('.').map_or("", |(_, e)| e))).filter(|f| !f.is_empty()) { + Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl or css)")))?, + None => PaletteFormat::Native, + }; + let st = s.doc()?; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string); + let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?; + let (count, text) = (lib.len(), palette_io::write(&lib, format)); + let mut out = json!({"format": format.id(), "count": count}); + if bool_or(p, "user", false) { + let dir = s + .swatch_libraries + .user_dir() + .ok_or_else(|| bad(C, "no user library folder here (save with a path, or without one to get the data)"))? + .to_string(); + // A file name from the library's name, without characters file systems reject. + let base: String = lib.name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect(); + let file = format!("{}.{}", base.trim_matches(['.', ' ']), format.id()); + let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string(); + create_dir(&dir)?; + write_file(&path, text.as_bytes())?; + s.swatch_libraries.rescan(); + out["path"] = json!(path); + out["library"] = json!(format!("user/{file}")); + } else if let Some(path) = path { + write_file(path, text.as_bytes())?; + out["path"] = json!(path); + } else { + out["data"] = json!(text); + } + Ok(out) +} + +fn load(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.library.load"; + let path = str_param(p, "path"); + let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) { + (Some(path), ..) => read_file(path)?, + (None, Some(text), _) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?, + _ => return Err(bad(C, "give `path`, `data` or `dataBase64`")), + }; + let file = str_param(p, "name").map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into()); + // A library file, or a document whose swatches become the library. + let text = std::str::from_utf8(&bytes).ok().filter(|t| palette_io::sniff(t)); + let lib = match text { + Some(t) => palette_io::read(t, stem(&file)).map_err(|e| bad(C, e))?, + None => { + let doc = super::fileio::load(&file, &bytes).map_err(|e| bad(C, e.to_string()))?.doc; + document_library(&doc, &[], stem(&file).to_string(), C)? + } + }; + // A file of the user library folder is its User Defined library. + s.swatch_libraries.rescan(); + let user = path.and_then(|p| s.swatch_libraries.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p))); + let info = match user { + Some(e) => e.info.clone(), + None => { + let id = format!("loaded/{}", path.unwrap_or(&file)); + let info = LibraryInfo { id, name: lib.name.clone(), category: "loaded" }; + s.swatch_libraries.put(Extra { info: info.clone(), path: path.map(str::to_string), lib: Arc::new(lib.clone()) }); + info + } + }; + Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) } fn get(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 55cd5d75..c51424d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -462,6 +462,8 @@ pub struct Session { pub(crate) pending_paint: Option, /// What the Eyedropper copies (`eyedropper.setOptions`); not saved. pub eyedropper: EyedropperOptions, + /// User Defined and loaded swatch libraries (Window → Swatch Libraries); not saved. + pub swatch_libraries: cmd::swatchlib::Libraries, } impl Default for Session { @@ -494,6 +496,7 @@ impl Session { recent_colors: vec![], pending_paint: None, eyedropper: Default::default(), + swatch_libraries: Default::default(), } } diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests_swatchlib.rs index c30a79e7..d1bbfd11 100644 --- a/crates/engine/src/tests_swatchlib.rs +++ b/crates/engine/src/tests_swatchlib.rs @@ -129,3 +129,70 @@ fn reset_defaults_restores_missing_swatches_or_replaces_them() { assert_eq!(fill, Paint::solid(Color::from_hex("#abcdef").unwrap())); assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#abcdef").unwrap()), "the default fill is unlinked too"); } + +/// A fresh scratch folder for one test. +fn temp_dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-swatchlib-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d +} + +#[test] +fn save_and_load_round_trip_keeps_cmyk_spot_and_groups() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 1.0, "m": 0.5, "y": 0.0, "k": 0.2}, "spot": true})); + run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#123456", "#abcdef"]})); + let r = run(&mut s, "swatch.library.save", json!({"names": ["Ink", "Brand", "Sunset"], "name": "Brand Kit"})); + assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("vcswatches"), Some(4))); + let data = r["data"].as_str().unwrap().to_string(); + let r = run(&mut s, "swatch.library.load", json!({"data": data, "name": "kit.vcswatches"})); + assert_eq!((r["name"].as_str(), r["count"].as_u64()), (Some("Brand Kit"), Some(4))); + let id = r["library"].as_str().unwrap().to_string(); + let (_, lib) = cmd::swatchlib::library(&s, &id).unwrap(); + let ink = lib.swatch("Ink").unwrap(); + assert!(ink.spot && ink.global); + assert_eq!(ink.paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2))); + assert_eq!(lib.groups[0].name, "Brand"); + assert_eq!(lib.groups[0].swatches.len(), 2); + assert!(matches!(lib.swatch("Sunset").unwrap().paint, Paint::Gradient(_))); + // Listed as loaded; adding from it works like any library. + let list = run(&mut s, "swatch.library.list", json!({})); + assert!(list["libraries"].as_array().unwrap().iter().any(|l| l["id"] == id.as_str() && l["category"] == "loaded")); + assert!(s.execute("swatch.library.save", &json!({"names": ["Nope"]})).is_err()); + assert!(s.execute("swatch.library.save", &json!({"user": true})).is_err(), "no user folder in a headless session"); +} + +#[test] +fn libraries_saved_in_the_user_folder_are_user_defined() { + let dir = temp_dir("user"); + let mut s = session(); + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let r = run(&mut s, "swatch.library.save", json!({"user": true, "name": "My: Greys", "format": "gpl", "names": ["Grays"]})); + assert_eq!(r["library"], "user/My- Greys.gpl"); + let path = r["path"].as_str().unwrap().to_string(); + assert!(std::fs::read_to_string(&path).unwrap().starts_with("GIMP Palette\nName: My: Greys\n")); + let list = run(&mut s, "swatch.library.list", json!({})); + let user: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "user").collect(); + assert_eq!(user.len(), 1); + assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("My: Greys"), Some(9))); + // Opening a file of the user folder gives its User Defined library. + let r = run(&mut s, "swatch.library.load", json!({ "path": path })); + assert_eq!(r["library"], "user/My- Greys.gpl"); + // CSS by extension. + let css = dir.join("out.css").to_string_lossy().to_string(); + run(&mut s, "swatch.library.save", json!({ "path": css })); + assert!(std::fs::read_to_string(&css).unwrap().contains(" --white: #ffffff;")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn another_documents_swatches_load_as_a_library() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Signal", "color": "#ff3300"})); + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "swatch.library.load", json!({"dataBase64": b64, "name": "Poster.vectorcraft"})); + assert_eq!(r["name"], "Poster"); + let (_, lib) = cmd::swatchlib::library(&s, r["library"].as_str().unwrap()).unwrap(); + 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()); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index cb08277d..6a41958b 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -27,6 +27,7 @@ mod path_ops; mod recolor; pub mod saturate; mod save_changes; +pub mod save_swatch_library; mod shapes; pub mod swatch_options; mod tools; @@ -150,6 +151,7 @@ registry! { ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC, ColorBalance: [color_balance::KIND] => color_balance::SPEC, Saturate: [saturate::KIND] => saturate::SPEC, + SaveSwatchLibrary: [save_swatch_library::KIND] => save_swatch_library::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/save_swatch_library.rs b/crates/ui-egui/src/dialogs/save_swatch_library.rs new file mode 100644 index 00000000..62a0ec21 --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_swatch_library.rs @@ -0,0 +1,97 @@ +//! Save Swatch Library: a name, a file format and where it goes: the user library folder (listed +//! under Window → Swatch Libraries → User Defined) or a file picked in a save dialog (a download +//! on the web). OK runs `swatch.library.save`. +//! +//! Fields: `name`, `format` (`vcswatches`, `gpl` or `css`), `user` (save to the user library +//! folder), `selectedOnly` and `names` (the swatches selected in the Swatches panel), `__user` +//! (there is a user library folder). + +use serde_json::{Value, json}; +use vectorcraft_color::palette_io::PaletteFormat; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Save Swatch Library. +pub const KIND: &str = "saveSwatchLibrary"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Save Swatch Library".into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM }; + +/// Open Save Swatch Library for the document's swatches (`names`: the ones selected in the panel). +pub fn open(app: &mut VectorcraftApp, names: Vec) -> Result { + let st = app.session.active().ok_or("no document open")?; + let title = st.title(); + let name = title.rsplit_once('.').map_or(title.as_str(), |(s, _)| s).to_string(); + let user = app.session.swatch_libraries.user_dir().is_some(); + let fields = json!({ + "name": name, "format": PaletteFormat::Native.id(), "user": user, "__user": user, + "selectedOnly": false, "names": names, + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn format_of(d: &Dialog) -> PaletteFormat { + PaletteFormat::parse(&d.str("format")).unwrap_or(PaletteFormat::Native) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let labels = PaletteFormat::ALL.map(PaletteFormat::label); + let names = d.fields.get("names").and_then(Value::as_array).map_or(0, Vec::len); + let set = |d: &mut Dialog, key: &str, v: Value| { + d.fields.insert(key.into(), v); + }; + grid(ui, |ui| { + label(ui, "Name:"); + form::text(ui, d, "name", 220.0); + ui.end_row(); + label(ui, "Format:"); + if let Some(i) = widgets::dropdown(ui, "library-format", format_of(d).label(), &labels, 230.0) { + set(d, "format", json!(PaletteFormat::ALL[i].id())); + } + ui.end_row(); + label(ui, "Save To:"); + let (user, has_user) = (d.bool("user"), d.bool("__user")); + if widgets::radio(ui, "User Defined Libraries", user, has_user) { + set(d, "user", json!(true)); + } + ui.end_row(); + ui.label(""); + if widgets::radio(ui, "A File…", !user, true) { + set(d, "user", json!(false)); + } + ui.end_row(); + ui.label(""); + let only = d.bool("selectedOnly"); + if widgets::check(ui, &format!("Selected Swatches Only ({names})"), only, names > 0) { + set(d, "selectedOnly", json!(!only)); + } + ui.end_row(); + }); + if format_of(d) == PaletteFormat::Gpl { + widgets::dim_label(ui, "GPL palettes keep solid colours only, as RGB."); + } + false +} + +/// `swatch.library.save` parameters from the fields. +fn params(d: &Dialog) -> Value { + let mut p = json!({"name": d.str("name"), "format": format_of(d).id(), "user": d.bool("user") && d.bool("__user")}); + if d.bool("selectedOnly") { + p["names"] = d.fields.get("names").cloned().unwrap_or_else(|| json!([])); + } + p +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let p = params(d); + if p["user"] == json!(true) { + let r = run_and_close(app, "swatch.library.save", p)?; + app.status(format!("Saved to User Defined: {}", d.str("name"))); + return Ok(r); + } + app.ui.dialog = None; + crate::io::save_command_output(app, "swatch.library.save", format_of(d).id(), p).map(|path| json!({ "path": path })) +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 8dde2d9f..5356ab9e 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -8,8 +8,16 @@ use vectorcraft_geom::Affine; use crate::VectorcraftApp; -/// Open bytes of any readable format as a new document (templates open untitled). +/// Open bytes of any readable format as a new document (templates open untitled); swatch library +/// files open in the library panel. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { + if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&fileio::extension(name).as_str()) { + let p = match path { + Some(path) => serde_json::json!({ "path": path }), + None => serde_json::json!({"name": name, "data": String::from_utf8_lossy(bytes)}), + }; + return crate::panels::swatches::load_library(app, p).map(|_| ()); + } let r = fileio::open_bytes(&mut app.session, name, bytes, path).map_err(|e| e.to_string())?; app.sync_views(); if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { @@ -113,7 +121,9 @@ pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut pa } } let v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; - let bytes = v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode).ok_or("no data")?; + // Binary output comes as base64, text (swatch libraries) as is. + let bytes = v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode).or_else(|| v["data"].as_str().map(|t| t.as_bytes().to_vec())); + let bytes = bytes.ok_or("no data")?; write_out(app, &path, &bytes)?; app.status(format!("Saved {path}")); Ok(path) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ade9d4e2..a8e6a6dc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -225,6 +225,28 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{library: id or name (see swatch.library.list) | null (close)} open the read-only library panel on a swatch library (UI state `library_panel`); clicking a swatch there runs swatch.library.add with apply → {open, name, count}", ), + ( + "window.swatchLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load a .vcswatches or .gpl library, or another document's swatches (engine: swatch.library.load), and open it in the library panel", + ), + ( + "ui.saveSwatchLibrary", + "Save Swatch Library…", + "", + "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save", + ), + ("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"), + ("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"), + ("window.userSwatchLibrary3", "User Swatch Library 3", "", "{} open the 3. User Defined swatch library"), + ("window.userSwatchLibrary4", "User Swatch Library 4", "", "{} open the 4. User Defined swatch library"), + ("window.userSwatchLibrary5", "User Swatch Library 5", "", "{} open the 5. User Defined swatch library"), + ("window.userSwatchLibrary6", "User Swatch Library 6", "", "{} open the 6. User Defined swatch library"), + ("window.userSwatchLibrary7", "User Swatch Library 7", "", "{} open the 7. User Defined swatch library"), + ("window.userSwatchLibrary8", "User Swatch Library 8", "", "{} open the 8. User Defined swatch library"), + ("window.userSwatchLibrary9", "User Swatch Library 9", "", "{} open the 9. User Defined swatch library"), + ("window.userSwatchLibrary10", "User Swatch Library 10", "", "{} open the 10. User Defined swatch library"), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -559,6 +581,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::swatches::open_library(app, p), + "window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")), + "ui.saveSwatchLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_swatch_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::swatches::USER_SLOT) => match crate::panels::swatches::user_library(app, id) { + Some(l) => crate::panels::swatches::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, _ => return None, }; Some(r) @@ -651,10 +682,19 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { } "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), + id if id.starts_with(crate::panels::swatches::USER_SLOT) => { + crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } _ => label.into(), } } +/// Menu slots that are left out while they have nothing to show (unused saved views, User +/// Defined swatch libraries). +fn hidden_when_disabled(id: &str) -> bool { + id.starts_with("view.goto") || id.starts_with(crate::panels::swatches::USER_SLOT) +} + /// File → Open Recent Files slots. const RECENT_IDS: [&str; 10] = [ "file.openRecent1", @@ -724,6 +764,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), "ui.graphicStyleOptions" => app.session.active().is_some(), "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.saveSwatchLibrary" => app.session.active().is_some(), + id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(), _ => true, } } @@ -1385,7 +1427,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked Item::Cmd(label, id, p) => { let en = enabled(app, id); // Unused saved-view slots are hidden (Illustrator lists only the saved views). - if !en && id.starts_with("view.goto") { + if !en && hidden_when_disabled(id) { continue; } let label = dynamic_label(app, id, label); @@ -1564,7 +1606,7 @@ pub fn menu_entries(app: &VectorcraftApp) -> Vec { fn walk(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Vec) { for it in items { match it { - Item::Cmd(_, id, _) if id.starts_with("view.goto") && !enabled(app, id) => {} + Item::Cmd(_, id, _) if hidden_when_disabled(id) && !enabled(app, id) => {} Item::Cmd(l, id, p) => out.push(MenuEntry { path: path.clone(), label: dynamic_label(app, id, l), diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 452d2a64..c22845b4 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -446,4 +446,78 @@ mod tests { let defaults = crate::menus::menu_entries(&app).into_iter().any(|e| e.command.as_deref() == Some("swatch.resetDefaults")); assert!(defaults, "Default Swatches is listed"); } + + #[test] + fn save_dialog_writes_user_libraries_that_the_window_menu_lists() { + let dir = std::env::temp_dir().join(format!("vc-library-panel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut app = app(); + app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let user_items = |app: &VectorcraftApp| -> Vec<(String, String)> { + crate::menus::menu_entries(app) + .into_iter() + .filter(|e| e.path == ["Window", "Swatch Libraries", "User Defined"]) + .map(|e| (e.label, e.command.unwrap_or_default())) + .collect() + }; + assert!(user_items(&app).is_empty(), "empty slots are hidden"); + app.run("ui.saveSwatchLibrary", json!({"names": ["Brights"]})).unwrap(); + let d = app.ui.dialog.as_mut().expect("Save Swatch Library opens"); + assert_eq!((d.kind.as_str(), d.bool("user")), (crate::dialogs::save_swatch_library::KIND, true)); + d.fields.insert("name".into(), json!("Loud")); + d.fields.insert("selectedOnly".into(), json!(true)); + let ctx = context(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), ..Default::default() }; + ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx())).textures_delta.clear(); + assert!(app.ui.dialog.is_some(), "the dialog draws and stays open"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(user_items(&app), [("Loud".to_string(), "window.userSwatchLibrary1".to_string())]); + app.run("window.userSwatchLibrary1", json!({})).unwrap(); + assert_eq!(app.ui.library_panel.as_ref().map(|o| o.id.as_str()), Some("user/Loud.vcswatches")); + let (_, lib) = SwatchLibraries::get(&app, "user/Loud.vcswatches").unwrap(); + assert_eq!((lib.groups.len(), lib.len()), (1, 5), "only the selected group"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn saving_to_a_file_and_opening_a_palette_file() { + let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); + let w = written.clone(); + let services = crate::Services { + pick_save: Some(Box::new(|name: &str| Some(format!("/tmp/{name}")))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.saveSwatchLibrary", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(!d.bool("user") && !d.bool("__user"), "no user folder: a file"); + d.fields.insert("format".into(), json!("gpl")); + crate::dialogs::confirm(&mut app).unwrap(); + let (path, bytes) = written.borrow()[0].clone(); + assert!(path.ends_with(".gpl"), "{path}"); + let text = String::from_utf8(bytes).unwrap(); + assert!( + text.starts_with( + "GIMP Palette +" + ) && text.contains( + " Red +" + ), + "{text}" + ); + // Opening a palette file opens it in the library panel. + crate::io::open_bytes(&mut app, "Saved.gpl", text.as_bytes(), None).unwrap(); + let open = app.ui.library_panel.clone().unwrap(); + assert!(open.id.starts_with("loaded/"), "{open:?}"); + assert_eq!(app.session.documents().len(), 1, "not opened as a document"); + let other = SwatchLibraries::list(&app).into_iter().find(|l| l.id == open.id).unwrap(); + assert_eq!(other.submenu, Some("Other Libraries")); + } } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 7d66e493..2bc86618 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -890,7 +890,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { menu_item(ui, "Spot Colors…", false, false); ui.separator(); ui.menu_button("Open Swatch Library", |ui| library_panel::library_menu::(app, ui)); - menu_item(ui, "Save Swatch Library…", false, false); + if menu_item(ui, "Save Swatch Library…", true, false) { + app.run("ui.saveSwatchLibrary", json!({ "names": deletable(&selected) })).ok(); + } } /// Swatch libraries in the library panel (engine: `swatch.library.*`). @@ -905,7 +907,12 @@ impl LibraryKind for SwatchLibraries { type Item = Swatch; fn list(app: &VectorcraftApp) -> Vec { - swatchlib::libraries(&app.session).into_iter().map(|l| LibraryRef { id: l.id, name: l.name, submenu: None }).collect() + let submenu = |category: &str| match category { + "user" => Some("User Defined"), + "loaded" => Some("Other Libraries"), + _ => None, + }; + swatchlib::libraries(&app.session).into_iter().map(|l| LibraryRef { submenu: submenu(l.category), id: l.id, name: l.name }).collect() } fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib)) @@ -953,6 +960,18 @@ impl LibraryKind for SwatchLibraries { } ui.separator(); } + fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.separator(); + if menu_item(ui, "Other Library…", true, false) + && let Err(e) = other_library(app, None) + { + app.status(e); + } + if menu_item(ui, "Save Swatch Library…", app.session.active().is_some(), false) { + let names = deletable(&selection(app, ui)); + app.run("ui.saveSwatchLibrary", json!({ "names": names })).ok(); + } + } } fn add_from_library(app: &mut VectorcraftApp, params: Value) { @@ -974,11 +993,57 @@ pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result Result { + let r = app.run("swatch.library.load", params)?; + open_library(app, &json!({ "library": r["library"] })) +} + +/// Other Library…: load the library (or document) at `path`, else one picked in an open dialog +/// (on the web the picked file arrives later and opens through [`crate::io::open_bytes`]). +pub(crate) fn other_library(app: &mut VectorcraftApp, path: Option) -> Result { + if path.is_none() + && let Some(f) = app.services.open_async.as_mut() + { + f(); + return Ok(Value::Null); + } + let path = path.or_else(|| app.services.pick_open.as_mut().and_then(|f| f())).ok_or("cancelled")?; + load_library(app, json!({ "path": path })) +} + +/// The id prefix of the Window → Swatch Libraries → User Defined slots. +pub(crate) const USER_SLOT: &str = "window.userSwatchLibrary"; + +/// The User Defined library slot `id` (`window.userSwatchLibrary3`) stands for. +pub(crate) fn user_library(app: &VectorcraftApp, id: &str) -> Option { + let n: usize = id.strip_prefix(USER_SLOT)?.parse().ok()?; + swatchlib::libraries(&app.session).into_iter().filter(|l| l.category == "user").nth(n.checked_sub(1)?) +} + /// Window → Swatch Libraries. pub(crate) fn window_menu() -> Vec { + const SLOTS: [&str; 10] = [ + "window.userSwatchLibrary1", + "window.userSwatchLibrary2", + "window.userSwatchLibrary3", + "window.userSwatchLibrary4", + "window.userSwatchLibrary5", + "window.userSwatchLibrary6", + "window.userSwatchLibrary7", + "window.userSwatchLibrary8", + "window.userSwatchLibrary9", + "window.userSwatchLibrary10", + ]; 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([Item::Sep, Item::Todo("User Defined", ""), Item::Sep, Item::Todo("Other Library…", "")]); + items.extend([ + Item::Sep, + Item::Sub("User Defined", SLOTS.iter().map(|id| Item::Cmd("User Library", id, Value::Null)).collect()), + Item::Sep, + Item::Cmd("Other Library…", "window.swatchLibrary.other", Value::Null), + Item::Cmd("Save Swatch Library…", "ui.saveSwatchLibrary", Value::Null), + ]); items } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1ca07f8f..aa93fc56 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -83,3 +83,7 @@ panel on a swatch library (`ui.inspect` shows it as `library_panel: {kind, id}`; Clicking a swatch there runs `swatch.library.add {library, names: [name], apply}` (the active proxy, or the other one with Alt), so one undo step adds and applies it; Shift/Cmd-clicks select swatches and colour groups for Add to Swatches. +`window.swatchLibrary.other {path?}` loads a library file (or another document's swatches) and opens it there; +opening a `.vcswatches` or `.gpl` file with `app.open` does the same. `ui.saveSwatchLibrary {names?}` opens the +`saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`css`, `user`: save to the user library +folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.save` (to a file it asks for a path). diff --git a/docs/mcp.md b/docs/mcp.md index a7bc1d82..f92cb9fc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -319,3 +319,8 @@ swatch in the library panel does). `swatch.resetDefaults {replace?}` brings back {"name":"run_command","arguments":{"command":"swatch.library.get","params":{"library":"earth-tones"}}} {"name":"run_command","arguments":{"command":"swatch.library.add","params":{"library":"earth-tones","names":["Clay"]}}} ``` +`swatch.library.save {path?, format?: "vcswatches"|"gpl"|"css", names?, name?, user?}` writes the document's swatches +as a library (`.vcswatches` keeps colour models, global, spot, gradients and colour groups; `.gpl` is 8-bit RGB; +CSS writes custom properties); without `path` it returns `{data}`, and `user: true` saves into the user library +folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? | +dataBase64?, name?}` loads a `.vcswatches` or `.gpl` file, or another document's swatches, as a library to add from. From 3a79e6d9229cf193c5c47022d63769f33caeb990 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:46:45 +0700 Subject: [PATCH 3/3] 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.