M3.56: swatch library panel with code-generated swatch libraries

Window > Swatch Libraries and the Swatches panel's library button open a
read-only, floating library panel (thumbnail and list views, find field,
libraries menu, previous/next arrows). Clicking a swatch adds it to the
document and applies it as one undo step; Shift/Cmd-clicks select swatches
and groups for Add to Swatches. Default Swatches restores missing defaults.

Libraries are computed in code (OKLCH, rounded to 8-bit sRGB): Web Safe 216,
Grays and Neutrals, Earth Tones, Skin Tone Ramps, Pastels, Brights,
Metallic Gradients, Perceptual Scales, Harmony Sets.

Commands: swatch.library.list, swatch.library.get, swatch.library.add
{library, names?, apply?, focus?}, swatch.resetDefaults {replace?};
UI command window.swatchLibrary {library}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 00:59:59 +07:00
co-authored by Claude Opus 5.5
parent 8a837fd4b9
commit e7f6328188
16 changed files with 1483 additions and 81 deletions
+2
View File
@@ -37,6 +37,7 @@ mod select;
mod stroke;
mod style;
mod swatch;
pub mod swatchlib;
pub(crate) mod tabs;
mod textedit;
pub mod textstyles;
@@ -194,6 +195,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(distortcmds::specs());
v.extend(paint::proxy_specs());
v.extend(overprint::specs());
v.extend(swatchlib::specs());
v
})
}
+3 -3
View File
@@ -383,7 +383,7 @@ fn swatch_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
}
/// A list-of-strings parameter (missing or non-string entries are skipped).
fn str_list(p: &Value, key: &str) -> Vec<String> {
pub(super) fn str_list(p: &Value, key: &str) -> Vec<String> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
}
@@ -450,7 +450,7 @@ impl Relink {
/// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live
/// preview (Cancel rolls the document back, not the session).
fn map_default_paints(s: &mut Session, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) {
pub(super) fn map_default_paints(s: &mut Session, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) {
if s.in_interaction() {
return;
}
@@ -499,7 +499,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"name": to, "relinked": relinked}))
}
fn swatch_json(sw: &Swatch, group: Option<&str>) -> Value {
pub(super) fn swatch_json(sw: &Swatch, group: Option<&str>) -> Value {
let mut v = json!({"name": sw.name, "group": group, "global": sw.global, "spot": sw.spot});
match &sw.paint {
Paint::None => v["kind"] = json!("none"),
+233
View File
@@ -0,0 +1,233 @@
//! Swatch libraries (Window → Swatch Libraries, the Swatches panel's library button): read-only
//! sets of swatches the library panel shows. The built-in libraries are computed in
//! [`vectorcraft_color::libraries`]; `swatch.library.add` copies swatches into the document.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::libraries::{SWATCH_LIBRARIES, builtin_library};
use vectorcraft_color::{Swatch, SwatchGroup, SwatchLibrary, default_swatches};
use super::menucmds::squash;
use super::swatch::{map_default_paints, str_list, swatch_json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "swatch.library.list",
"Swatch Libraries",
[],
None,
"{} → {libraries: [{id, name, category: \"builtIn\", count}]} every library the library panel opens, in menu order",
always,
list
),
cmd!(
query "swatch.library.get",
"Swatch Library",
[],
None,
"{library: id or name} → {id, name, category, swatches: [{name, kind, group, global, spot, color?, hex?, gradient?}] (as swatch.list), groups: [{name, swatches: [names]}]}",
always,
get
),
cmd!(
"swatch.library.add",
"Add to Swatches",
["Window", "Swatch Libraries"],
None,
"{library: id or name, names?: [swatch or colour group names in the library] (default: all of it; a group comes as a colour group, a single swatch ungrouped), apply?: \"fill\"|\"stroke\" (also apply the first one to the selection and the defaults, as paint.setFill / paint.setStroke), focus?: true (with apply: false keeps the active proxy)} copy library swatches into the document as one undo step. A swatch the document already has (same name and paint) isn't added again; a name taken by another swatch gets a number (\"Clay 1 2\") → {library, added: [names in the document], existing: [names already there], applied?: name}",
has_doc,
add
),
cmd!(
"swatch.resetDefaults",
"Default Swatches",
["Window", "Swatch Libraries"],
None,
"{replace?: false} bring back the default swatches and colour groups of the document's colour mode that are missing (by name), as one undo step; replace: true makes the swatches exactly the defaults (art linked to a removed global swatch keeps its colour, unlinked) → {added: [names]}",
has_doc,
reset_defaults
),
]
}
/// A library as the menus and the library panel list it.
#[derive(Clone, Debug, PartialEq)]
pub struct LibraryInfo {
pub id: String,
pub name: String,
/// "builtIn".
pub category: &'static str,
}
/// Every library, in menu order.
pub fn libraries(_s: &Session) -> Vec<LibraryInfo> {
SWATCH_LIBRARIES.iter().map(|b| LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" }).collect()
}
/// Library `key` (an id, or a name in any case) with its info.
pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<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)?;
Some((info, lib))
}
fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<SwatchLibrary>)> {
let key = str_param(p, "library").ok_or_else(|| bad(cmd, "missing `library` (see swatch.library.list)"))?;
library(s, key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)")))
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
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})
})
.collect();
Ok(json!({ "libraries": libs }))
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let (info, lib) = library_param(s, p, "swatch.library.get")?;
let ungrouped = lib.swatches.iter().map(|w| swatch_json(w, None));
let grouped = lib.groups.iter().flat_map(|g| g.swatches.iter().map(|w| swatch_json(w, Some(&g.name))));
let groups: Vec<Value> =
lib.groups.iter().map(|g| json!({"name": g.name, "swatches": g.swatches.iter().map(|w| &w.name).collect::<Vec<_>>()})).collect();
Ok(
json!({"id": info.id, "name": info.name, "category": info.category, "swatches": ungrouped.chain(grouped).collect::<Vec<_>>(), "groups": groups}),
)
}
/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`:
/// ungrouped): a group's name brings the group, a swatch's name the swatch alone; no names, all.
fn picked<'a>(lib: &'a SwatchLibrary, names: &[String], cmd: &str) -> Result<Vec<(Option<&'a str>, &'a Swatch)>> {
let mut out: Vec<(Option<&str>, &Swatch)> = vec![];
let mut push = |g: Option<&'a str>, w: &'a Swatch| {
if !out.iter().any(|(_, x)| x.name == w.name) {
out.push((g, w));
}
};
if names.is_empty() {
lib.swatches.iter().for_each(|w| push(None, w));
lib.groups.iter().for_each(|g| g.swatches.iter().for_each(|w| push(Some(&g.name), w)));
}
for n in names {
match (lib.group(n), lib.swatch(n)) {
(Some(g), _) => g.swatches.iter().for_each(|w| push(Some(&g.name), w)),
(None, Some(w)) => push(None, w),
(None, None) => return Err(bad(cmd, format!("no swatch or colour group `{n}` in `{}`", lib.name))),
}
}
Ok(out)
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.library.add";
let (info, lib) = library_param(s, p, C)?;
let apply = match str_param(p, "apply") {
None => None,
Some("fill") => Some("paint.setFill"),
Some("stroke") => Some("paint.setStroke"),
Some(other) => return Err(bad(C, format!("apply is \"fill\" or \"stroke\", not `{other}`"))),
};
let picked = picked(&lib, &str_list(p, "names"), C)?;
// Swatches the document already has (same name and paint) are reused, not added again.
let d = &s.doc()?.doc;
let (existing, fresh): (Vec<_>, Vec<_>) = picked.iter().partition(|(_, w)| d.swatch(&w.name).is_some_and(|x| x.paint == w.paint));
let existing: Vec<String> = existing.iter().map(|(_, w)| w.name.clone()).collect();
let undo_before = s.doc()?.history.undo.len();
// Library name → the name it got in the document.
let added: Vec<(String, String)> = if fresh.is_empty() {
vec![]
} else {
s.edit("Add to Swatches", |d, _| {
let mut out = vec![];
for (group, w) in fresh {
let name = d.free_swatch_name(&w.name);
let sw = Swatch { name: name.clone(), ..(*w).clone() };
match group {
Some(g) => {
let i = match d.swatch_groups.iter().position(|x| x.name == *g) {
Some(i) => i,
None => {
let name = d.free_swatch_name(g);
d.swatch_groups.push(SwatchGroup { name, swatches: vec![] });
d.swatch_groups.len() - 1
}
};
d.swatch_groups[i].swatches.push(sw);
}
None => d.swatches.push(sw),
}
out.push((w.name.clone(), name));
}
Ok(out)
})?
};
let mut out = json!({"library": info.id, "added": added.iter().map(|(_, n)| n).collect::<Vec<_>>(), "existing": existing});
if let (Some(cmd), Some((_, first))) = (apply, picked.first()) {
let name = added.iter().find(|(l, _)| *l == first.name).map_or(first.name.clone(), |(_, n)| n.clone());
s.execute(cmd, &json!({"swatch": name, "focus": bool_or(p, "focus", true)}))?;
// Adding and applying undo together.
squash(s, undo_before, "Add to Swatches");
out["applied"] = json!(name);
}
Ok(out)
}
fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
let replace = bool_or(p, "replace", false);
let model = s.doc()?.doc.color_mode.model();
let (defaults, groups) = default_swatches(model);
// With replace, links to swatches that are no longer global ones are dropped (the art keeps
// its colour); filled in by the edit.
let mut live: Vec<String> = vec![];
let added = s.edit("Default Swatches", |d, _| {
let mut added = vec![];
if replace {
let before: Vec<String> = d.swatches_iter().map(|w| w.name.clone()).collect();
(d.swatches, d.swatch_groups) = (defaults, groups);
added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone()));
live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone()));
d.map_solid_paints(&mut |_, l| unlink_dead(&live, l));
return Ok(added);
}
for (i, w) in defaults.into_iter().enumerate() {
if !d.swatch_name_taken(&w.name) {
added.push(w.name.clone());
// None leads, as in a new document; the others go after the existing swatches.
let at = if i == 0 && w.paint.is_none() { 0 } else { d.swatches.len() };
d.swatches.insert(at, w);
}
}
for g in groups {
let i = match d.swatch_groups.iter().position(|x| x.name == g.name) {
Some(i) => i,
None if d.swatch_name_taken(&g.name) => continue,
None => {
d.swatch_groups.push(SwatchGroup { name: g.name.clone(), swatches: vec![] });
d.swatch_groups.len() - 1
}
};
for w in g.swatches {
if !d.swatch_name_taken(&w.name) {
added.push(w.name.clone());
d.swatch_groups[i].swatches.push(w);
}
}
}
Ok(added)
})?;
if replace {
map_default_paints(s, &mut |_, l| unlink_dead(&live, l));
}
Ok(json!({ "added": added }))
}
/// Drop a link to a swatch not in `live`; true when it did.
fn unlink_dead(live: &[String], link: &mut Option<String>) -> bool {
link.as_ref().is_some_and(|n| !live.contains(n)) && link.take().is_some()
}
+2
View File
@@ -802,6 +802,8 @@ mod tests_swatchcmds;
#[cfg(test)]
mod tests_swatches;
#[cfg(test)]
mod tests_swatchlib;
#[cfg(test)]
mod tests_textedit;
#[cfg(test)]
mod tests_xform;
+131
View File
@@ -0,0 +1,131 @@
//! Swatch libraries: listing, reading, adding to the document (deduped, one undo step, applied)
//! and Default Swatches.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn doc(s: &Session) -> &vectorcraft_doc::Document {
&s.doc().unwrap().doc
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn list_and_get_every_builtin_library() {
let mut s = Session::new();
let libs = run(&mut s, "swatch.library.list", json!({}));
let libs = libs["libraries"].as_array().unwrap();
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
for id in [
"web-safe-216",
"grays-neutrals",
"earth-tones",
"skin-tone-ramps",
"pastels",
"brights",
"metallic-gradients",
"perceptual-scales",
"harmony-sets",
] {
assert!(ids.contains(&id), "{id} missing from {ids:?}");
}
let web = libs.iter().find(|l| l["id"] == "web-safe-216").unwrap();
assert_eq!((web["name"].as_str(), web["count"].as_u64()), (Some("Web Safe 216"), Some(216)));
// By id or by name in any case; groups list their swatches.
let g = run(&mut s, "swatch.library.get", json!({"library": "earth tones"}));
assert_eq!(g["id"], "earth-tones");
let clay = g["groups"].as_array().unwrap().iter().find(|x| x["name"] == "Clay").unwrap();
assert_eq!(clay["swatches"].as_array().unwrap().len(), 6);
assert!(g["swatches"].as_array().unwrap().iter().all(|w| w["kind"] == "color" && w["group"].is_string()));
let m = run(&mut s, "swatch.library.get", json!({"library": "metallic-gradients"}));
assert!(m["swatches"].as_array().unwrap().iter().all(|w| w["kind"] == "gradient"));
assert!(s.execute("swatch.library.get", &json!({"library": "nope"})).is_err());
}
#[test]
fn add_dedupes_as_one_undo_step() {
let mut s = session();
let before = undo_len(&s);
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Clay", "Ochre 2", "Clay 3"]}));
// The Clay group comes whole (its third swatch once); Ochre 2 comes ungrouped.
let added: Vec<&str> = r["added"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(added.len(), 7, "{added:?}");
assert_eq!(undo_len(&s), before + 1, "one undo step");
let d = doc(&s);
assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Clay").map(|g| g.swatches.len()), Some(6));
assert!(d.swatches.iter().any(|w| w.name == "Ochre 2"));
// Adding again finds them all and changes nothing (no undo step).
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Clay", "Ochre 2"]}));
assert_eq!((r["added"].as_array().unwrap().len(), r["existing"].as_array().unwrap().len()), (0, 7));
assert_eq!(undo_len(&s), before + 1);
// A name taken by a different colour gets a number.
run(&mut s, "swatch.new", json!({"name": "Ochre 3", "color": "#123456"}));
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Ochre 3"]}));
assert_eq!(r["added"], json!(["Ochre 3 2"]));
// Undo removes the whole first add.
for _ in 0..3 {
run(&mut s, "edit.undo", json!({}));
}
assert!(doc(&s).swatch_groups.iter().all(|g| g.name != "Clay") && doc(&s).swatch("Ochre 2").is_none());
assert!(s.execute("swatch.library.add", &json!({"library": "earth-tones", "names": ["Nope"]})).is_err());
}
#[test]
fn add_with_apply_paints_the_selection_in_the_same_undo_step() {
let mut s = session();
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
let before = undo_len(&s);
let r = run(&mut s, "swatch.library.add", json!({"library": "web-safe-216", "names": ["#FF6600"], "apply": "fill"}));
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["#FF6600"]), json!("#FF6600")));
let fill = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
assert_eq!(fill.color().map(|c| c.to_hex()), Some("#ff6600".into()));
assert_eq!(undo_len(&s), before + 1, "added and applied as one step");
run(&mut s, "edit.undo", json!({}));
assert!(doc(&s).swatch("#FF6600").is_none());
// A gradient swatch applied to the stroke; an existing swatch is applied without an add.
run(&mut s, "swatch.library.add", json!({"library": "metallic-gradients", "names": ["Gold"], "apply": "stroke"}));
let n = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap();
assert!(matches!(n.appearance.stroke_paint(), Paint::Gradient(_)));
let r = run(&mut s, "swatch.library.add", json!({"library": "metallic-gradients", "names": ["Gold"], "apply": "fill"}));
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!([]), json!("Gold")));
}
#[test]
fn reset_defaults_restores_missing_swatches_or_replaces_them() {
let mut s = session();
let defaults: Vec<String> = doc(&s).swatches_iter().map(|w| w.name.clone()).collect();
run(&mut s, "swatch.delete", json!({"names": ["Red", "Grays"]}));
run(&mut s, "swatch.new", json!({"name": "Mine", "color": "#abcdef"}));
let r = run(&mut s, "swatch.resetDefaults", json!({}));
assert!(r["added"].as_array().unwrap().iter().any(|n| n == "Red"));
let d = doc(&s);
assert!(d.swatch("Mine").is_some(), "the user's swatches stay");
assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Grays").map(|g| g.swatches.len()), Some(9));
assert!(defaults.iter().all(|n| d.swatch(n).is_some()));
// Replace: exactly the defaults; art linked to a removed global swatch keeps its colour.
run(&mut s, "swatch.edit", json!({"name": "Mine", "global": true}));
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [id]}));
run(&mut s, "paint.setFill", json!({"swatch": "Mine"}));
assert_eq!(s.paint.fill, Paint::Solid { color: Color::from_hex("#abcdef").unwrap(), swatch: Some("Mine".into()) });
run(&mut s, "swatch.resetDefaults", json!({"replace": true}));
let d = doc(&s);
assert_eq!(d.swatches_iter().map(|w| w.name.clone()).collect::<Vec<_>>(), defaults);
let fill = d.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
assert_eq!(fill, Paint::solid(Color::from_hex("#abcdef").unwrap()));
assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#abcdef").unwrap()), "the default fill is unlinked too");
}