diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 9ae909c4..8d35e65d 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -161,6 +161,9 @@ fn main() -> eframe::Result { Box::new(move |cc| { let mut app = VectorcraftApp::new(Session::new(), services()); load_prefs(&mut app); + // User Defined swatch libraries live next to the preferences. + let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string())); + app.session.swatch_libraries.set_user_dir(swatches); app.integrated_titlebar = cfg!(target_os = "macos"); if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml index 010c0475..a007ede3 100644 --- a/crates/color/Cargo.toml +++ b/crates/color/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] kurbo = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } moxcms = "0.8" diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 26f3fd34..816a8789 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -9,6 +9,7 @@ pub mod cms; pub mod gradient; pub mod harmony; pub mod libraries; +pub mod palette_io; pub mod swatch; pub use blend::BlendMode; diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs new file mode 100644 index 00000000..160383c0 --- /dev/null +++ b/crates/color/src/palette_io.rs @@ -0,0 +1,324 @@ +//! Swatch library files: the native `.vcswatches` JSON (colour models, global, spot, gradients +//! and colour groups exactly), `.gpl` palettes (8-bit RGB; colour groups as `# Group:` comment +//! headers) and CSS custom properties (written only). + +use serde::{Deserialize, Serialize}; + +use crate::{Color, GradientKind, Paint, Swatch, SwatchGroup, SwatchLibrary}; + +/// A swatch library file format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PaletteFormat { + /// `.vcswatches`: JSON, lossless. + Native, + /// `.gpl`: a plain-text RGB palette many paint and design tools read. + Gpl, + /// `.css`: custom properties on `:root` (colours and gradients; written only). + Css, +} + +impl PaletteFormat { + pub const ALL: [PaletteFormat; 3] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Css]; + + /// The format's id, which is also its extension. + pub fn id(self) -> &'static str { + match self { + PaletteFormat::Native => "vcswatches", + PaletteFormat::Gpl => "gpl", + PaletteFormat::Css => "css", + } + } + pub fn label(self) -> &'static str { + match self { + PaletteFormat::Native => "VectorCraft Swatches (.vcswatches)", + PaletteFormat::Gpl => "GPL Palette (.gpl)", + PaletteFormat::Css => "CSS Custom Properties (.css)", + } + } + /// The format with id or extension `s` (any case, leading dot allowed). + pub fn parse(s: &str) -> Option { + let s = s.trim_start_matches('.').to_ascii_lowercase(); + Self::ALL.into_iter().find(|f| f.id() == s) + } + /// Can libraries be read back from this format? + pub fn readable(self) -> bool { + self != PaletteFormat::Css + } +} + +/// The native file: a header around the library. +#[derive(Serialize, Deserialize)] +struct NativeFile { + format: String, + version: u32, + #[serde(flatten)] + library: SwatchLibrary, +} + +const NATIVE_FORMAT: &str = "vcswatches"; +const GPL_HEADER: &str = "GIMP Palette"; +const GPL_GROUP: &str = "# Group:"; + +/// Write `lib` in `format`. Pattern swatches (their tiles live in a document) and None are left +/// out; `.gpl` keeps solid colours only, as 8-bit RGB. +pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String { + let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_)); + match format { + PaletteFormat::Native => { + let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::>(); + let library = SwatchLibrary { + name: lib.name.clone(), + swatches: strip(&lib.swatches), + groups: lib.groups.iter().map(|g| SwatchGroup { name: g.name.clone(), swatches: strip(&g.swatches) }).collect(), + }; + let file = NativeFile { format: NATIVE_FORMAT.into(), version: 1, library }; + serde_json::to_string_pretty(&file).unwrap_or_default() + } + PaletteFormat::Gpl => write_gpl(lib), + PaletteFormat::Css => write_css(lib, keep), + } +} + +/// One line of text: no line breaks or tabs. +fn one_line(s: &str) -> String { + s.split(['\n', '\r', '\t']).collect::>().join(" ").trim().to_string() +} + +fn write_gpl(lib: &SwatchLibrary) -> String { + let mut out = format!("{GPL_HEADER}\nName: {}\nColumns: 0\n#\n", one_line(&lib.name)); + let colors = |out: &mut String, list: &[Swatch]| { + for w in list { + if let Some(c) = w.paint.color() { + let [r, g, b, _] = c.to_rgba8(1.0); + out.push_str(&format!("{r:3} {g:3} {b:3}\t{}\n", one_line(&w.name))); + } + } + }; + colors(&mut out, &lib.swatches); + for g in &lib.groups { + out.push_str(&format!("{GPL_GROUP} {}\n", one_line(&g.name))); + colors(&mut out, &g.swatches); + } + out +} + +/// `name` as a CSS custom property (`--` and an escaped identifier): lower case, spaces as +/// hyphens, other characters an identifier can't hold escaped with a backslash. +pub fn css_property(name: &str) -> String { + let mut out = String::from("--"); + for word in name.split_whitespace() { + if out.len() > 2 { + out.push('-'); + } + for ch in word.chars().flat_map(char::to_lowercase) { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || !ch.is_ascii() { + out.push(ch); + } else if ch.is_ascii_graphic() { + out.push('\\'); + out.push(ch); + } else { + out.push_str(&format!("\\{:x} ", ch as u32)); + } + } + } + if out.len() == 2 { + out.push_str("swatch"); + } + out +} + +/// A colour as CSS (`#rrggbb`, or `#rrggbbaa` below full opacity). +fn css_color(c: &Color, opacity: f32) -> String { + let [r, g, b, a] = c.to_rgba8(opacity); + if a == 255 { format!("#{r:02x}{g:02x}{b:02x}") } else { format!("#{r:02x}{g:02x}{b:02x}{a:02x}") } +} + +/// A comment's text: `*/` can't end it early. +fn css_comment(s: &str) -> String { + one_line(s).replace("*/", "* /") +} + +fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { + let mut out = format!("/* {} */\n:root {{\n", css_comment(&lib.name)); + let mut taken: Vec = vec![]; + let mut props = |out: &mut String, list: &[Swatch]| { + for w in list.iter().filter(&keep) { + let value = match &w.paint { + Paint::Solid { color, .. } => css_color(color, 1.0), + Paint::Gradient(g) if g.gradient.kind != GradientKind::Freeform => { + let stops: Vec = + g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), (s.offset * 100.0).round())).collect(); + match g.gradient.kind { + GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")), + _ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")), + } + } + _ => continue, + }; + // Names differing only in case or punctuation map to one property: number the others. + let base = css_property(&w.name); + let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base}-{i}") }).find(|n| !taken.contains(n)).unwrap_or(base); + taken.push(name.clone()); + let note = if w.spot { " /* spot */" } else { "" }; + out.push_str(&format!(" {name}: {value};{note}\n")); + } + }; + props(&mut out, &lib.swatches); + for g in &lib.groups { + out.push_str(&format!(" /* {} */\n", css_comment(&g.name))); + props(&mut out, &g.swatches); + } + out.push_str("}\n"); + out +} + +/// Read a `.vcswatches` or `.gpl` library (detected from its content). `name` names it when the +/// file doesn't. +pub fn read(text: &str, name: &str) -> Result { + let text = text.trim_start_matches('\u{feff}').trim_start(); + if text.starts_with(GPL_HEADER) { + return Ok(read_gpl(text, name)); + } + if text.starts_with('{') { + let f: NativeFile = serde_json::from_str(text).map_err(|e| format!("not a swatch library: {e}"))?; + if f.format != NATIVE_FORMAT { + return Err(format!("not a swatch library (format `{}`)", f.format)); + } + let mut lib = f.library; + if lib.name.trim().is_empty() { + lib.name = name.into(); + } + return Ok(lib); + } + Err("not a swatch library (.vcswatches or .gpl)".into()) +} + +/// Is `text` a library [`read`] understands? +pub fn sniff(text: &str) -> bool { + let t = text.trim_start_matches('\u{feff}').trim_start(); + t.starts_with(GPL_HEADER) || (t.starts_with('{') && t.contains(NATIVE_FORMAT)) +} + +/// `.gpl`: `Name:` names the library, `# Group: name` starts a colour group, other `#` lines and +/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colours are +/// named by their values; repeated names get a number. +fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary { + let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() }; + let mut names: Vec = vec![]; + for line in text.lines().skip(1) { + let line = line.trim(); + if let Some(g) = line.strip_prefix(GPL_GROUP) { + lib.groups.push(SwatchGroup { name: g.trim().to_string(), swatches: vec![] }); + continue; + } + if let Some(n) = line.strip_prefix("Name:") { + lib.name = n.trim().to_string(); + continue; + } + // Comments, headers (`Columns: 4`) and anything else that isn't a colour are skipped. + let words: Vec<&str> = line.split_whitespace().collect(); + let rgb: Vec = words.iter().take(3).map_while(|v| v.parse().ok()).collect(); + let [r, g, b] = rgb[..] else { continue }; + let rest = words[3..].join(" "); + let base = if rest.is_empty() { format!("R={r} G={g} B={b}") } else { rest }; + let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base} {i}") }).find(|n| !names.contains(n)).unwrap_or(base); + names.push(name.clone()); + let w = Swatch { name, paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false }; + match lib.groups.last_mut() { + Some(grp) => grp.swatches.push(w), + None => lib.swatches.push(w), + } + } + lib +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Gradient, GradientPaint, GradientStop}; + + fn sample() -> SwatchLibrary { + let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient { + kind: GradientKind::Linear, + stops: vec![ + GradientStop { offset: 0.0, color: Color::cmyk(0.1, 0.2, 0.3, 0.0), opacity: 1.0, midpoint: 0.4 }, + GradientStop { offset: 1.0, color: Color::rgb8(255, 0, 0), opacity: 0.5, midpoint: 0.5 }, + ], + }))); + SwatchLibrary { + name: "Brand */ Colours".into(), + swatches: vec![ + Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.2)), global: true, spot: true }, + Swatch { name: "Fade".into(), paint: grad, global: false, spot: false }, + Swatch { name: "Tiles".into(), paint: Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }, global: false, spot: false }, + ], + groups: vec![SwatchGroup { + name: "Neutrals".into(), + swatches: vec![ + Swatch { name: "R=255 G=0 B=0".into(), paint: Paint::solid(Color::rgb8(255, 0, 0)), global: false, spot: false }, + Swatch { name: "Mist".into(), paint: Paint::solid(Color::gray(0.25)), global: true, spot: false }, + ], + }], + } + } + + #[test] + fn native_round_trip_keeps_cmyk_spot_and_groups() { + let lib = sample(); + let text = write(&lib, PaletteFormat::Native); + assert!(sniff(&text)); + let back = read(&text, "fallback").unwrap(); + let mut expected = lib.clone(); + expected.swatches.retain(|w| !matches!(w.paint, Paint::Pattern { .. })); + assert_eq!(back, expected, "everything but the pattern comes back exactly"); + assert!(back.swatches[0].spot && back.swatches[0].global); + assert_eq!(back.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2))); + assert!(read("{\"format\": \"other\", \"version\": 1, \"name\": \"x\"}", "x").is_err()); + } + + #[test] + fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() { + let text = write(&sample(), PaletteFormat::Gpl); + assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n")); + assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}"); + assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours"); + let back = read(&text, "x").unwrap(); + assert_eq!(back.name, "Brand */ Colours"); + assert_eq!(back.swatches.len(), 1); + assert_eq!(back.groups[0].swatches[1].paint.color(), Some(Color::rgb8(191, 191, 191)), "grey as 8-bit RGB"); + // Another tool's palette: comments, a Columns header, blank lines, unnamed and repeated names. + let other = "GIMP Palette\r\nName: Sunset\r\nColumns: 4\r\n# a comment\r\n\r\n 0 128 255\tSky Blue\r\n10 20 30\r\n0 0 0 Black\r\n1 1 1 Black\r\nbad line\r\n"; + let lib = read(other, "file").unwrap(); + assert_eq!(lib.name, "Sunset"); + let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]); + assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255))); + assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback"); + } + + #[test] + fn css_escapes_names_and_writes_gradients() { + assert_eq!(css_property("Sky Blue"), "--sky-blue"); + assert_eq!(css_property("R=255 G=0 B=0"), "--r\\=255-g\\=0-b\\=0"); + assert_eq!(css_property("#FF00CC"), "--\\#ff00cc"); + assert_eq!(css_property("Café 50%"), "--café-50\\%"); + assert_eq!(css_property(" "), "--swatch"); + let text = write(&sample(), PaletteFormat::Css); + assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}"); + assert!(text.contains(" --ink: #"), "{text}"); + assert!(text.contains("; /* spot */")); + assert!(text.contains(" --fade: linear-gradient(90deg, #"), "{text}"); + assert!(text.contains("#ff000080 100%)"), "half-transparent stop: {text}"); + assert!(text.contains(" /* Neutrals */\n --r\\=255-g\\=0-b\\=0: #ff0000;\n")); + assert!(!text.contains("tiles")); + // Names that collide once escaped are numbered. + let lib = SwatchLibrary { + name: "x".into(), + swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(), + groups: vec![], + }; + let text = write(&lib, PaletteFormat::Css); + assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;")); + assert!(read(&text, "x").is_err(), "CSS is written only"); + } +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index b8505e5c..95832f6f 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -242,9 +242,12 @@ pub const FORMATS: &[Format] = &[ pub const OPEN_EXTS: &[&str] = &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"]; -/// Open-dialog filters: "All readable files" first, then one per readable format. +/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch +/// libraries (which open in the library panel). pub fn open_filters() -> impl Iterator { - std::iter::once(("All readable files", OPEN_EXTS)).chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions))) + std::iter::once(("All readable files", OPEN_EXTS)) + .chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions))) + .chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS))) } /// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`). @@ -291,17 +294,17 @@ fn formats(_: &mut Session, _: &Value) -> Result { // ---------- the file system (none on the web, where commands take and return bytes) ---------- #[cfg(not(target_arch = "wasm32"))] -fn read_file(path: &str) -> Result> { +pub(crate) fn read_file(path: &str) -> Result> { std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(not(target_arch = "wasm32"))] -fn write_file(path: &str, bytes: &[u8]) -> Result<()> { +pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(not(target_arch = "wasm32"))] -fn create_dir(path: &str) -> Result<()> { +pub(crate) fn create_dir(path: &str) -> Result<()> { std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -311,17 +314,17 @@ fn no_fs(path: &str) -> EngineError { } #[cfg(target_arch = "wasm32")] -fn read_file(path: &str) -> Result> { +pub(crate) fn read_file(path: &str) -> Result> { Err(no_fs(path)) } #[cfg(target_arch = "wasm32")] -fn write_file(path: &str, _: &[u8]) -> Result<()> { +pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> { Err(no_fs(path)) } #[cfg(target_arch = "wasm32")] -fn create_dir(path: &str) -> Result<()> { +pub(crate) fn create_dir(path: &str) -> Result<()> { Err(no_fs(path)) } diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index ea31f52b..ec67c7c6 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -148,7 +148,7 @@ fn open_exts_cover_every_readable_format() { } let filters: Vec<_> = open_filters().collect(); assert_eq!(filters[0], ("All readable files", OPEN_EXTS)); - assert_eq!(filters.len(), 1 + FORMATS.iter().filter(|f| f.read).count()); + assert_eq!(filters.len(), 2 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries"); } #[test] diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 11d629b6..b3b44cec 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -1,13 +1,17 @@ //! Swatch libraries (Window → Swatch Libraries, the Swatches panel's library button): read-only //! sets of swatches the library panel shows. The built-in libraries are computed in -//! [`vectorcraft_color::libraries`]; `swatch.library.add` copies swatches into the document. +//! [`vectorcraft_color::libraries`]; User Defined ones are the library files in the user library +//! folder ([`Libraries`]), and Other Library… loads more from files. `swatch.library.add` copies +//! swatches into the document; `swatch.library.save` writes the document's ([`palette_io`]). use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::libraries::{SWATCH_LIBRARIES, builtin_library}; -use vectorcraft_color::{Swatch, SwatchGroup, SwatchLibrary, default_swatches}; +use vectorcraft_color::palette_io::{self, PaletteFormat}; +use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches}; +use super::fileio::{create_dir, read_file, write_file}; use super::menucmds::squash; use super::swatch::{map_default_paints, str_list, swatch_json}; use super::*; @@ -19,7 +23,7 @@ pub fn specs() -> Vec { "Swatch Libraries", [], None, - "{} → {libraries: [{id, name, category: \"builtIn\", count}]} every library the library panel opens, in menu order", + "{} → {libraries: [{id, name, category: \"builtIn\"|\"user\" (User Defined: the library files in the user library folder, rescanned now)|\"loaded\" (swatch.library.load), count, path?}], userFolder} every library the library panel opens, in menu order", always, list ), @@ -50,27 +54,126 @@ pub fn specs() -> Vec { has_doc, reset_defaults ), + cmd!( + "swatch.library.save", + "Save Swatch Library…", + ["Window", "Swatches"], + None, + "{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}", + has_doc, + save + ), + cmd!( + "swatch.library.load", + "Other Library…", + ["Window", "Swatch Libraries"], + None, + "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches or .gpl library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}", + always, + load + ), ] } +/// The libraries beyond the built-in ones: User Defined (the library files in the user library +/// folder, as last scanned) and loaded ones (`swatch.library.load`). +#[derive(Clone, Debug, Default)] +pub struct Libraries { + /// The user library folder. The desktop app sets it; without one (the web, headless sessions) + /// there are no User Defined libraries. + user_dir: Option, + extra: Vec, +} + +#[derive(Clone, Debug)] +struct Extra { + info: LibraryInfo, + path: Option, + lib: Arc, +} + +/// The extensions of library files [`palette_io::read`] reads. +pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl"]; + +impl Libraries { + pub fn user_dir(&self) -> Option<&str> { + self.user_dir.as_deref() + } + + /// Set the user library folder and list its libraries. + pub fn set_user_dir(&mut self, dir: Option) { + self.user_dir = dir; + self.rescan(); + } + + /// Re-read the library files of the user library folder (unreadable ones are skipped). + pub fn rescan(&mut self) { + self.extra.retain(|e| e.info.category != "user"); + let Some(dir) = self.user_dir.clone() else { return }; + for path in library_files(&dir) { + let file = file_name(&path); + let Some(lib) = read_file(&path).ok().and_then(|b| palette_io::read(&String::from_utf8_lossy(&b), stem(&file)).ok()) else { continue }; + let info = LibraryInfo { id: format!("user/{file}"), name: lib.name.clone(), category: "user" }; + self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) }); + } + } + + /// Add (or replace, by id) a loaded library. + fn put(&mut self, e: Extra) { + self.extra.retain(|x| x.info.id != e.info.id); + self.extra.push(e); + } +} + +/// The library files in folder `dir`, sorted by name. +#[cfg(not(target_arch = "wasm32"))] +fn library_files(dir: &str) -> Vec { + let Ok(rd) = std::fs::read_dir(dir) else { return vec![] }; + let mut files: Vec = rd + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| LIBRARY_EXTS.contains(&x.to_string_lossy().to_ascii_lowercase().as_str()))) + .map(|p| p.to_string_lossy().to_string()) + .collect(); + files.sort_by_key(|f| f.to_lowercase()); + files +} + +#[cfg(target_arch = "wasm32")] +fn library_files(_: &str) -> Vec { + vec![] +} + +fn file_name(path: &str) -> String { + std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) +} + +fn stem(name: &str) -> &str { + name.rsplit_once('.').map_or(name, |(s, _)| s) +} + /// A library as the menus and the library panel list it. #[derive(Clone, Debug, PartialEq)] pub struct LibraryInfo { pub id: String, pub name: String, - /// "builtIn". + /// "builtIn", "user" or "loaded". pub category: &'static str, } -/// Every library, in menu order. -pub fn libraries(_s: &Session) -> Vec { - SWATCH_LIBRARIES.iter().map(|b| LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" }).collect() +/// Every library, in menu order: built-in, User Defined, loaded. +pub fn libraries(s: &Session) -> Vec { + let builtin = SWATCH_LIBRARIES.iter().map(|b| LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" }); + builtin.chain(s.swatch_libraries.extra.iter().map(|e| e.info.clone())).collect() } /// Library `key` (an id, or a name in any case) with its info. pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc)> { - let info = libraries(s).into_iter().find(|l| l.id == key).or_else(|| libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key)))?; - let lib = builtin_library(&info.id)?; + let all = libraries(s); + let info = all.iter().find(|l| l.id == key).or_else(|| all.iter().find(|l| l.name.eq_ignore_ascii_case(key)))?.clone(); + let lib = match s.swatch_libraries.extra.iter().find(|e| e.info.id == info.id) { + Some(e) => e.lib.clone(), + None => builtin_library(&info.id)?, + }; Some((info, lib)) } @@ -80,14 +183,107 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc< } fn list(s: &mut Session, _: &Value) -> Result { + s.swatch_libraries.rescan(); + let path = |id: &str| s.swatch_libraries.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.clone()); let libs: Vec = libraries(s) .into_iter() .map(|l| { let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); - json!({"id": l.id, "name": l.name, "category": l.category, "count": count}) + json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": path(&l.id)}) }) .collect(); - Ok(json!({ "libraries": libs })) + Ok(json!({ "libraries": libs, "userFolder": s.swatch_libraries.user_dir() })) +} + +/// The document's swatches `names` (swatches or colour groups; empty: all) as a library named +/// `name`, without None and patterns. +fn document_library(d: &vectorcraft_doc::Document, names: &[String], name: String, cmd: &str) -> Result { + if let Some(n) = names.iter().find(|n| !d.swatch_name_taken(n)) { + return Err(bad(cmd, format!("no swatch or colour group `{n}`"))); + } + let want = |n: &str| names.is_empty() || names.iter().any(|x| x == n); + let savable = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_)); + let swatches = d.swatches.iter().filter(savable).filter(|w| want(&w.name)).cloned().collect(); + let groups = d + .swatch_groups + .iter() + .filter_map(|g| { + let all = want(&g.name); + let swatches: Vec = g.swatches.iter().filter(savable).filter(|w| all || want(&w.name)).cloned().collect(); + (!swatches.is_empty() || (all && !names.is_empty())).then(|| SwatchGroup { name: g.name.clone(), swatches }) + }) + .collect(); + Ok(SwatchLibrary { name, swatches, groups }) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.library.save"; + let path = str_param(p, "path"); + let format = match str_param(p, "format").or_else(|| path.map(|p| p.rsplit_once('.').map_or("", |(_, e)| e))).filter(|f| !f.is_empty()) { + Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl or css)")))?, + None => PaletteFormat::Native, + }; + let st = s.doc()?; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string); + let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?; + let (count, text) = (lib.len(), palette_io::write(&lib, format)); + let mut out = json!({"format": format.id(), "count": count}); + if bool_or(p, "user", false) { + let dir = s + .swatch_libraries + .user_dir() + .ok_or_else(|| bad(C, "no user library folder here (save with a path, or without one to get the data)"))? + .to_string(); + // A file name from the library's name, without characters file systems reject. + let base: String = lib.name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect(); + let file = format!("{}.{}", base.trim_matches(['.', ' ']), format.id()); + let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string(); + create_dir(&dir)?; + write_file(&path, text.as_bytes())?; + s.swatch_libraries.rescan(); + out["path"] = json!(path); + out["library"] = json!(format!("user/{file}")); + } else if let Some(path) = path { + write_file(path, text.as_bytes())?; + out["path"] = json!(path); + } else { + out["data"] = json!(text); + } + Ok(out) +} + +fn load(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.library.load"; + let path = str_param(p, "path"); + let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) { + (Some(path), ..) => read_file(path)?, + (None, Some(text), _) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?, + _ => return Err(bad(C, "give `path`, `data` or `dataBase64`")), + }; + let file = str_param(p, "name").map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into()); + // A library file, or a document whose swatches become the library. + let text = std::str::from_utf8(&bytes).ok().filter(|t| palette_io::sniff(t)); + let lib = match text { + Some(t) => palette_io::read(t, stem(&file)).map_err(|e| bad(C, e))?, + None => { + let doc = super::fileio::load(&file, &bytes).map_err(|e| bad(C, e.to_string()))?.doc; + document_library(&doc, &[], stem(&file).to_string(), C)? + } + }; + // A file of the user library folder is its User Defined library. + s.swatch_libraries.rescan(); + let user = path.and_then(|p| s.swatch_libraries.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p))); + let info = match user { + Some(e) => e.info.clone(), + None => { + let id = format!("loaded/{}", path.unwrap_or(&file)); + let info = LibraryInfo { id, name: lib.name.clone(), category: "loaded" }; + s.swatch_libraries.put(Extra { info: info.clone(), path: path.map(str::to_string), lib: Arc::new(lib.clone()) }); + info + } + }; + Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) } fn get(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 55cd5d75..c51424d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -462,6 +462,8 @@ pub struct Session { pub(crate) pending_paint: Option, /// What the Eyedropper copies (`eyedropper.setOptions`); not saved. pub eyedropper: EyedropperOptions, + /// User Defined and loaded swatch libraries (Window → Swatch Libraries); not saved. + pub swatch_libraries: cmd::swatchlib::Libraries, } impl Default for Session { @@ -494,6 +496,7 @@ impl Session { recent_colors: vec![], pending_paint: None, eyedropper: Default::default(), + swatch_libraries: Default::default(), } } diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests_swatchlib.rs index c30a79e7..d1bbfd11 100644 --- a/crates/engine/src/tests_swatchlib.rs +++ b/crates/engine/src/tests_swatchlib.rs @@ -129,3 +129,70 @@ fn reset_defaults_restores_missing_swatches_or_replaces_them() { assert_eq!(fill, Paint::solid(Color::from_hex("#abcdef").unwrap())); assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#abcdef").unwrap()), "the default fill is unlinked too"); } + +/// A fresh scratch folder for one test. +fn temp_dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-swatchlib-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d +} + +#[test] +fn save_and_load_round_trip_keeps_cmyk_spot_and_groups() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 1.0, "m": 0.5, "y": 0.0, "k": 0.2}, "spot": true})); + run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#123456", "#abcdef"]})); + let r = run(&mut s, "swatch.library.save", json!({"names": ["Ink", "Brand", "Sunset"], "name": "Brand Kit"})); + assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("vcswatches"), Some(4))); + let data = r["data"].as_str().unwrap().to_string(); + let r = run(&mut s, "swatch.library.load", json!({"data": data, "name": "kit.vcswatches"})); + assert_eq!((r["name"].as_str(), r["count"].as_u64()), (Some("Brand Kit"), Some(4))); + let id = r["library"].as_str().unwrap().to_string(); + let (_, lib) = cmd::swatchlib::library(&s, &id).unwrap(); + let ink = lib.swatch("Ink").unwrap(); + assert!(ink.spot && ink.global); + assert_eq!(ink.paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2))); + assert_eq!(lib.groups[0].name, "Brand"); + assert_eq!(lib.groups[0].swatches.len(), 2); + assert!(matches!(lib.swatch("Sunset").unwrap().paint, Paint::Gradient(_))); + // Listed as loaded; adding from it works like any library. + let list = run(&mut s, "swatch.library.list", json!({})); + assert!(list["libraries"].as_array().unwrap().iter().any(|l| l["id"] == id.as_str() && l["category"] == "loaded")); + assert!(s.execute("swatch.library.save", &json!({"names": ["Nope"]})).is_err()); + assert!(s.execute("swatch.library.save", &json!({"user": true})).is_err(), "no user folder in a headless session"); +} + +#[test] +fn libraries_saved_in_the_user_folder_are_user_defined() { + let dir = temp_dir("user"); + let mut s = session(); + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let r = run(&mut s, "swatch.library.save", json!({"user": true, "name": "My: Greys", "format": "gpl", "names": ["Grays"]})); + assert_eq!(r["library"], "user/My- Greys.gpl"); + let path = r["path"].as_str().unwrap().to_string(); + assert!(std::fs::read_to_string(&path).unwrap().starts_with("GIMP Palette\nName: My: Greys\n")); + let list = run(&mut s, "swatch.library.list", json!({})); + let user: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "user").collect(); + assert_eq!(user.len(), 1); + assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("My: Greys"), Some(9))); + // Opening a file of the user folder gives its User Defined library. + let r = run(&mut s, "swatch.library.load", json!({ "path": path })); + assert_eq!(r["library"], "user/My- Greys.gpl"); + // CSS by extension. + let css = dir.join("out.css").to_string_lossy().to_string(); + run(&mut s, "swatch.library.save", json!({ "path": css })); + assert!(std::fs::read_to_string(&css).unwrap().contains(" --white: #ffffff;")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn another_documents_swatches_load_as_a_library() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Signal", "color": "#ff3300"})); + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "swatch.library.load", json!({"dataBase64": b64, "name": "Poster.vectorcraft"})); + assert_eq!(r["name"], "Poster"); + let (_, lib) = cmd::swatchlib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert!(lib.swatch("Signal").is_some() && lib.swatch("[None]").is_none()); + assert!(s.execute("swatch.library.load", &json!({"data": "hello", "name": "x.txt"})).is_err()); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index cb08277d..6a41958b 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -27,6 +27,7 @@ mod path_ops; mod recolor; pub mod saturate; mod save_changes; +pub mod save_swatch_library; mod shapes; pub mod swatch_options; mod tools; @@ -150,6 +151,7 @@ registry! { ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC, ColorBalance: [color_balance::KIND] => color_balance::SPEC, Saturate: [saturate::KIND] => saturate::SPEC, + SaveSwatchLibrary: [save_swatch_library::KIND] => save_swatch_library::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/save_swatch_library.rs b/crates/ui-egui/src/dialogs/save_swatch_library.rs new file mode 100644 index 00000000..62a0ec21 --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_swatch_library.rs @@ -0,0 +1,97 @@ +//! Save Swatch Library: a name, a file format and where it goes: the user library folder (listed +//! under Window → Swatch Libraries → User Defined) or a file picked in a save dialog (a download +//! on the web). OK runs `swatch.library.save`. +//! +//! Fields: `name`, `format` (`vcswatches`, `gpl` or `css`), `user` (save to the user library +//! folder), `selectedOnly` and `names` (the swatches selected in the Swatches panel), `__user` +//! (there is a user library folder). + +use serde_json::{Value, json}; +use vectorcraft_color::palette_io::PaletteFormat; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Save Swatch Library. +pub const KIND: &str = "saveSwatchLibrary"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Save Swatch Library".into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM }; + +/// Open Save Swatch Library for the document's swatches (`names`: the ones selected in the panel). +pub fn open(app: &mut VectorcraftApp, names: Vec) -> Result { + let st = app.session.active().ok_or("no document open")?; + let title = st.title(); + let name = title.rsplit_once('.').map_or(title.as_str(), |(s, _)| s).to_string(); + let user = app.session.swatch_libraries.user_dir().is_some(); + let fields = json!({ + "name": name, "format": PaletteFormat::Native.id(), "user": user, "__user": user, + "selectedOnly": false, "names": names, + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn format_of(d: &Dialog) -> PaletteFormat { + PaletteFormat::parse(&d.str("format")).unwrap_or(PaletteFormat::Native) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let labels = PaletteFormat::ALL.map(PaletteFormat::label); + let names = d.fields.get("names").and_then(Value::as_array).map_or(0, Vec::len); + let set = |d: &mut Dialog, key: &str, v: Value| { + d.fields.insert(key.into(), v); + }; + grid(ui, |ui| { + label(ui, "Name:"); + form::text(ui, d, "name", 220.0); + ui.end_row(); + label(ui, "Format:"); + if let Some(i) = widgets::dropdown(ui, "library-format", format_of(d).label(), &labels, 230.0) { + set(d, "format", json!(PaletteFormat::ALL[i].id())); + } + ui.end_row(); + label(ui, "Save To:"); + let (user, has_user) = (d.bool("user"), d.bool("__user")); + if widgets::radio(ui, "User Defined Libraries", user, has_user) { + set(d, "user", json!(true)); + } + ui.end_row(); + ui.label(""); + if widgets::radio(ui, "A File…", !user, true) { + set(d, "user", json!(false)); + } + ui.end_row(); + ui.label(""); + let only = d.bool("selectedOnly"); + if widgets::check(ui, &format!("Selected Swatches Only ({names})"), only, names > 0) { + set(d, "selectedOnly", json!(!only)); + } + ui.end_row(); + }); + if format_of(d) == PaletteFormat::Gpl { + widgets::dim_label(ui, "GPL palettes keep solid colours only, as RGB."); + } + false +} + +/// `swatch.library.save` parameters from the fields. +fn params(d: &Dialog) -> Value { + let mut p = json!({"name": d.str("name"), "format": format_of(d).id(), "user": d.bool("user") && d.bool("__user")}); + if d.bool("selectedOnly") { + p["names"] = d.fields.get("names").cloned().unwrap_or_else(|| json!([])); + } + p +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let p = params(d); + if p["user"] == json!(true) { + let r = run_and_close(app, "swatch.library.save", p)?; + app.status(format!("Saved to User Defined: {}", d.str("name"))); + return Ok(r); + } + app.ui.dialog = None; + crate::io::save_command_output(app, "swatch.library.save", format_of(d).id(), p).map(|path| json!({ "path": path })) +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 8dde2d9f..5356ab9e 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -8,8 +8,16 @@ use vectorcraft_geom::Affine; use crate::VectorcraftApp; -/// Open bytes of any readable format as a new document (templates open untitled). +/// Open bytes of any readable format as a new document (templates open untitled); swatch library +/// files open in the library panel. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { + if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&fileio::extension(name).as_str()) { + let p = match path { + Some(path) => serde_json::json!({ "path": path }), + None => serde_json::json!({"name": name, "data": String::from_utf8_lossy(bytes)}), + }; + return crate::panels::swatches::load_library(app, p).map(|_| ()); + } let r = fileio::open_bytes(&mut app.session, name, bytes, path).map_err(|e| e.to_string())?; app.sync_views(); if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { @@ -113,7 +121,9 @@ pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut pa } } let v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; - let bytes = v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode).ok_or("no data")?; + // Binary output comes as base64, text (swatch libraries) as is. + let bytes = v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode).or_else(|| v["data"].as_str().map(|t| t.as_bytes().to_vec())); + let bytes = bytes.ok_or("no data")?; write_out(app, &path, &bytes)?; app.status(format!("Saved {path}")); Ok(path) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ade9d4e2..a8e6a6dc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -225,6 +225,28 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{library: id or name (see swatch.library.list) | null (close)} open the read-only library panel on a swatch library (UI state `library_panel`); clicking a swatch there runs swatch.library.add with apply → {open, name, count}", ), + ( + "window.swatchLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load a .vcswatches or .gpl library, or another document's swatches (engine: swatch.library.load), and open it in the library panel", + ), + ( + "ui.saveSwatchLibrary", + "Save Swatch Library…", + "", + "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save", + ), + ("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"), + ("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"), + ("window.userSwatchLibrary3", "User Swatch Library 3", "", "{} open the 3. User Defined swatch library"), + ("window.userSwatchLibrary4", "User Swatch Library 4", "", "{} open the 4. User Defined swatch library"), + ("window.userSwatchLibrary5", "User Swatch Library 5", "", "{} open the 5. User Defined swatch library"), + ("window.userSwatchLibrary6", "User Swatch Library 6", "", "{} open the 6. User Defined swatch library"), + ("window.userSwatchLibrary7", "User Swatch Library 7", "", "{} open the 7. User Defined swatch library"), + ("window.userSwatchLibrary8", "User Swatch Library 8", "", "{} open the 8. User Defined swatch library"), + ("window.userSwatchLibrary9", "User Swatch Library 9", "", "{} open the 9. User Defined swatch library"), + ("window.userSwatchLibrary10", "User Swatch Library 10", "", "{} open the 10. User Defined swatch library"), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -559,6 +581,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::swatches::open_library(app, p), + "window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")), + "ui.saveSwatchLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_swatch_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::swatches::USER_SLOT) => match crate::panels::swatches::user_library(app, id) { + Some(l) => crate::panels::swatches::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, _ => return None, }; Some(r) @@ -651,10 +682,19 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { } "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), + id if id.starts_with(crate::panels::swatches::USER_SLOT) => { + crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } _ => label.into(), } } +/// Menu slots that are left out while they have nothing to show (unused saved views, User +/// Defined swatch libraries). +fn hidden_when_disabled(id: &str) -> bool { + id.starts_with("view.goto") || id.starts_with(crate::panels::swatches::USER_SLOT) +} + /// File → Open Recent Files slots. const RECENT_IDS: [&str; 10] = [ "file.openRecent1", @@ -724,6 +764,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), "ui.graphicStyleOptions" => app.session.active().is_some(), "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.saveSwatchLibrary" => app.session.active().is_some(), + id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(), _ => true, } } @@ -1385,7 +1427,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked Item::Cmd(label, id, p) => { let en = enabled(app, id); // Unused saved-view slots are hidden (Illustrator lists only the saved views). - if !en && id.starts_with("view.goto") { + if !en && hidden_when_disabled(id) { continue; } let label = dynamic_label(app, id, label); @@ -1564,7 +1606,7 @@ pub fn menu_entries(app: &VectorcraftApp) -> Vec { fn walk(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Vec) { for it in items { match it { - Item::Cmd(_, id, _) if id.starts_with("view.goto") && !enabled(app, id) => {} + Item::Cmd(_, id, _) if hidden_when_disabled(id) && !enabled(app, id) => {} Item::Cmd(l, id, p) => out.push(MenuEntry { path: path.clone(), label: dynamic_label(app, id, l), diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 452d2a64..c22845b4 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -446,4 +446,78 @@ mod tests { let defaults = crate::menus::menu_entries(&app).into_iter().any(|e| e.command.as_deref() == Some("swatch.resetDefaults")); assert!(defaults, "Default Swatches is listed"); } + + #[test] + fn save_dialog_writes_user_libraries_that_the_window_menu_lists() { + let dir = std::env::temp_dir().join(format!("vc-library-panel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut app = app(); + app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let user_items = |app: &VectorcraftApp| -> Vec<(String, String)> { + crate::menus::menu_entries(app) + .into_iter() + .filter(|e| e.path == ["Window", "Swatch Libraries", "User Defined"]) + .map(|e| (e.label, e.command.unwrap_or_default())) + .collect() + }; + assert!(user_items(&app).is_empty(), "empty slots are hidden"); + app.run("ui.saveSwatchLibrary", json!({"names": ["Brights"]})).unwrap(); + let d = app.ui.dialog.as_mut().expect("Save Swatch Library opens"); + assert_eq!((d.kind.as_str(), d.bool("user")), (crate::dialogs::save_swatch_library::KIND, true)); + d.fields.insert("name".into(), json!("Loud")); + d.fields.insert("selectedOnly".into(), json!(true)); + let ctx = context(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), ..Default::default() }; + ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx())).textures_delta.clear(); + assert!(app.ui.dialog.is_some(), "the dialog draws and stays open"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(user_items(&app), [("Loud".to_string(), "window.userSwatchLibrary1".to_string())]); + app.run("window.userSwatchLibrary1", json!({})).unwrap(); + assert_eq!(app.ui.library_panel.as_ref().map(|o| o.id.as_str()), Some("user/Loud.vcswatches")); + let (_, lib) = SwatchLibraries::get(&app, "user/Loud.vcswatches").unwrap(); + assert_eq!((lib.groups.len(), lib.len()), (1, 5), "only the selected group"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn saving_to_a_file_and_opening_a_palette_file() { + let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); + let w = written.clone(); + let services = crate::Services { + pick_save: Some(Box::new(|name: &str| Some(format!("/tmp/{name}")))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.saveSwatchLibrary", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(!d.bool("user") && !d.bool("__user"), "no user folder: a file"); + d.fields.insert("format".into(), json!("gpl")); + crate::dialogs::confirm(&mut app).unwrap(); + let (path, bytes) = written.borrow()[0].clone(); + assert!(path.ends_with(".gpl"), "{path}"); + let text = String::from_utf8(bytes).unwrap(); + assert!( + text.starts_with( + "GIMP Palette +" + ) && text.contains( + " Red +" + ), + "{text}" + ); + // Opening a palette file opens it in the library panel. + crate::io::open_bytes(&mut app, "Saved.gpl", text.as_bytes(), None).unwrap(); + let open = app.ui.library_panel.clone().unwrap(); + assert!(open.id.starts_with("loaded/"), "{open:?}"); + assert_eq!(app.session.documents().len(), 1, "not opened as a document"); + let other = SwatchLibraries::list(&app).into_iter().find(|l| l.id == open.id).unwrap(); + assert_eq!(other.submenu, Some("Other Libraries")); + } } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 7d66e493..2bc86618 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -890,7 +890,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { menu_item(ui, "Spot Colors…", false, false); ui.separator(); ui.menu_button("Open Swatch Library", |ui| library_panel::library_menu::(app, ui)); - menu_item(ui, "Save Swatch Library…", false, false); + if menu_item(ui, "Save Swatch Library…", true, false) { + app.run("ui.saveSwatchLibrary", json!({ "names": deletable(&selected) })).ok(); + } } /// Swatch libraries in the library panel (engine: `swatch.library.*`). @@ -905,7 +907,12 @@ impl LibraryKind for SwatchLibraries { type Item = Swatch; fn list(app: &VectorcraftApp) -> Vec { - swatchlib::libraries(&app.session).into_iter().map(|l| LibraryRef { id: l.id, name: l.name, submenu: None }).collect() + let submenu = |category: &str| match category { + "user" => Some("User Defined"), + "loaded" => Some("Other Libraries"), + _ => None, + }; + swatchlib::libraries(&app.session).into_iter().map(|l| LibraryRef { submenu: submenu(l.category), id: l.id, name: l.name }).collect() } fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib)) @@ -953,6 +960,18 @@ impl LibraryKind for SwatchLibraries { } ui.separator(); } + fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.separator(); + if menu_item(ui, "Other Library…", true, false) + && let Err(e) = other_library(app, None) + { + app.status(e); + } + if menu_item(ui, "Save Swatch Library…", app.session.active().is_some(), false) { + let names = deletable(&selection(app, ui)); + app.run("ui.saveSwatchLibrary", json!({ "names": names })).ok(); + } + } } fn add_from_library(app: &mut VectorcraftApp, params: Value) { @@ -974,11 +993,57 @@ pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result Result { + let r = app.run("swatch.library.load", params)?; + open_library(app, &json!({ "library": r["library"] })) +} + +/// Other Library…: load the library (or document) at `path`, else one picked in an open dialog +/// (on the web the picked file arrives later and opens through [`crate::io::open_bytes`]). +pub(crate) fn other_library(app: &mut VectorcraftApp, path: Option) -> Result { + if path.is_none() + && let Some(f) = app.services.open_async.as_mut() + { + f(); + return Ok(Value::Null); + } + let path = path.or_else(|| app.services.pick_open.as_mut().and_then(|f| f())).ok_or("cancelled")?; + load_library(app, json!({ "path": path })) +} + +/// The id prefix of the Window → Swatch Libraries → User Defined slots. +pub(crate) const USER_SLOT: &str = "window.userSwatchLibrary"; + +/// The User Defined library slot `id` (`window.userSwatchLibrary3`) stands for. +pub(crate) fn user_library(app: &VectorcraftApp, id: &str) -> Option { + let n: usize = id.strip_prefix(USER_SLOT)?.parse().ok()?; + swatchlib::libraries(&app.session).into_iter().filter(|l| l.category == "user").nth(n.checked_sub(1)?) +} + /// Window → Swatch Libraries. pub(crate) fn window_menu() -> Vec { + const SLOTS: [&str; 10] = [ + "window.userSwatchLibrary1", + "window.userSwatchLibrary2", + "window.userSwatchLibrary3", + "window.userSwatchLibrary4", + "window.userSwatchLibrary5", + "window.userSwatchLibrary6", + "window.userSwatchLibrary7", + "window.userSwatchLibrary8", + "window.userSwatchLibrary9", + "window.userSwatchLibrary10", + ]; let mut items = vec![Item::Cmd("Default Swatches", "swatch.resetDefaults", Value::Null), Item::Sep]; items.extend(SWATCH_LIBRARIES.iter().map(|b| Item::Cmd(b.name, "window.swatchLibrary", json!({ "library": b.id })))); - items.extend([Item::Sep, Item::Todo("User Defined", ""), Item::Sep, Item::Todo("Other Library…", "")]); + items.extend([ + Item::Sep, + Item::Sub("User Defined", SLOTS.iter().map(|id| Item::Cmd("User Library", id, Value::Null)).collect()), + Item::Sep, + Item::Cmd("Other Library…", "window.swatchLibrary.other", Value::Null), + Item::Cmd("Save Swatch Library…", "ui.saveSwatchLibrary", Value::Null), + ]); items } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1ca07f8f..aa93fc56 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -83,3 +83,7 @@ panel on a swatch library (`ui.inspect` shows it as `library_panel: {kind, id}`; Clicking a swatch there runs `swatch.library.add {library, names: [name], apply}` (the active proxy, or the other one with Alt), so one undo step adds and applies it; Shift/Cmd-clicks select swatches and colour groups for Add to Swatches. +`window.swatchLibrary.other {path?}` loads a library file (or another document's swatches) and opens it there; +opening a `.vcswatches` or `.gpl` file with `app.open` does the same. `ui.saveSwatchLibrary {names?}` opens the +`saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`css`, `user`: save to the user library +folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.save` (to a file it asks for a path). diff --git a/docs/mcp.md b/docs/mcp.md index a7bc1d82..f92cb9fc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -319,3 +319,8 @@ swatch in the library panel does). `swatch.resetDefaults {replace?}` brings back {"name":"run_command","arguments":{"command":"swatch.library.get","params":{"library":"earth-tones"}}} {"name":"run_command","arguments":{"command":"swatch.library.add","params":{"library":"earth-tones","names":["Clay"]}}} ``` +`swatch.library.save {path?, format?: "vcswatches"|"gpl"|"css", names?, name?, user?}` writes the document's swatches +as a library (`.vcswatches` keeps colour models, global, spot, gradients and colour groups; `.gpl` is 8-bit RGB; +CSS writes custom properties); without `path` it returns `{data}`, and `user: true` saves into the user library +folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? | +dataBase64?, name?}` loads a `.vcswatches` or `.gpl` file, or another document's swatches, as a library to add from.