M3.57: save swatch libraries as .vcswatches, .gpl or CSS and load them back

Save Swatch Library… (Swatches panel menu, libraries menu, Window menu)
opens a dialog: name, format and destination. .vcswatches is lossless JSON
(colour models, global, spot, gradients, groups), .gpl an RGB palette with
groups as comment headers, CSS custom properties with escaped names.
Libraries saved to the user library folder show under User Defined.
Other Library… (and opening a .vcswatches/.gpl file) loads a library or
another document's swatches into the library panel.

Commands: swatch.library.save {path?, format?, names?, name?, user?}
(no path returns {data}), swatch.library.load {path?|data?|dataBase64?,
name?}; UI: window.swatchLibrary.other, ui.saveSwatchLibrary (dialog
saveSwatchLibrary), window.userSwatchLibrary1..10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 00:32:01 +07:00
parent e7f6328188
commit 786f2c05d3
17 changed files with 924 additions and 27 deletions

View File

@ -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());

View File

@ -11,6 +11,7 @@ workspace = true
[dependencies]
kurbo = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
moxcms = "0.8"

View File

@ -9,6 +9,7 @@ pub mod cms;
pub mod gradient;
pub mod harmony;
pub mod libraries;
pub mod palette_io;
pub mod swatch;
pub use blend::BlendMode;

View File

@ -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");
}
}

View File

@ -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))
}

View File

@ -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]

View File

@ -1,13 +1,17 @@
//! Swatch libraries (Window → Swatch Libraries, the Swatches panel's library button): read-only
//! sets of swatches the library panel shows. The built-in libraries are computed in
//! [`vectorcraft_color::libraries`]; `swatch.library.add` copies swatches into the document.
//! [`vectorcraft_color::libraries`]; User Defined ones are the library files in the user library
//! folder ([`Libraries`]), and Other Library… loads more from files. `swatch.library.add` copies
//! swatches into the document; `swatch.library.save` writes the document's ([`palette_io`]).
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::libraries::{SWATCH_LIBRARIES, builtin_library};
use vectorcraft_color::{Swatch, SwatchGroup, SwatchLibrary, default_swatches};
use vectorcraft_color::palette_io::{self, PaletteFormat};
use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches};
use super::fileio::{create_dir, read_file, write_file};
use super::menucmds::squash;
use super::swatch::{map_default_paints, str_list, swatch_json};
use super::*;
@ -19,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swatch Libraries",
[],
None,
"{} → {libraries: [{id, name, category: \"builtIn\", count}]} every library the library panel opens, in menu order",
"{} → {libraries: [{id, name, category: \"builtIn\"|\"user\" (User Defined: the library files in the user library folder, rescanned now)|\"loaded\" (swatch.library.load), count, path?}], userFolder} every library the library panel opens, in menu order",
always,
list
),
@ -50,27 +54,126 @@ pub fn specs() -> Vec<CommandSpec> {
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".
/// "builtIn", "user" or "loaded".
pub category: &'static str,
}
/// Every library, in menu order.
pub fn libraries(_s: &Session) -> Vec<LibraryInfo> {
SWATCH_LIBRARIES.iter().map(|b| LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" }).collect()
/// Every library, in menu order: built-in, User Defined, loaded.
pub fn libraries(s: &Session) -> Vec<LibraryInfo> {
let builtin = SWATCH_LIBRARIES.iter().map(|b| LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" });
builtin.chain(s.swatch_libraries.extra.iter().map(|e| e.info.clone())).collect()
}
/// Library `key` (an id, or a name in any case) with its info.
pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<SwatchLibrary>)> {
let info = libraries(s).into_iter().find(|l| l.id == key).or_else(|| libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key)))?;
let lib = builtin_library(&info.id)?;
let all = libraries(s);
let info = all.iter().find(|l| l.id == key).or_else(|| all.iter().find(|l| l.name.eq_ignore_ascii_case(key)))?.clone();
let lib = match s.swatch_libraries.extra.iter().find(|e| e.info.id == info.id) {
Some(e) => e.lib.clone(),
None => builtin_library(&info.id)?,
};
Some((info, lib))
}
@ -80,14 +183,107 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<
}
fn list(s: &mut Session, _: &Value) -> Result<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})
json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": path(&l.id)})
})
.collect();
Ok(json!({ "libraries": libs }))
Ok(json!({ "libraries": libs, "userFolder": s.swatch_libraries.user_dir() }))
}
/// The document's swatches `names` (swatches or colour groups; empty: all) as a library named
/// `name`, without None and patterns.
fn document_library(d: &vectorcraft_doc::Document, names: &[String], name: String, cmd: &str) -> Result<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> {

View File

@ -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(),
}
}

View File

@ -129,3 +129,70 @@ fn reset_defaults_restores_missing_swatches_or_replaces_them() {
assert_eq!(fill, Paint::solid(Color::from_hex("#abcdef").unwrap()));
assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#abcdef").unwrap()), "the default fill is unlinked too");
}
/// A fresh scratch folder for one test.
fn temp_dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vc-swatchlib-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn save_and_load_round_trip_keeps_cmyk_spot_and_groups() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 1.0, "m": 0.5, "y": 0.0, "k": 0.2}, "spot": true}));
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#123456", "#abcdef"]}));
let r = run(&mut s, "swatch.library.save", json!({"names": ["Ink", "Brand", "Sunset"], "name": "Brand Kit"}));
assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("vcswatches"), Some(4)));
let data = r["data"].as_str().unwrap().to_string();
let r = run(&mut s, "swatch.library.load", json!({"data": data, "name": "kit.vcswatches"}));
assert_eq!((r["name"].as_str(), r["count"].as_u64()), (Some("Brand Kit"), Some(4)));
let id = r["library"].as_str().unwrap().to_string();
let (_, lib) = cmd::swatchlib::library(&s, &id).unwrap();
let ink = lib.swatch("Ink").unwrap();
assert!(ink.spot && ink.global);
assert_eq!(ink.paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2)));
assert_eq!(lib.groups[0].name, "Brand");
assert_eq!(lib.groups[0].swatches.len(), 2);
assert!(matches!(lib.swatch("Sunset").unwrap().paint, Paint::Gradient(_)));
// Listed as loaded; adding from it works like any library.
let list = run(&mut s, "swatch.library.list", json!({}));
assert!(list["libraries"].as_array().unwrap().iter().any(|l| l["id"] == id.as_str() && l["category"] == "loaded"));
assert!(s.execute("swatch.library.save", &json!({"names": ["Nope"]})).is_err());
assert!(s.execute("swatch.library.save", &json!({"user": true})).is_err(), "no user folder in a headless session");
}
#[test]
fn libraries_saved_in_the_user_folder_are_user_defined() {
let dir = temp_dir("user");
let mut s = session();
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let r = run(&mut s, "swatch.library.save", json!({"user": true, "name": "My: Greys", "format": "gpl", "names": ["Grays"]}));
assert_eq!(r["library"], "user/My- Greys.gpl");
let path = r["path"].as_str().unwrap().to_string();
assert!(std::fs::read_to_string(&path).unwrap().starts_with("GIMP Palette\nName: My: Greys\n"));
let list = run(&mut s, "swatch.library.list", json!({}));
let user: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "user").collect();
assert_eq!(user.len(), 1);
assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("My: Greys"), Some(9)));
// Opening a file of the user folder gives its User Defined library.
let r = run(&mut s, "swatch.library.load", json!({ "path": path }));
assert_eq!(r["library"], "user/My- Greys.gpl");
// CSS by extension.
let css = dir.join("out.css").to_string_lossy().to_string();
run(&mut s, "swatch.library.save", json!({ "path": css }));
assert!(std::fs::read_to_string(&css).unwrap().contains(" --white: #ffffff;"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn another_documents_swatches_load_as_a_library() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Signal", "color": "#ff3300"}));
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": b64, "name": "Poster.vectorcraft"}));
assert_eq!(r["name"], "Poster");
let (_, lib) = cmd::swatchlib::library(&s, r["library"].as_str().unwrap()).unwrap();
assert!(lib.swatch("Signal").is_some() && lib.swatch("[None]").is_none());
assert!(s.execute("swatch.library.load", &json!({"data": "hello", "name": "x.txt"})).is_err());
}

View File

@ -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).

View File

@ -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 }))
}

View File

@ -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, &params).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)

View File

@ -225,6 +225,28 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{library: id or name (see swatch.library.list) | null (close)} open the read-only library panel on a swatch library (UI state `library_panel`); clicking a swatch there runs swatch.library.add with apply → {open, name, count}",
),
(
"window.swatchLibrary.other",
"Other Library…",
"",
"{path?} (default: pick a file) load a .vcswatches or .gpl library, or another document's swatches (engine: swatch.library.load), and open it in the library panel",
),
(
"ui.saveSwatchLibrary",
"Save Swatch Library…",
"",
"{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save",
),
("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"),
("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"),
("window.userSwatchLibrary3", "User Swatch Library 3", "", "{} open the 3. User Defined swatch library"),
("window.userSwatchLibrary4", "User Swatch Library 4", "", "{} open the 4. User Defined swatch library"),
("window.userSwatchLibrary5", "User Swatch Library 5", "", "{} open the 5. User Defined swatch library"),
("window.userSwatchLibrary6", "User Swatch Library 6", "", "{} open the 6. User Defined swatch library"),
("window.userSwatchLibrary7", "User Swatch Library 7", "", "{} open the 7. User Defined swatch library"),
("window.userSwatchLibrary8", "User Swatch Library 8", "", "{} open the 8. User Defined swatch library"),
("window.userSwatchLibrary9", "User Swatch Library 9", "", "{} open the 9. User Defined swatch library"),
("window.userSwatchLibrary10", "User Swatch Library 10", "", "{} open the 10. User Defined swatch library"),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@ -559,6 +581,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
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)
@ -651,10 +682,19 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
}
"edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()),
"edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => {
crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
_ => label.into(),
}
}
/// Menu slots that are left out while they have nothing to show (unused saved views, User
/// Defined swatch libraries).
fn hidden_when_disabled(id: &str) -> bool {
id.starts_with("view.goto") || id.starts_with(crate::panels::swatches::USER_SLOT)
}
/// File → Open Recent Files slots.
const RECENT_IDS: [&str; 10] = [
"file.openRecent1",
@ -724,6 +764,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
"ui.graphicStyleOptions" => app.session.active().is_some(),
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.saveSwatchLibrary" => app.session.active().is_some(),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(),
_ => true,
}
}
@ -1385,7 +1427,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
Item::Cmd(label, id, p) => {
let en = enabled(app, id);
// Unused saved-view slots are hidden (Illustrator lists only the saved views).
if !en && id.starts_with("view.goto") {
if !en && hidden_when_disabled(id) {
continue;
}
let label = dynamic_label(app, id, label);
@ -1564,7 +1606,7 @@ pub fn menu_entries(app: &VectorcraftApp) -> Vec<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),

View File

@ -446,4 +446,78 @@ mod tests {
let defaults = crate::menus::menu_entries(&app).into_iter().any(|e| e.command.as_deref() == Some("swatch.resetDefaults"));
assert!(defaults, "Default Swatches is listed");
}
#[test]
fn save_dialog_writes_user_libraries_that_the_window_menu_lists() {
let dir = std::env::temp_dir().join(format!("vc-library-panel-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut app = app();
app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let user_items = |app: &VectorcraftApp| -> Vec<(String, String)> {
crate::menus::menu_entries(app)
.into_iter()
.filter(|e| e.path == ["Window", "Swatch Libraries", "User Defined"])
.map(|e| (e.label, e.command.unwrap_or_default()))
.collect()
};
assert!(user_items(&app).is_empty(), "empty slots are hidden");
app.run("ui.saveSwatchLibrary", json!({"names": ["Brights"]})).unwrap();
let d = app.ui.dialog.as_mut().expect("Save Swatch Library opens");
assert_eq!((d.kind.as_str(), d.bool("user")), (crate::dialogs::save_swatch_library::KIND, true));
d.fields.insert("name".into(), json!("Loud"));
d.fields.insert("selectedOnly".into(), json!(true));
let ctx = context();
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), ..Default::default() };
ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx())).textures_delta.clear();
assert!(app.ui.dialog.is_some(), "the dialog draws and stays open");
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(user_items(&app), [("Loud".to_string(), "window.userSwatchLibrary1".to_string())]);
app.run("window.userSwatchLibrary1", json!({})).unwrap();
assert_eq!(app.ui.library_panel.as_ref().map(|o| o.id.as_str()), Some("user/Loud.vcswatches"));
let (_, lib) = SwatchLibraries::get(&app, "user/Loud.vcswatches").unwrap();
assert_eq!((lib.groups.len(), lib.len()), (1, 5), "only the selected group");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn saving_to_a_file_and_opening_a_palette_file() {
let written = std::rc::Rc::new(std::cell::RefCell::new(vec![]));
let w = written.clone();
let services = crate::Services {
pick_save: Some(Box::new(|name: &str| Some(format!("/tmp/{name}")))),
write: Some(Box::new(move |p: &str, b: &[u8]| {
w.borrow_mut().push((p.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("ui.saveSwatchLibrary", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
assert!(!d.bool("user") && !d.bool("__user"), "no user folder: a file");
d.fields.insert("format".into(), json!("gpl"));
crate::dialogs::confirm(&mut app).unwrap();
let (path, bytes) = written.borrow()[0].clone();
assert!(path.ends_with(".gpl"), "{path}");
let text = String::from_utf8(bytes).unwrap();
assert!(
text.starts_with(
"GIMP Palette
"
) && text.contains(
" Red
"
),
"{text}"
);
// Opening a palette file opens it in the library panel.
crate::io::open_bytes(&mut app, "Saved.gpl", text.as_bytes(), None).unwrap();
let open = app.ui.library_panel.clone().unwrap();
assert!(open.id.starts_with("loaded/"), "{open:?}");
assert_eq!(app.session.documents().len(), 1, "not opened as a document");
let other = SwatchLibraries::list(&app).into_iter().find(|l| l.id == open.id).unwrap();
assert_eq!(other.submenu, Some("Other Libraries"));
}
}

View File

@ -890,7 +890,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
menu_item(ui, "Spot Colors…", false, false);
ui.separator();
ui.menu_button("Open Swatch Library", |ui| library_panel::library_menu::<SwatchLibraries>(app, ui));
menu_item(ui, "Save Swatch Library…", false, false);
if menu_item(ui, "Save Swatch Library…", true, false) {
app.run("ui.saveSwatchLibrary", json!({ "names": deletable(&selected) })).ok();
}
}
/// Swatch libraries in the library panel (engine: `swatch.library.*`).
@ -905,7 +907,12 @@ impl LibraryKind for SwatchLibraries {
type Item = Swatch;
fn list(app: &VectorcraftApp) -> Vec<LibraryRef> {
swatchlib::libraries(&app.session).into_iter().map(|l| LibraryRef { id: l.id, name: l.name, submenu: None }).collect()
let submenu = |category: &str| match category {
"user" => Some("User Defined"),
"loaded" => Some("Other Libraries"),
_ => None,
};
swatchlib::libraries(&app.session).into_iter().map(|l| LibraryRef { submenu: submenu(l.category), id: l.id, name: l.name }).collect()
}
fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> {
swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib))
@ -953,6 +960,18 @@ impl LibraryKind for SwatchLibraries {
}
ui.separator();
}
fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.separator();
if menu_item(ui, "Other Library…", true, false)
&& let Err(e) = other_library(app, None)
{
app.status(e);
}
if menu_item(ui, "Save Swatch Library…", app.session.active().is_some(), false) {
let names = deletable(&selection(app, ui));
app.run("ui.saveSwatchLibrary", json!({ "names": names })).ok();
}
}
}
fn add_from_library(app: &mut VectorcraftApp, params: Value) {
@ -974,11 +993,57 @@ pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result<Value,
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 mut items = vec![Item::Cmd("Default Swatches", "swatch.resetDefaults", Value::Null), Item::Sep];
items.extend(SWATCH_LIBRARIES.iter().map(|b| Item::Cmd(b.name, "window.swatchLibrary", json!({ "library": b.id }))));
items.extend([Item::Sep, Item::Todo("User Defined", ""), Item::Sep, Item::Todo("Other Library…", "")]);
items.extend([
Item::Sep,
Item::Sub("User Defined", SLOTS.iter().map(|id| Item::Cmd("User Library", id, Value::Null)).collect()),
Item::Sep,
Item::Cmd("Other Library…", "window.swatchLibrary.other", Value::Null),
Item::Cmd("Save Swatch Library…", "ui.saveSwatchLibrary", Value::Null),
]);
items
}

View File

@ -83,3 +83,7 @@ panel on a swatch library (`ui.inspect` shows it as `library_panel: {kind, id}`;
Clicking a swatch there runs `swatch.library.add {library, names: [name], apply}` (the active proxy, or the other
one with Alt), so one undo step adds and applies it; Shift/Cmd-clicks select swatches and colour groups for
Add to Swatches.
`window.swatchLibrary.other {path?}` loads a library file (or another document's swatches) and opens it there;
opening a `.vcswatches` or `.gpl` file with `app.open` does the same. `ui.saveSwatchLibrary {names?}` opens the
`saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`css`, `user`: save to the user library
folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.save` (to a file it asks for a path).

View File

@ -319,3 +319,8 @@ swatch in the library panel does). `swatch.resetDefaults {replace?}` brings back
{"name":"run_command","arguments":{"command":"swatch.library.get","params":{"library":"earth-tones"}}}
{"name":"run_command","arguments":{"command":"swatch.library.add","params":{"library":"earth-tones","names":["Clay"]}}}
```
`swatch.library.save {path?, format?: "vcswatches"|"gpl"|"css", names?, name?, user?}` writes the document's swatches
as a library (`.vcswatches` keeps colour models, global, spot, gradients and colour groups; `.gpl` is 8-bit RGB;
CSS writes custom properties); without `path` it returns `{data}`, and `user: true` saves into the user library
folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? |
dataBase64?, name?}` loads a `.vcswatches` or `.gpl` file, or another document's swatches, as a library to add from.