From e7f63281884bda48b6d1cec975c6132486fd4a2a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:19:18 +0700 Subject: [PATCH] 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"]}}} ```