Merge pull request #27 from storytold/pr/m3-swatch-libraries
M3: swatch libraries — library panel, generated libraries, save and load, gradient libraries (M3.56–M3.58)
This commit is contained in:
commit
09778f236c
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ workspace = true
|
|||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
moxcms = "0.8"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@ pub mod blend;
|
|||
pub mod cms;
|
||||
pub mod gradient;
|
||||
pub mod harmony;
|
||||
pub mod libraries;
|
||||
pub mod palette_io;
|
||||
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};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,532 @@
|
|||
//! 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<Swatch>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<SwatchGroup>,
|
||||
}
|
||||
|
||||
impl SwatchLibrary {
|
||||
/// Every swatch: the ungrouped ones first, then each group's in order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &Swatch> {
|
||||
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::<usize>()
|
||||
}
|
||||
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<Swatch>, Vec<SwatchGroup>),
|
||||
}
|
||||
|
||||
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),
|
||||
];
|
||||
|
||||
/// The built-in gradient libraries (Window → Swatch Libraries → Gradients), in menu order.
|
||||
pub const GRADIENT_LIBRARIES: &[BuiltinLibrary] = &[
|
||||
lib!("spectrum-gradients", "Spectrum Gradients", spectrum_gradients),
|
||||
lib!("sky-gradients", "Sky Gradients", sky_gradients),
|
||||
lib!("neutral-gradients", "Neutral Gradients", neutral_gradients),
|
||||
lib!("duotone-gradients", "Duotone Gradients", duotone_gradients),
|
||||
lib!("radial-glows", "Radial Glows", radial_glows),
|
||||
];
|
||||
|
||||
/// Every built-in library: the swatch libraries, then the gradient libraries.
|
||||
fn builtins() -> impl Iterator<Item = &'static BuiltinLibrary> {
|
||||
SWATCH_LIBRARIES.iter().chain(GRADIENT_LIBRARIES)
|
||||
}
|
||||
|
||||
/// Built-in library `id` (swatch or gradient library), computed once.
|
||||
pub fn builtin_library(id: &str) -> Option<Arc<SwatchLibrary>> {
|
||||
static LIBS: OnceLock<Vec<Arc<SwatchLibrary>>> = OnceLock::new();
|
||||
let libs = LIBS.get_or_init(|| {
|
||||
builtins()
|
||||
.map(|b| {
|
||||
let (swatches, groups) = (b.make)();
|
||||
Arc::new(SwatchLibrary { name: b.name.into(), swatches, groups })
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
builtins().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<String>, color: Color) -> Swatch {
|
||||
Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false }
|
||||
}
|
||||
|
||||
fn group(name: &str, swatches: Vec<Swatch>) -> 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<Swatch>, Vec<SwatchGroup>) {
|
||||
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<Swatch>, Vec<SwatchGroup>) {
|
||||
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<Swatch>, Vec<SwatchGroup>) {
|
||||
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<Swatch>, Vec<SwatchGroup>) {
|
||||
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<Swatch>, Vec<SwatchGroup>) {
|
||||
(vec![], vec![hue_row("Pastel", 0.91, 0.055), hue_row("Soft", 0.84, 0.085)])
|
||||
}
|
||||
|
||||
fn brights() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
// 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<Swatch>, Vec<SwatchGroup>) {
|
||||
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)];
|
||||
|
||||
/// A linear gradient through evenly spaced, opaque `colors`.
|
||||
fn even(name: &str, kind: GradientKind, colors: &[Color]) -> Swatch {
|
||||
let last = (colors.len() - 1).max(1) as f32;
|
||||
let stops: Vec<(f32, Color, f32)> = colors.iter().enumerate().map(|(i, c)| (i as f32 / last, *c, 1.0)).collect();
|
||||
gradient(name, kind, &stops)
|
||||
}
|
||||
|
||||
/// OKLCH `(l, c, h°)` colours.
|
||||
fn lch(points: &[(f64, f64, f64)]) -> Vec<Color> {
|
||||
points.iter().map(|&(l, c, h)| oklch(l, c, h)).collect()
|
||||
}
|
||||
|
||||
/// Full-hue sweeps and hue-neighbour blends.
|
||||
fn spectrum_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let wheel: Vec<Color> = (0..=6).map(|i| oklch(0.72, 0.4, 25.0 + i as f64 * 60.0)).collect();
|
||||
let mut s = vec![
|
||||
even("Spectrum", GradientKind::Linear, &wheel),
|
||||
even("Soft Spectrum", GradientKind::Linear, &lch(&[(0.88, 0.08, 25.0), (0.9, 0.08, 145.0), (0.86, 0.08, 265.0), (0.88, 0.08, 25.0)])),
|
||||
];
|
||||
// Each hue to the one two steps round the wheel.
|
||||
s.extend((0..HUES.len()).map(|i| {
|
||||
let ((a, ha), (b, hb)) = (HUES[i], HUES[(i + 2) % HUES.len()]);
|
||||
even(&format!("{a} to {b}"), GradientKind::Linear, &[oklch(0.7, 0.4, ha), oklch(0.7, 0.4, hb)])
|
||||
}));
|
||||
(s, vec![])
|
||||
}
|
||||
|
||||
fn sky_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let skies: [(&str, Anchors); 6] = [
|
||||
("Dawn", &[(0.45, 0.08, 280.0), (0.72, 0.12, 350.0), (0.9, 0.09, 75.0)]),
|
||||
("Noon", &[(0.55, 0.13, 250.0), (0.78, 0.09, 235.0), (0.95, 0.03, 220.0)]),
|
||||
("Sunset", &[(0.35, 0.09, 300.0), (0.62, 0.18, 20.0), (0.85, 0.15, 70.0)]),
|
||||
("Dusk", &[(0.25, 0.07, 275.0), (0.48, 0.1, 310.0), (0.7, 0.1, 30.0)]),
|
||||
("Night", &[(0.15, 0.04, 270.0), (0.3, 0.07, 265.0)]),
|
||||
("Overcast", &[(0.62, 0.015, 240.0), (0.86, 0.01, 230.0)]),
|
||||
];
|
||||
(skies.iter().map(|(n, p)| even(n, GradientKind::Linear, &lch(p))).collect(), vec![])
|
||||
}
|
||||
|
||||
fn neutral_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let (white, black) = (Color::gray(0.0), Color::gray(1.0));
|
||||
let s = vec![
|
||||
even("White to Black", GradientKind::Linear, &[white, black]),
|
||||
even("Light Grays", GradientKind::Linear, &[white, Color::gray(0.4)]),
|
||||
even("Dark Grays", GradientKind::Linear, &[Color::gray(0.6), black]),
|
||||
even("Warm Grays", GradientKind::Linear, &lch(&[(0.95, 0.012, 70.0), (0.3, 0.012, 70.0)])),
|
||||
even("Cool Grays", GradientKind::Linear, &lch(&[(0.95, 0.015, 250.0), (0.3, 0.015, 250.0)])),
|
||||
gradient("Fade to Transparent", GradientKind::Linear, &[(0.0, black, 1.0), (1.0, black, 0.0)]),
|
||||
gradient("White Fade", GradientKind::Linear, &[(0.0, white, 1.0), (1.0, white, 0.0)]),
|
||||
gradient("Vignette", GradientKind::Radial, &[(0.0, black, 0.0), (0.6, black, 0.0), (1.0, black, 0.7)]),
|
||||
];
|
||||
(s, vec![])
|
||||
}
|
||||
|
||||
/// Pairs of hues from opposite sides of the wheel, at different lightness.
|
||||
fn duotone_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let pairs = [
|
||||
("Coral", 0.7, 30.0, "Navy", 0.32, 265.0),
|
||||
("Plum", 0.38, 330.0, "Lime", 0.88, 125.0),
|
||||
("Teal", 0.5, 190.0, "Gold", 0.85, 90.0),
|
||||
("Indigo", 0.3, 280.0, "Peach", 0.86, 55.0),
|
||||
("Forest", 0.35, 150.0, "Rose", 0.8, 0.0),
|
||||
];
|
||||
let s = pairs
|
||||
.iter()
|
||||
.map(|&(a, la, ha, b, lb, hb)| even(&format!("{a} and {b}"), GradientKind::Linear, &[oklch(la, 0.15, ha), oklch(lb, 0.15, hb)]))
|
||||
.collect();
|
||||
(s, vec![])
|
||||
}
|
||||
|
||||
/// A bright centre fading to a transparent edge, per hue.
|
||||
fn radial_glows() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
let s = HUES
|
||||
.iter()
|
||||
.map(|(n, h)| {
|
||||
let c = oklch(0.78, 0.2, *h);
|
||||
gradient(&format!("{n} Glow"), GradientKind::Radial, &[(0.0, Color::WHITE, 1.0), (0.25, c, 1.0), (1.0, c, 0.0)])
|
||||
})
|
||||
.collect();
|
||||
(s, vec![])
|
||||
}
|
||||
|
||||
/// The sequential scales of [`perceptual`]: name and anchors of rising lightness.
|
||||
const SEQUENTIAL: [(&str, Anchors); 5] = [
|
||||
("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]),
|
||||
("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<Swatch>, Vec<SwatchGroup>) {
|
||||
let mut groups: Vec<SwatchGroup> = 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<Swatch>, Vec<SwatchGroup>) {
|
||||
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<Arc<SwatchLibrary>> {
|
||||
builtins().map(|b| builtin_library(b.id).unwrap()).collect()
|
||||
}
|
||||
|
||||
/// Every component of every colour (solid or gradient stop).
|
||||
fn components(lib: &SwatchLibrary) -> Vec<f32> {
|
||||
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 builtins().zip(all()) {
|
||||
assert!(ids.insert(b.id), "duplicate id {}", b.id);
|
||||
assert_eq!(lib.name, b.name);
|
||||
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);
|
||||
// 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<String> = 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<f64> = 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 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 {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Self> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>().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<String> = 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<String> =
|
||||
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<SwatchLibrary, String> {
|
||||
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<String> = 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<u8> = 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Item = (&'static str, &'static [&'static str])> {
|
||||
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<Value> {
|
|||
// ---------- the file system (none on the web, where commands take and return bytes) ----------
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Swatch Options",
|
||||
["Window", "Swatches"],
|
||||
None,
|
||||
"{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global)} edit a swatch in any colour group, as one undo step. Fills, strokes and text linked to a global swatch take its new colour and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only → {name, relinked: paints changed}",
|
||||
"{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour, gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes and text linked to a global swatch take its new colour and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only → {name, relinked: paints changed}",
|
||||
has_doc,
|
||||
swatch_edit
|
||||
),
|
||||
|
|
@ -383,7 +383,7 @@ fn swatch_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
/// A list-of-strings parameter (missing or non-string entries are skipped).
|
||||
fn str_list(p: &Value, key: &str) -> Vec<String> {
|
||||
pub(super) fn str_list(p: &Value, key: &str) -> Vec<String> {
|
||||
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<String>) -> bool) {
|
||||
pub(super) fn map_default_paints(s: &mut Session, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) {
|
||||
if s.in_interaction() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -474,9 +474,21 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if solid.is_none() && (given("color") || given("mode") || bool_or(p, "spot", false)) {
|
||||
return Err(bad(C, "colour, mode and spot apply to solid-colour swatches only"));
|
||||
}
|
||||
// A replacement paint of the swatch's kind (a dropped colour or edited gradient).
|
||||
let paint = match p.get("paint").filter(|v| !v.is_null()) {
|
||||
Some(v) => match (paint_from(s, v)?, &sw.paint) {
|
||||
(Some(Paint::Solid { color, .. }), Paint::Solid { .. }) => Some(Paint::solid(color)),
|
||||
(Some(Paint::Gradient(g)), Paint::Gradient(_)) => {
|
||||
Some(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { geom: None, swatch: None, ..*g })))
|
||||
}
|
||||
(Some(new @ Paint::Pattern { .. }), Paint::Pattern { .. }) => Some(new),
|
||||
_ => return Err(bad(C, format!("`{name}` takes a paint of its own kind (colour, gradient or pattern)"))),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let mut color = match p.get("color").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(color_value(v).ok_or_else(|| bad(C, format!("bad color {v}")))?),
|
||||
None => solid,
|
||||
None => paint.as_ref().and_then(Paint::color).or(solid),
|
||||
};
|
||||
if let (Some(c), Some(m)) = (color, str_param(p, "mode")) {
|
||||
color = Some(convert_to_mode(c, m, C)?);
|
||||
|
|
@ -490,8 +502,13 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
w.name = to.clone();
|
||||
w.spot = spot;
|
||||
w.global = global;
|
||||
if let (Some(c), Paint::Solid { color: wc, .. }) = (color, &mut w.paint) {
|
||||
*wc = c;
|
||||
match (color, &mut w.paint) {
|
||||
(Some(c), Paint::Solid { color: wc, .. }) => *wc = c,
|
||||
(_, wp) => {
|
||||
if let Some(new) = paint {
|
||||
*wp = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(d.map_solid_paints(&mut |c, l| relink.apply(c, l)))
|
||||
})?;
|
||||
|
|
@ -499,7 +516,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,438 @@
|
|||
//! 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`]; 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::{BuiltinLibrary, GRADIENT_LIBRARIES, SWATCH_LIBRARIES, builtin_library};
|
||||
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::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "swatch.library.list",
|
||||
"Swatch Libraries",
|
||||
[],
|
||||
None,
|
||||
"{} → {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
|
||||
),
|
||||
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
|
||||
),
|
||||
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<String>,
|
||||
extra: Vec<Extra>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Extra {
|
||||
info: LibraryInfo,
|
||||
path: Option<String>,
|
||||
lib: Arc<SwatchLibrary>,
|
||||
}
|
||||
|
||||
/// 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<String>) {
|
||||
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<String> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
let mut files: Vec<String> = 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<String> {
|
||||
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", "gradients", "user" or "loaded".
|
||||
pub category: &'static str,
|
||||
}
|
||||
|
||||
/// The built-in libraries with their categories.
|
||||
fn builtins() -> impl Iterator<Item = (&'static str, &'static BuiltinLibrary)> {
|
||||
SWATCH_LIBRARIES.iter().map(|b| ("builtIn", b)).chain(GRADIENT_LIBRARIES.iter().map(|b| ("gradients", b)))
|
||||
}
|
||||
|
||||
fn builtin_info((category, b): (&'static str, &BuiltinLibrary)) -> LibraryInfo {
|
||||
LibraryInfo { id: b.id.into(), name: b.name.into(), category }
|
||||
}
|
||||
|
||||
/// Every library, in menu order: built-in, gradients, User Defined, loaded.
|
||||
pub fn libraries(s: &Session) -> Vec<LibraryInfo> {
|
||||
builtins().map(builtin_info).chain(s.swatch_libraries.extra.iter().map(|e| e.info.clone())).collect()
|
||||
}
|
||||
|
||||
/// Library `key` (an id, or a name in any case) with its info.
|
||||
pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<SwatchLibrary>)> {
|
||||
if let Some(e) = s.swatch_libraries.extra.iter().find(|e| e.info.id == key) {
|
||||
return Some((e.info.clone(), e.lib.clone()));
|
||||
}
|
||||
if let Some(b) = builtins().find(|(_, b)| b.id == key) {
|
||||
return Some((builtin_info(b), builtin_library(key)?));
|
||||
}
|
||||
let id = libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key))?.id;
|
||||
library(s, &id)
|
||||
}
|
||||
|
||||
fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<SwatchLibrary>)> {
|
||||
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<Value> {
|
||||
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<Value> = 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, "path": path(&l.id)})
|
||||
})
|
||||
.collect();
|
||||
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<SwatchLibrary> {
|
||||
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<Swatch> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> =
|
||||
lib.groups.iter().map(|g| json!({"name": g.name, "swatches": g.swatches.iter().map(|w| &w.name).collect::<Vec<_>>()})).collect();
|
||||
Ok(
|
||||
json!({"id": info.id, "name": info.name, "category": info.category, "swatches": ungrouped.chain(grouped).collect::<Vec<_>>(), "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<Vec<(Option<&'a str>, &'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<Value> {
|
||||
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<String> = 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::<Vec<_>>(), "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<Value> {
|
||||
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<String> = vec![];
|
||||
let added = s.edit("Default Swatches", |d, _| {
|
||||
let mut added = vec![];
|
||||
if replace {
|
||||
let before: Vec<String> = 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<String>) -> bool {
|
||||
link.as_ref().is_some_and(|n| !live.contains(n)) && link.take().is_some()
|
||||
}
|
||||
|
|
@ -462,6 +462,8 @@ pub struct Session {
|
|||
pub(crate) pending_paint: Option<Paint>,
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -802,6 +805,8 @@ mod tests_swatchcmds;
|
|||
#[cfg(test)]
|
||||
mod tests_swatches;
|
||||
#[cfg(test)]
|
||||
mod tests_swatchlib;
|
||||
#[cfg(test)]
|
||||
mod tests_textedit;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
//! 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<String> = 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::<Vec<_>>(), 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");
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
#[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()));
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<String>) -> Result<Value, String> {
|
||||
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<Value, String> {
|
||||
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 }))
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
//! Swatch Options: name, colour type (process or spot), Global, colour mode with sliders and hex,
|
||||
//! previewed live on the canvas (Cancel rolls the preview back). Double-clicking a swatch opens it
|
||||
//! for a solid colour; gradients open the Gradient panel and patterns pattern editing ([`open`]).
|
||||
//! for a solid colour; for a gradient it shows the name and the gradient; patterns open pattern
|
||||
//! editing ([`open`]).
|
||||
//!
|
||||
//! Fields: `__swatch` (the swatch being edited), `name`, `spot`, `global`, `mode` (`gray`, `rgb`,
|
||||
//! `hsb`, `cmyk` or `web`), `color` (`"#rrggbb"` or a colour object such as
|
||||
//! `{"model": "cmyk", "c": 0.1, "m": 0.2, "y": 0.3, "k": 0}`) and `preview`.
|
||||
//! `{"model": "cmyk", "c": 0.1, "m": 0.2, "y": 0.3, "k": 0}`) and `preview`; a gradient swatch has
|
||||
//! `name`, `preview` and `__gradient` (the gradient shown) only.
|
||||
|
||||
use egui::vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -51,8 +53,8 @@ fn set_color(d: &mut Dialog, c: Color) {
|
|||
d.fields.insert("color".into(), json!(c));
|
||||
}
|
||||
|
||||
/// Open the editor of swatch `name`: Swatch Options for a colour, the Gradient panel (with the
|
||||
/// gradient applied to the active proxy) for a gradient, pattern editing for a pattern.
|
||||
/// Open the editor of swatch `name`: Swatch Options for a colour or a gradient (its name only),
|
||||
/// pattern editing for a pattern.
|
||||
pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
|
||||
let sw = app.session.active().and_then(|st| st.doc.swatch(name).cloned()).ok_or_else(|| format!("no swatch `{name}`"))?;
|
||||
match &sw.paint {
|
||||
|
|
@ -64,9 +66,9 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
|
|||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Paint::Gradient(_) => {
|
||||
app.run(crate::panels::proxy_cmd(app, false), json!({"swatch": name}))?;
|
||||
app.ui.open_panel = Some("gradient".into());
|
||||
Paint::Gradient(g) => {
|
||||
let fields = json!({"__swatch": name, "name": name, "__gradient": g.gradient, "preview": true});
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Paint::Pattern { pattern, .. } => {
|
||||
|
|
@ -78,8 +80,11 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// `swatch.edit` parameters from the fields.
|
||||
/// `swatch.edit` parameters from the fields (a gradient swatch only renames).
|
||||
fn edit_params(d: &Dialog) -> Value {
|
||||
if d.fields.contains_key("__gradient") {
|
||||
return json!({"name": d.str("__swatch"), "newName": d.str("name")});
|
||||
}
|
||||
json!({
|
||||
"name": d.str("__swatch"),
|
||||
"newName": d.str("name"),
|
||||
|
|
@ -91,7 +96,16 @@ fn edit_params(d: &Dialog) -> Value {
|
|||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let changed = editor(ui, d);
|
||||
let changed = match d.fields.get("__gradient").and_then(|g| serde_json::from_value::<vectorcraft_color::Gradient>(g.clone()).ok()) {
|
||||
Some(g) => {
|
||||
let changed = grid(ui, |ui| name_row(ui, d));
|
||||
ui.add_space(6.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), egui::Sense::hover());
|
||||
widgets::gradient_chip(ui, r, &g);
|
||||
changed
|
||||
}
|
||||
None => editor(ui, d),
|
||||
};
|
||||
ui.add_space(8.0);
|
||||
let mut pv = d.bool("preview");
|
||||
let pv_changed = widgets::check(ui, "Preview", pv, true);
|
||||
|
|
|
|||
|
|
@ -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<String>) -> 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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -219,6 +219,34 @@ 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}",
|
||||
),
|
||||
(
|
||||
"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).
|
||||
|
|
@ -552,6 +580,16 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
crate::dialogs::saturate::open(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"window.swatchLibrary" => 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)
|
||||
|
|
@ -644,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",
|
||||
|
|
@ -717,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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1305,7 +1354,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
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()),
|
||||
],
|
||||
),
|
||||
|
|
@ -1378,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);
|
||||
|
|
@ -1557,7 +1606,7 @@ pub fn menu_entries(app: &VectorcraftApp) -> Vec<MenuEntry> {
|
|||
fn walk(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,532 @@
|
|||
//! 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<LibraryRef>;
|
||||
/// 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<Row<'a, Self::Item>>;
|
||||
/// 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<String>);
|
||||
/// 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<K: LibraryKind>(key: &str) -> String {
|
||||
format!("{}-library-{key}", K::KIND)
|
||||
}
|
||||
|
||||
/// The interaction id of the tile or row of `name`.
|
||||
pub(crate) fn tile_id<K: LibraryKind>(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::<super::swatches::SwatchLibraries>(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<K: LibraryKind>(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::<K>(app, ui, id);
|
||||
});
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
|
||||
ui.set_width(236.0);
|
||||
body::<K>(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<K: LibraryKind>(ctx: &egui::Context, id: &str) -> Vec<String> {
|
||||
let (lib, sel): (String, Vec<String>) = pstate(ctx, &key::<K>("selected"));
|
||||
if lib == id { sel } else { vec![] }
|
||||
}
|
||||
|
||||
fn set_selection<K: LibraryKind>(ctx: &egui::Context, id: &str, sel: Vec<String>) {
|
||||
set_pstate(ctx, &key::<K>("selected"), (id.to_string(), sel));
|
||||
}
|
||||
|
||||
/// Find field, thumbnails or list rows, and the bottom bar.
|
||||
fn body<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K::Lib) {
|
||||
let view: View = pstate(ui.ctx(), &key::<K>("view"));
|
||||
let query = if pstate::<bool>(ui.ctx(), &key::<K>("hide-find")) {
|
||||
String::new()
|
||||
} else {
|
||||
widgets::search_field(ui, egui::Id::new(key::<K>("find")), "Find")
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
let rows = K::rows(lib, &query.trim().to_lowercase());
|
||||
let selected = selection::<K>(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<K::Item>, 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::<K>("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::<K>(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::<K>(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::<K>("anchor"), selected.clone(), &order, &name, m) } else { vec![name.clone()] };
|
||||
set_selection::<K>(ui.ctx(), id, sel);
|
||||
if !only_select {
|
||||
K::click(app, ui, id, &name);
|
||||
}
|
||||
}
|
||||
bottom::<K>(app, ui, id);
|
||||
}
|
||||
|
||||
/// The library `step` places after (or before, when negative) `id` in menu order, wrapping.
|
||||
fn neighbour<K: LibraryKind>(app: &VectorcraftApp, id: &str, step: isize) -> Option<String> {
|
||||
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<K: LibraryKind>(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<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
||||
let selected = selection::<K>(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::<K>(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::<K>(app, id, step)
|
||||
{
|
||||
open::<K>(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<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
||||
let selected = selection::<K>(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::<K>("view"));
|
||||
for (v, label) in View::ALL {
|
||||
if menu_item(ui, label, true, v == view) {
|
||||
set_pstate(ui.ctx(), &key::<K>("view"), v);
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let hidden: bool = pstate(ui.ctx(), &key::<K>("hide-find"));
|
||||
if menu_item(ui, "Show Find Field", true, !hidden) {
|
||||
set_pstate(ui.ctx(), &key::<K>("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::<K>(app, id, step)
|
||||
{
|
||||
open::<K>(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<K: LibraryKind>(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::<K>(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<Event>, 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::<SwatchLibraries>(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::<SwatchLibraries>(&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::<SwatchLibraries>(&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::<SwatchLibraries>(&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::<SwatchLibraries>("view"), View::SmallList);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new(key::<SwatchLibraries>("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::<SwatchLibraries>("#FF0033")).is_some());
|
||||
assert!(ctx.read_response(tile_id::<SwatchLibraries>("#00FF00")).is_none());
|
||||
let first = SWATCH_IDS[0];
|
||||
let last = SwatchLibraries::list(&app).pop().unwrap().id;
|
||||
assert_eq!(last, "radial-glows", "gradient libraries come after the swatch libraries");
|
||||
assert_eq!(neighbour::<SwatchLibraries>(&app, first, -1), Some(last), "wraps around");
|
||||
assert_eq!(neighbour::<SwatchLibraries>(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1]));
|
||||
}
|
||||
|
||||
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<Value> = 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 all = vectorcraft_engine::cmd::swatchlib::libraries(&app.session);
|
||||
let listed = |category: &str| all.iter().filter(|l| l.category == category).map(|l| json!(l.id)).collect::<Vec<_>>();
|
||||
assert_eq!(ids, listed("builtIn"));
|
||||
assert_eq!(ids, SWATCH_IDS.map(|i| json!(i)));
|
||||
let gradients: Vec<Value> = crate::menus::menu_entries(&app)
|
||||
.into_iter()
|
||||
.filter(|e| e.path == ["Window", "Swatch Libraries", "Gradients"])
|
||||
.map(|e| e.params["library"].clone())
|
||||
.collect();
|
||||
assert!(gradients.len() >= 5);
|
||||
assert_eq!(gradients, listed("gradients"));
|
||||
let defaults = crate::menus::menu_entries(&app).into_iter().any(|e| e.command.as_deref() == Some("swatch.resetDefaults"));
|
||||
assert!(defaults, "Default Swatches is listed");
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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::{BuiltinLibrary, GRADIENT_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<String>, order: &[&str], name: &str, m: egui::Modifiers) -> Vec<String> {
|
||||
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<String>, order: &[&str], name: &str, m: egui::Modifiers) -> Vec<String> {
|
||||
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<String>, 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()];
|
||||
}
|
||||
|
|
@ -336,17 +343,18 @@ enum Drop {
|
|||
/// at the end (`None`).
|
||||
Move { names: Vec<String>, target: Option<String>, after: bool },
|
||||
/// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row
|
||||
/// `target` (or between rows) becomes a swatch.
|
||||
New { paint: Paint, target: Option<String> },
|
||||
/// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch
|
||||
/// `target` when that is of its kind.
|
||||
New { paint: Paint, target: Option<String>, replace: bool },
|
||||
}
|
||||
|
||||
impl Drop {
|
||||
/// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows
|
||||
/// it carries, or make a swatch of a paint dragged from elsewhere (appearances: nothing).
|
||||
fn of(d: &PanelDrag, target: Option<String>, after: bool) -> Option<Self> {
|
||||
fn of(d: &PanelDrag, target: Option<String>, after: bool, replace: bool) -> Option<Self> {
|
||||
match d {
|
||||
PanelDrag::Paint { rows: Some(r), .. } => Some(Drop::Move { names: r.names.clone(), target, after }),
|
||||
PanelDrag::Paint { paint, .. } => Some(Drop::New { paint: paint.clone(), target }),
|
||||
PanelDrag::Paint { paint, .. } => Some(Drop::New { paint: paint.clone(), target, replace }),
|
||||
PanelDrag::Appearance(_) => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -433,7 +441,7 @@ fn tile_drop(ui: &Ui, resp: &Response, e: &Entry, list: bool, ev: &mut TileEvent
|
|||
ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent));
|
||||
}
|
||||
if resp.dnd_release_payload::<PanelDrag>().is_some() {
|
||||
ev.drop = Drop::of(&d, Some(name.to_string()), after);
|
||||
ev.drop = Drop::of(&d, Some(name.to_string()), after, super::alt_held(ui));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -445,7 +453,7 @@ fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) {
|
|||
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
|
||||
}
|
||||
if zone.dnd_release_payload::<PanelDrag>().is_some() {
|
||||
ev.drop = Drop::of(&d, None, false);
|
||||
ev.drop = Drop::of(&d, None, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -490,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) {
|
||||
|
|
@ -499,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))
|
||||
|
|
@ -547,7 +564,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 +599,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 +606,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 +617,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<&Entry>> = 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 +651,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 +664,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<&T>> = 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 +774,12 @@ fn delete(app: &mut VectorcraftApp, names: Vec<String>, 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::<SwatchLibraries>(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,8 +899,165 @@ 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);
|
||||
menu_item(ui, "Save Swatch Library…", false, false);
|
||||
ui.menu_button("Open Swatch Library", |ui| library_panel::library_menu::<SwatchLibraries>(app, ui));
|
||||
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.*`).
|
||||
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<SwatchLibrary>;
|
||||
type Item = Swatch;
|
||||
|
||||
fn list(app: &VectorcraftApp) -> Vec<LibraryRef> {
|
||||
let submenu = |category: &str| match category {
|
||||
"gradients" => Some("Gradients"),
|
||||
"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))
|
||||
}
|
||||
/// 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<Row<'a, Swatch>> {
|
||||
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<Row<Swatch>> = 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<String>) {
|
||||
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 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) {
|
||||
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<Value, String> {
|
||||
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()}))
|
||||
}
|
||||
|
||||
/// `swatch.library.load` params, then open the library in the panel.
|
||||
pub(crate) fn load_library(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
|
||||
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<String>) -> Result<Value, String> {
|
||||
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<swatchlib::LibraryInfo> {
|
||||
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<Item> {
|
||||
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 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(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,
|
||||
Item::Cmd("Other Library…", "window.swatchLibrary.other", Value::Null),
|
||||
Item::Cmd("Save Swatch Library…", "ui.saveSwatchLibrary", Value::Null),
|
||||
]);
|
||||
items
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -973,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());
|
||||
}
|
||||
|
|
@ -1191,6 +1390,36 @@ mod tests {
|
|||
assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_dropping_a_gradient_on_a_gradient_swatch_replaces_it() {
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
frame(&mut app, &ctx, vec![], 0.0, show);
|
||||
let grad = |c: &str| {
|
||||
let stops = vec![
|
||||
vectorcraft_color::GradientStop { offset: 0.0, color: Color::from_hex(c).unwrap(), opacity: 1.0, midpoint: 0.5 },
|
||||
vectorcraft_color::GradientStop { offset: 1.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
|
||||
];
|
||||
Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient { kind: GradientKind::Radial, stops })))
|
||||
};
|
||||
let drop_on = |app: &mut VectorcraftApp, name: &str, paint: Paint, m: Modifiers, time: f64| {
|
||||
let at = tile_center(&ctx, name);
|
||||
egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(paint));
|
||||
let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m };
|
||||
frame_with(app, &ctx, vec![Event::PointerMoved(at), release], m, time, show);
|
||||
};
|
||||
let count = app.session.doc().unwrap().doc.swatches_iter().count();
|
||||
drop_on(&mut app, "Ocean", grad("#ff0000"), Modifiers::ALT, 1.0);
|
||||
let d = &app.session.doc().unwrap().doc;
|
||||
let Some(Paint::Gradient(g)) = d.swatch("Ocean").map(|w| &w.paint) else { panic!("Ocean is a gradient") };
|
||||
assert_eq!((g.gradient.kind, d.swatches_iter().count()), (GradientKind::Radial, count), "replaced, not added");
|
||||
// Without Alt, or on a swatch of another kind, it becomes a new swatch.
|
||||
frame(&mut app, &ctx, vec![], 2.0, show);
|
||||
drop_on(&mut app, "Red", grad("#00ff00"), Modifiers::ALT, 3.0);
|
||||
assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 1);
|
||||
assert!(app.session.doc().unwrap().doc.swatch("Red").is_some_and(|w| w.paint.color().is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_targets_follow_the_panel_selection() {
|
||||
let app = app();
|
||||
|
|
|
|||
|
|
@ -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<crate::panels::library_panel::OpenLibrary>,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
|
|
@ -301,6 +304,7 @@ impl Default for UiState {
|
|||
recent_fonts: vec![],
|
||||
engine_prefs: Value::Null,
|
||||
color_guide: Default::default(),
|
||||
library_panel: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -77,3 +78,13 @@ 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.
|
||||
`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).
|
||||
|
|
|
|||
20
docs/mcp.md
20
docs/mcp.md
|
|
@ -303,4 +303,24 @@ 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"]}}}
|
||||
```
|
||||
`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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue