M3.95: Color Guide Limit to Library and Edit or Apply Colors without art

The Color Guide's library button opens a Limit to Library popup (None,
Document Swatches, the colour libraries): every harmony colour and
variation snaps to the nearest colour there (CIEDE2000), cached per
inputs so the grid isn't re-snapped every frame. `color.harmony` takes
`limitTo`, `ui.colorGuideLimit` sets the panel's limit, and Recolor
Artwork's Limit to Library also offers the document's swatches (one
shared engine helper, swatchlib::limit_palette).

Edit or Apply Colors opens Recolor with the harmony colours limited to
the same library; with no art selected the colours are the rows and OK
saves them as a new colour group.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 05:08:15 +07:00
1 parent 96a1a02436
commit 81582c8d4f
14 files changed
+430 -71

No files matched your search

+39
View File
@@ -2,6 +2,7 @@
use serde::{Deserialize, Serialize};
use crate::recolor::Palette;
use crate::{Color, keep_model};
/// A harmony rule: a group of colours built from a base colour, base first.
@@ -293,6 +294,23 @@ impl Guide {
pub fn centre(&self) -> usize {
self.grid.first().map_or(0, |r| r.len() / 2)
}
/// The guide limited to `palette` (Limit to Library): every harmony colour and variation snaps
/// to the palette's nearest colour (CIEDE2000), in that colour's own model.
pub fn limited(mut self, palette: &Palette) -> Self {
if palette.is_empty() {
return self;
}
let centre = self.centre();
for (row, c) in self.grid.iter_mut().zip(&mut self.colors) {
for v in row.iter_mut() {
*v = palette.nearest(*v);
}
// The centre column is the harmony colour itself.
*c = row[centre];
}
self
}
}
#[cfg(test)]
@@ -382,4 +400,25 @@ mod tests {
let g = Guide::new(base, Harmony::Triad, &GuideOptions { steps: 99, ..Default::default() });
assert_eq!(g.grid[0].len(), 41);
}
#[test]
fn a_limited_guide_only_holds_palette_colours() {
let lib =
[Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5), Color::rgb8(250, 240, 200), Color::lab(30.0, 10.0, -40.0)];
let palette = Palette::new(lib);
for rule in Harmony::ALL {
let g = Guide::new(Color::rgb8(200, 60, 40), rule, &GuideOptions::default()).limited(&palette);
assert!(g.grid.iter().flatten().chain(&g.colors).all(|c| lib.contains(c)), "{}", rule.label());
for (row, c) in g.grid.iter().zip(&g.colors) {
assert_eq!(row[g.centre()], *c, "{}: the harmony colour stays in the centre", rule.label());
}
}
// The nearest colour wins: a pale yellow snaps to the cream and its darkest shade to the grey.
let g = Guide::new(Color::rgb(0.95, 0.9, 0.75), Harmony::Complementary, &GuideOptions::default()).limited(&palette);
assert_eq!(g.colors[0], lib[3]);
assert_eq!(g.grid[0][0], lib[2]);
// An empty palette limits nothing.
let free = Guide::new(Color::rgb(0.8, 0.1, 0.1), Harmony::Triad, &GuideOptions::default());
assert_eq!(free.clone().limited(&Palette::default()), free);
}
}
+6 -3
View File
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Color Guide",
[],
None,
"{color, rule: complementary|complementary2|splitComplementary|leftComplement|rightComplement|analogous|analogous2|monochromatic|shades|triad|triad2|triad3|tetrad|tetrad2|tetrad3|compound|compound2|highContrast|highContrast2|highContrast3|pentagram (or its label), steps?: 1..20 (4), variation?: \"tintsShades\"|\"warmCool\"|\"vividMuted\", amount?: 0..100 (50; how far the outermost steps go)} the Color Guide for a base colour → {rule, colors: [\"#rrggbb\", the base first], grid: [per colour, 2·steps+1 variations from shades/cool/muted to tints/warm/vivid with the colour itself in the centre]}",
"{color, rule: complementary|complementary2|splitComplementary|leftComplement|rightComplement|analogous|analogous2|monochromatic|shades|triad|triad2|triad3|tetrad|tetrad2|tetrad3|compound|compound2|highContrast|highContrast2|highContrast3|pentagram (or its label), steps?: 1..20 (4), variation?: \"tintsShades\"|\"warmCool\"|\"vividMuted\", amount?: 0..100 (50; how far the outermost steps go), limitTo?: swatch library id or name, or \"document\" (the document's swatches): every colour snaps to its nearest colour there (ΔE 2000)} the Color Guide for a base colour → {rule, colors: [\"#rrggbb\", the base first], grid: [per colour, 2·steps+1 variations from shades/cool/muted to tints/warm/vivid with the colour itself in the centre]}",
always,
harmony
),
@@ -539,7 +539,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
Ok(json!({ "changed": changed }))
}
fn harmony(_: &mut Session, p: &Value) -> Result<Value> {
fn harmony(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "color.harmony";
let base = p.get("color").and_then(color_value).ok_or_else(|| bad(C, "missing or invalid `color`"))?;
let rule = str_param(p, "rule").ok_or_else(|| bad(C, "missing `rule`"))?;
@@ -557,7 +557,10 @@ fn harmony(_: &mut Session, p: &Value) -> Result<Value> {
if let Some(a) = p.get("amount").and_then(Value::as_f64) {
opts.amount = a as f32;
}
let g = Guide::new(base, rule, &opts);
let mut g = Guide::new(base, rule, &opts);
if let Some(palette) = super::swatchlib::limit_param(s, p, C)? {
g = g.limited(&palette);
}
let hex = |cs: &[Color]| cs.iter().map(Color::to_hex).collect::<Vec<_>>();
Ok(json!({ "rule": rule.id(), "colors": hex(&g.colors), "grid": g.grid.iter().map(|r| hex(r)).collect::<Vec<_>>() }))
}
+5 -16
View File
@@ -6,7 +6,7 @@
use std::collections::HashMap;
use serde_json::{Value, json};
use vectorcraft_color::recolor::{ColorKey, Method, Palette, Preserve, Rng, RowMap, cluster};
use vectorcraft_color::recolor::{ColorKey, Method, Preserve, Rng, RowMap, cluster};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::swatches::node_colors;
use vectorcraft_doc::{AppearanceItem, Document, NodeId, NodeKind};
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library (new colours snap to its nearest colour), group?: colour group rewritten with groupColors?: [colour] (default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) and the group, as one undo step → {changed}",
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name, or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group rewritten with groupColors?: [colour] (default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) and the group, as one undo step → {changed}",
has_doc,
apply
),
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reduce Colors",
[],
None,
"{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply, default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: swatch library (new colours snap to it)} group the selection's colours into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}",
"{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply, default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: as recolor.apply (new colours snap to it)} group the selection's colours into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}",
has_selection,
reduce
),
@@ -134,17 +134,6 @@ fn method_param(p: &Value, cmd: &str, default: Method) -> Result<Method> {
}
}
/// The colours of the `limitTo` library (`None` without one).
fn palette_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Palette>> {
let Some(key) = str_param(p, "limitTo").filter(|k| !k.is_empty()) else { return Ok(None) };
let (_, lib) = super::swatchlib::library(s, key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)")))?;
let palette = Palette::new(lib.iter().filter_map(|w| w.paint.color()));
if palette.is_empty() {
return Err(bad(cmd, format!("swatch library `{key}` has no colours")));
}
Ok(Some(palette))
}
fn key_str(c: &Color) -> Value {
json!(ColorKey::of(c).to_string())
}
@@ -152,7 +141,7 @@ fn key_str(c: &Color) -> Value {
fn reduce(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "recolor.reduce";
let method = method_param(p, C, Method::ScaleTints)?;
let palette = palette_param(s, p, C)?;
let palette = super::swatchlib::limit_param(s, p, C)?;
let def = Preserve::default();
let flag = |k: &str, d: bool| p.get("preserve").map_or(d, |v| bool_or(v, k, d));
let preserve = Preserve { white: flag("white", def.white), black: flag("black", def.black), grays: flag("grays", def.grays) };
@@ -297,7 +286,7 @@ impl ColorMap {
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "recolor.apply";
let method = method_param(p, C, Method::Exact)?;
let palette = palette_param(s, p, C)?;
let palette = super::swatchlib::limit_param(s, p, C)?;
let mut rows = parse_rows(p, C)?;
if let Some(pl) = &palette {
for r in &mut rows {
+26
View File
@@ -9,6 +9,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::libraries::{BuiltinLibrary, GRADIENT_LIBRARIES, SWATCH_LIBRARIES, builtin_library};
use vectorcraft_color::palette_io::{self, PaletteFormat};
use vectorcraft_color::recolor::Palette;
use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches};
use super::fileio::{create_dir, read_file, write_file};
@@ -267,6 +268,31 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<SwatchLibrary
library(s, &id)
}
/// The Limit to Library key for the active document's own swatches.
pub const DOCUMENT_SWATCHES: &str = "document";
/// The colours Limit to Library snaps to: the solid colours of library `key` (an id or name) or, for
/// [`DOCUMENT_SWATCHES`], of the active document's swatches and colour groups. `None` when there is
/// no such library (or document).
pub fn limit_palette(s: &Session, key: &str) -> Option<Palette> {
let colors = |sw: &Swatch| sw.paint.color();
if key == DOCUMENT_SWATCHES {
return Some(Palette::new(s.active()?.doc.swatches_iter().filter_map(colors)));
}
Some(Palette::new(library(s, key)?.1.iter().filter_map(colors)))
}
/// The `limitTo` parameter's palette ([`limit_palette`]); `None` without one (or for "").
pub(crate) fn limit_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Palette>> {
let Some(key) = str_param(p, "limitTo").filter(|k| !k.is_empty()) else { return Ok(None) };
let palette = limit_palette(s, key)
.ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list; \"{DOCUMENT_SWATCHES}\": the document's swatches)")))?;
if palette.is_empty() {
return Err(bad(cmd, format!("`{key}` has no colours")));
}
Ok(Some(palette))
}
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)")))
+2
View File
@@ -849,6 +849,8 @@ mod tests_cmykflatten;
#[cfg(test)]
mod tests_colorguide;
#[cfg(test)]
mod tests_colorguidelib;
#[cfg(test)]
mod tests_colormgmt;
#[cfg(test)]
mod tests_containers;
+70
View File
@@ -0,0 +1,70 @@
//! The Color Guide's Limit to Library: `color.harmony {limitTo}` snaps every colour to a library or
//! to the document's swatches, as Recolor Artwork's `limitTo` does.
use std::collections::HashSet;
use serde_json::{Value, json};
use super::*;
fn hexes(v: &Value) -> Vec<String> {
v.as_array().unwrap().iter().map(|h| h.as_str().unwrap().to_string()).collect()
}
/// Every colour `color.harmony` answers: the harmony colours and the whole grid.
fn all_colours(r: &Value) -> Vec<String> {
let mut out = hexes(&r["colors"]);
for row in r["grid"].as_array().unwrap() {
out.extend(hexes(row));
}
out
}
fn library_hexes(s: &mut Session, library: &str) -> HashSet<String> {
let lib = s.execute("swatch.library.get", &json!({ "library": library })).unwrap();
lib["swatches"].as_array().unwrap().iter().filter_map(|w| w["hex"].as_str()).map(str::to_string).collect()
}
#[test]
fn every_guide_colour_is_a_library_member() {
let mut s = Session::new();
for library in ["web-safe-216", "earth-tones"] {
let members = library_hexes(&mut s, library);
for rule in ["triad", "monochromatic", "highContrast3", "pentagram"] {
let r = s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": rule, "steps": 6, "limitTo": library})).unwrap();
let out = all_colours(&r);
assert_eq!(out.len(), r["colors"].as_array().unwrap().len() * 14);
assert!(out.iter().all(|h| members.contains(h)), "{library} {rule}: {out:?}");
}
}
// A name works as well as an id; "" and no limit leave the colours free.
let by_name = s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": "Web Safe 216"})).unwrap();
let by_id = s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": "web-safe-216"})).unwrap();
assert_eq!(by_name, by_id);
let free = s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": ""})).unwrap();
assert_eq!(free["colors"][0], "#3a7bd5");
assert_ne!(by_id["colors"][0], "#3a7bd5", "a library without the base snaps it too");
assert!(s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": "nope"})).is_err());
assert!(s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": "document"})).is_err(), "no document");
}
#[test]
fn document_swatches_limit_the_guide_and_recolor() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let doc_hexes =
|s: &Session| -> HashSet<String> { s.doc().unwrap().doc.swatches_iter().filter_map(|w| w.paint.color()).map(|c| c.to_hex()).collect() };
s.execute("swatch.newGroup", &json!({"name": "Mine", "colors": ["#123456", "#fedcba"]})).unwrap();
let members = doc_hexes(&s);
assert!(members.contains("#123456"), "colour groups count");
let r = s.execute("color.harmony", &json!({"color": "#103050", "rule": "splitComplementary", "limitTo": "document"})).unwrap();
assert!(all_colours(&r).iter().all(|h| members.contains(h)), "{r}");
assert_eq!(r["colors"][0], "#123456", "the base snaps to the nearest swatch");
// Recolor Artwork limits to the document's swatches the same way.
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
s.execute("paint.setFill", &json!({"color": "#112255"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let r = s.execute("recolor.reduce", &json!({"limitTo": "document"})).unwrap();
let to = cmd::color_value(&r["map"][0]["to"]).unwrap().to_hex();
assert!(members.contains(&to) && to != "#112255", "{r}");
}
+44 -27
View File
@@ -9,22 +9,26 @@
//! scale together), a brightness slider and the harmony rules. The preset menu sets up a 1, 2 or 3
//! colour job or a library. Opened on a colour group (Edit or Apply Color Group), OK also rewrites
//! the group with the new colours (renamed by `groupName`); without art only the group changes.
//! Opened on colours alone (the Color Guide's Edit or Apply Colors without art), OK saves the new
//! colours as a colour group named `groupName`.
//!
//! Fields (`ui.dialog.set`): `rows` ([{from: [keys], to: key, exclude?}]), `colors` (null: Auto, or a
//! count), `method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo` (library id, "" for
//! none), `group`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`,
//! `preview`. Changing `colors`, a preserve flag or `limitTo` reduces the rows again.
//! count), `method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo` (library id,
//! "document" for the document's swatches, "" for none), `group`, `groupName`, `tab` (`assign` or
//! `edit`), `rule` (harmony id), `linked`, `preview`. Changing `colors`, a preserve flag or
//! `limitTo` reduces the rows again.
use egui::{Pos2, Rect, Sense, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_color::harmony::Harmony;
use vectorcraft_color::recolor::{ColorKey, Method, Palette};
use vectorcraft_color::recolor::{ColorKey, Method};
use vectorcraft_color::{Color, keep_model};
use vectorcraft_engine::cmd::color_value;
use vectorcraft_engine::cmd::swatchlib;
use super::{DialogSpec, form};
use crate::panels::c32;
use crate::panels::swatches::{colour_libraries, limit_key, limit_name};
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
@@ -48,8 +52,9 @@ const PRESETS: [&str; 5] = ["Custom", "1 Color Job", "2 Color Job", "3 Color Job
/// Open Recolor Artwork (`ui.recolorDialog {colors?, library?, group?}`) on the selected art and,
/// with `group`, on that colour group: art is reduced to as many rows as the group has colours,
/// which become their new colours; without art the rows are the group's colours. `colors` is a
/// count (an n-colour job) or the new colours to assign; `library` limits to a library ("" picks
/// the first).
/// count (an n-colour job) or the new colours to assign (without art or a group they are the
/// rows, which OK saves as a new colour group); `library` limits to a library or "document" (""
/// picks the first library).
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let art = !st.selection.is_empty();
@@ -61,14 +66,16 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
}
None => None,
};
if !art && group.is_none() {
return Err("select artwork (or a colour group) to recolor".into());
}
let assign: Vec<Value> = match (&group_colors, p.get("colors")) {
(Some(cs), _) => cs.iter().map(key).collect(),
(None, Some(Value::Array(cs))) => cs.iter().filter_map(color_value).map(|c| key(&c)).collect(),
_ => vec![],
};
// Colours alone (no art, no group) become a new colour group.
let new_group = !art && group.is_none();
if new_group && assign.is_empty() {
return Err("select artwork (or a colour group) to recolor".into());
}
let count = match p.get("colors").and_then(Value::as_u64) {
Some(n) => json!(n.max(1)),
None if !assign.is_empty() => json!(assign.len()),
@@ -76,18 +83,23 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
};
let limit = match p.get("library").and_then(Value::as_str) {
Some("") => first_library(app).unwrap_or_default(),
Some(l) => swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}`"))?,
Some(l) => limit_key(app, l)?,
None => String::new(),
};
// Without art the rows are the group's colours, as they are.
let rows: Vec<Value> = if art { vec![] } else { assign.iter().map(|k| json!({"from": [k], "to": k})).collect() };
let start: Vec<Value> = if art { vec![] } else { assign.iter().map(|k| json!({"from": [k], "to": k})).collect() };
let fields = json!({
"rows": rows, "colors": count, "method": Method::ScaleTints.id(),
"rows": start, "colors": count, "method": Method::ScaleTints.id(),
"preserveWhite": true, "preserveBlack": true, "preserveGrays": false, "limitTo": limit,
"group": group, "groupName": group, "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true,
"preview": true, "__art": art, "__assign": assign,
"group": group, "groupName": if new_group { Some(NEW_GROUP) } else { group }, "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true,
"preview": true, "__art": art, "__assign": assign, "__newGroup": new_group,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
let mut d = Dialog::new(KIND, fields);
// Without art the rows are there from the start: limit them now (art's rows snap as they reduce).
let mut rs = rows(&d);
snap(app, &d, &mut rs);
d.fields.insert("rows".into(), Value::Array(rs));
app.ui.dialog = Some(d);
Ok(Value::Null)
}
@@ -95,13 +107,11 @@ fn key(c: &Color) -> Value {
json!(ColorKey::of(c).to_string())
}
/// The libraries with colours, for Limit to Library: (id, name).
fn colour_libraries(app: &VectorcraftApp) -> Vec<(String, String)> {
swatchlib::libraries(&app.session).into_iter().filter(|l| l.category != "gradients").map(|l| (l.id, l.name)).collect()
}
/// The name a new colour group (opened on colours alone) starts with.
const NEW_GROUP: &str = "Color Group";
fn first_library(app: &VectorcraftApp) -> Option<String> {
colour_libraries(app).into_iter().next().map(|l| l.0)
colour_libraries(app).into_iter().next().map(|l| l.id)
}
/// `recolor.reduce` parameters (Method only picks each row's colour, so changing it keeps the rows).
@@ -141,8 +151,7 @@ fn sync(app: &mut VectorcraftApp, d: &mut Dialog) {
/// With Limit to Library, the rows' new colours snap to the library's nearest colours.
fn snap(app: &VectorcraftApp, d: &Dialog, rows: &mut [Value]) {
let Some((_, lib)) = Some(d.str("limitTo")).filter(|l| !l.is_empty()).and_then(|l| swatchlib::library(&app.session, &l)) else { return };
let palette = Palette::new(lib.iter().filter_map(|w| w.paint.color()));
let Some(palette) = Some(d.str("limitTo")).filter(|l| !l.is_empty()).and_then(|l| swatchlib::limit_palette(&app.session, &l)) else { return };
for r in rows {
if let Some(c) = to_color(r) {
r["to"] = key(&palette.nearest(c));
@@ -216,7 +225,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
snap(app, d, &mut rs);
d.fields.insert("rows".into(), Value::Array(rs));
}
if d.fields.get("group").is_some_and(Value::is_string) {
if d.fields.get("group").is_some_and(Value::is_string) || d.bool("__newGroup") {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Color Group:").color(t.text_dim));
@@ -230,10 +239,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
false
}
/// OK: keep the previewed recolour as one undo step (or apply it, e.g. to a colour group only).
/// OK: keep the previewed recolour as one undo step (or apply it, e.g. to a colour group only; or
/// save colours opened alone as a new colour group).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut d = d.clone();
sync(app, &mut d);
if d.bool("__newGroup") {
let colors: Vec<Value> = rows(&d).into_iter().filter(|r| !excluded(r)).map(|r| r["to"].clone()).collect();
return form::commit_preview(app, "swatch.newGroup", json!({"name": d.str("groupName"), "colors": colors}));
}
form::commit_preview(app, CMD, apply_params(&d))
}
@@ -407,12 +421,15 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
}
ui.end_row();
ui.label(egui::RichText::new("Limit to Library:").color(t.text_dim));
// None, the document's swatches, then the colour libraries.
let libs = colour_libraries(app);
let keys: Vec<&str> = ["", swatchlib::DOCUMENT_SWATCHES].into_iter().chain(libs.iter().map(|l| l.id.as_str())).collect();
let labels: Vec<String> = keys.iter().map(|k| limit_name(app, k).unwrap_or_else(|| "None".into())).collect();
let cur = d.str("limitTo");
let name = libs.iter().find(|l| l.0 == cur).map_or("None", |l| l.1.as_str());
let labels: Vec<&str> = std::iter::once("None").chain(libs.iter().map(|l| l.1.as_str())).collect();
let name = keys.iter().position(|k| *k == cur).map_or("None", |i| labels[i].as_str());
let labels: Vec<&str> = labels.iter().map(String::as_str).collect();
if let Some(i) = widgets::dropdown(ui, "recolor-library", name, &labels, 180.0) {
d.fields.insert("limitTo".into(), json!(if i == 0 { String::new() } else { libs[i - 1].0.clone() }));
d.fields.insert("limitTo".into(), json!(keys[i]));
}
ui.end_row();
});
+8 -1
View File
@@ -162,7 +162,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.recolorDialog",
"Recolor Artwork…",
"",
"{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order), library?: id or name (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)",
"{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name, or \"document\" (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)",
),
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
("file.export.pdf", "Save as PDF…", "", "{path?, artboard? | artboards? | range?: \"1-3, 5\"} (document.export options)"),
@@ -344,6 +344,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{id, index} open Width Point Edit for width point `index` of path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking a width point with the Width tool opens it too): OK runs stroke.widthPoint.set, discard: true (the Delete button) stroke.widthPoint.remove",
),
(
"ui.colorGuideLimit",
"Limit Color Guide to Library",
"",
"{library: swatch library id or name (see swatch.library.list) | \"document\" (the document's swatches) | \"\" or null (no limit)} limit the Color Guide panel's colours to that library: every harmony colour and variation snaps to its nearest colour, as color.harmony {limitTo} answers (ui.inspect: ui.color_guide_limit) → {limitTo, name}",
),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@@ -714,6 +720,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()),
},
"ui.widthPointEdit" => crate::dialogs::width_point::open(app, p),
"ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p),
_ => return None,
};
Some(r)
+166 -10
View File
@@ -3,14 +3,18 @@
//! shades/cool/muted to the left and tints/warm/vivid to the right (steps and reach from Color
//! Guide Options). The base colour stays put until "Set base color" takes the current colour. A
//! click on a cell applies it to the active proxy and selects it; Shift- or Cmd/Ctrl-click adds
//! cells to the selection that Save Colors as Swatches saves.
//! cells to the selection that Save Colors as Swatches saves. Limit to Library (the library button,
//! `ui.colorGuideLimit`) snaps every colour to a swatch library or the document's swatches; Edit or
//! Apply Colors opens Recolor Artwork with the harmony colours, limited the same way.
use egui::{Rect, Sense, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_color::harmony::{Guide, Harmony, Variation};
use vectorcraft_color::harmony::{Guide, GuideOptions, Harmony, Variation};
use vectorcraft_color::{Color, Paint};
use vectorcraft_engine::cmd::swatchlib;
use super::{active_paint, apply_click, c32, color_json, pstate, set_pstate};
use super::swatches::{self, colour_libraries, limit_key, limit_name};
use super::{active_paint, apply_click, c32, color_json, library_panel, pstate, set_pstate};
use crate::VectorcraftApp;
use crate::widgets::{self, menu_item};
@@ -41,8 +45,79 @@ fn clear_selection(ctx: &egui::Context) {
set_pstate(ctx, "cg-sel", Vec::<(usize, usize)>::new());
}
/// What the panel's guide is made from: with a library limit, snapping every cell is too slow to
/// redo each frame, so the guide is cached until one of these changes.
#[derive(Clone, PartialEq)]
struct GuideKey {
base: Color,
rule: Harmony,
opts: GuideOptions,
limit: String,
/// The document's identity and revision while limited to its swatches.
doc: Option<(u64, u64)>,
}
/// The guide the panel shows, limited to the Limit to Library colours.
fn guide(app: &VectorcraftApp, ctx: &egui::Context) -> Guide {
Guide::new(base_color(app, ctx), rule(ctx), &app.ui.color_guide)
let (base, rule, opts) = (base_color(app, ctx), rule(ctx), app.ui.color_guide);
let limit = &app.ui.color_guide_limit;
if limit.is_empty() {
return Guide::new(base, rule, &opts);
}
let doc = app.session.active().filter(|_| limit == swatchlib::DOCUMENT_SWATCHES).map(|d| (d.uid, d.revision));
let key = GuideKey { base, rule, opts, limit: limit.clone(), doc };
if let Some((k, g)) = pstate::<Option<(GuideKey, Guide)>>(ctx, "cg-guide")
&& k == key
{
return g;
}
let g = Guide::new(base, rule, &opts);
let g = match swatchlib::limit_palette(&app.session, limit) {
Some(p) => g.limited(&p),
None => g,
};
set_pstate(ctx, "cg-guide", Some((key, g.clone())));
g
}
/// `ui.colorGuideLimit {library}`: limit the panel's colours to a swatch library (an id or name),
/// the document's swatches ("document"), or nothing (`""` or null). → {limitTo, name}
pub(crate) fn set_limit(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let key = match p.get("library").and_then(Value::as_str).unwrap_or_default() {
"" => String::new(),
l => limit_key(app, l)?,
};
let name = limit_name(app, &key);
app.ui.color_guide_limit = key;
Ok(json!({"limitTo": app.ui.color_guide_limit, "name": name}))
}
/// The Limit to Library popup: None, Document Swatches, then the colour libraries.
fn limit_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.set_min_width(200.0);
let cur = app.ui.color_guide_limit.clone();
let mut chosen = None;
for (key, label) in [("", "None"), (swatchlib::DOCUMENT_SWATCHES, swatches::DOCUMENT_SWATCHES)] {
if menu_item(ui, label, true, cur == key) {
chosen = Some(key.to_string());
}
}
ui.separator();
chosen = library_panel::library_items(ui, &colour_libraries(app), Some(&cur)).or(chosen);
if let Some(key) = chosen {
set_limit(app, &json!({ "library": key })).ok();
clear_selection(ui.ctx());
}
}
/// Edit or Apply Colors: Recolor Artwork with the harmony colours as the new colours (of the
/// selected art, or of the colours alone), limited to the panel's library.
fn edit_or_apply(app: &mut VectorcraftApp, g: &Guide) {
let mut p = json!({ "colors": g.colors.iter().map(color_json).collect::<Vec<_>>() });
if limit_name(app, &app.ui.color_guide_limit).is_some() {
p["library"] = json!(app.ui.color_guide_limit);
}
app.run("ui.recolorDialog", p).ok();
}
/// Save the selected cells' colours as swatches (one undo step).
@@ -125,18 +200,28 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
let (left, right) = app.ui.color_guide.variation.sides();
let limit = limit_name(app, &app.ui.color_guide_limit);
ui.horizontal(|ui| {
widgets::dim_label(ui, left);
ui.add_space((ui.available_width() - 40.0).max(0.0));
// The library the colours are limited to, centred between the ends.
let middle = ui.available_width() - 40.0;
let label = limit.as_deref().unwrap_or_default();
ui.add_sized([middle.max(0.0), 16.0], egui::Label::new(egui::RichText::new(label).size(11.0).color(t.text_dim)).truncate());
widgets::dim_label(ui, right);
});
widgets::bottom_bar(ui, |ui| {
widgets::icon_button_enabled(ui, "library", "Limit to Swatch Library (on the roadmap)", false, false, 24.0);
let tip = match &limit {
Some(name) => format!("Limit colors to swatch library: {name}"),
None => "Limit colors to swatch library".into(),
};
let lr = widgets::icon_button(ui, "library", &tip, limit.is_some(), 24.0);
egui::Popup::menu(&lr).show(|ui| limit_menu(app, ui));
ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0));
// Recolor Artwork with the harmony colours as the new colours (the selected art's, if any).
let art = app.session.active().is_some_and(|d| !d.selection.is_empty());
if widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors", false, art, 24.0).clicked() {
app.run("ui.recolorDialog", json!({ "colors": g.colors.iter().map(color_json).collect::<Vec<_>>() })).ok();
// Recolor Artwork with the harmony colours as the new colours: the selected art's, or
// (without art) the colours themselves, which OK saves as a colour group.
let doc = app.session.active().is_some();
if widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors", false, doc, 24.0).clicked() {
edit_or_apply(app, &g);
}
if widgets::icon_button_enabled(ui, "dc-new-item", "Save selected colors as swatches", false, !sel.is_empty(), 24.0).clicked() {
save_selected(app, &ctx);
@@ -222,4 +307,75 @@ mod tests {
let g = guide(&app, &ctx);
assert_eq!(st.doc.swatches.last().unwrap().paint.color().map(|c| c.to_hex()), Some(g.grid[0][8].to_hex()));
}
/// Every colour of the panel's guide.
fn shown(app: &VectorcraftApp, ctx: &egui::Context) -> Vec<String> {
let g = guide(app, ctx);
g.grid.iter().flatten().chain(&g.colors).map(Color::to_hex).collect()
}
fn library_hexes(app: &VectorcraftApp, id: &str) -> Vec<String> {
let (_, lib) = swatchlib::library(&app.session, id).unwrap();
lib.iter().filter_map(|w| w.paint.color()).map(|c| c.to_hex()).collect()
}
#[test]
fn limit_to_library_snaps_the_panel_and_follows_the_document_swatches() {
let mut app = app();
let ctx = egui::Context::default();
app.run("paint.setFill", json!({"color": "#3a7bd5"})).unwrap();
frame(&mut app, &ctx);
assert!(shown(&app, &ctx).contains(&"#3a7bd5".to_string()), "no limit: the base itself");
let r = app.run("ui.colorGuideLimit", json!({"library": "Earth Tones"})).unwrap();
assert_eq!((r["limitTo"].as_str(), r["name"].as_str()), (Some("earth-tones"), Some("Earth Tones")));
frame(&mut app, &ctx);
let members = library_hexes(&app, "earth-tones");
assert!(shown(&app, &ctx).iter().all(|h| members.contains(h)), "every cell is a library colour");
// The document's swatches: the cached guide follows a new swatch.
app.run("ui.colorGuideLimit", json!({"library": "document"})).unwrap();
frame(&mut app, &ctx);
assert!(!shown(&app, &ctx).contains(&"#3a7bd4".to_string()));
app.run("swatch.new", json!({"colors": ["#3a7bd4"]})).unwrap();
frame(&mut app, &ctx);
let doc: Vec<String> = app.session.doc().unwrap().doc.swatches_iter().filter_map(|w| w.paint.color()).map(|c| c.to_hex()).collect();
let cells = shown(&app, &ctx);
assert!(cells.iter().all(|h| doc.contains(h)));
assert_eq!(guide(&app, &ctx).colors[0].to_hex(), "#3a7bd4", "the base snaps to the new swatch");
// The popup lists libraries with colours only, draws; unknown libraries are refused and ""
// lifts the limit.
let libs = colour_libraries(&app);
assert!(libs.iter().any(|l| l.id == "earth-tones") && !libs.iter().any(|l| l.id == "metallic-gradients"));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| limit_menu(&mut app, ui));
out.textures_delta.clear();
assert!(app.run("ui.colorGuideLimit", json!({"library": "nope"})).is_err());
assert_eq!(app.ui.color_guide_limit, "document");
assert_eq!(app.run("ui.colorGuideLimit", json!({"library": ""})).unwrap()["name"], Value::Null);
assert!(shown(&app, &ctx).contains(&"#3a7bd5".to_string()));
}
#[test]
fn edit_or_apply_without_art_saves_the_limited_colours_as_a_group() {
let mut app = app();
let ctx = egui::Context::default();
app.run("paint.setFill", json!({"color": "#c04020"})).unwrap();
app.run("select.none", json!({})).unwrap();
app.run("ui.colorGuideLimit", json!({"library": "web-safe-216"})).unwrap();
frame(&mut app, &ctx);
let g = guide(&app, &ctx);
edit_or_apply(&mut app, &g);
let d = app.ui.dialog.as_ref().expect("Recolor opens on the colours alone");
assert_eq!((d.kind.as_str(), d.str("limitTo").as_str()), ("recolor", "web-safe-216"), "limited to the same library");
let groups = app.session.doc().unwrap().doc.swatch_groups.len();
crate::dialogs::confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
assert_eq!(doc.swatch_groups.len(), groups + 1, "OK saves a new colour group");
let made: Vec<String> = doc.swatch_groups.last().unwrap().swatches.iter().filter_map(|w| w.paint.color()).map(|c| c.to_hex()).collect();
assert_eq!(made, g.colors.iter().map(Color::to_hex).collect::<Vec<_>>());
let members = library_hexes(&app, "web-safe-216");
assert!(made.iter().all(|h| members.contains(h)));
// Colours from elsewhere snap to the library as the dialog opens.
app.run("ui.recolorDialog", json!({"colors": ["#123457"], "library": "web-safe-216"})).unwrap();
let to = app.ui.dialog.as_ref().unwrap().fields["rows"][0]["to"].clone();
assert_eq!(vectorcraft_engine::cmd::color_value(&to).map(|c| c.to_hex()).as_deref(), Some("#003366"));
}
}
+14 -9
View File
@@ -343,33 +343,38 @@ fn panel_menu<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
pub(crate) fn library_menu<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui) {
K::menu_head(app, ui);
let current = app.ui.library_panel.as_ref().filter(|o| o.kind == K::KIND).map(|o| o.id.clone());
let all = K::list(app);
if let Some(id) = library_items(ui, &K::list(app), current.as_deref()) {
open::<K>(app, &id);
}
K::menu_tail(app, ui);
}
/// Menu rows for `libs`: the top-level ones, then a submenu per [`LibraryRef::submenu`], with
/// `current` checked. Returns the id of the one clicked.
pub(crate) fn library_items(ui: &mut Ui, libs: &[LibraryRef], current: Option<&str>) -> Option<String> {
let mut chosen = None;
let mut item = |ui: &mut Ui, l: &LibraryRef| {
if menu_item(ui, &l.name, true, current.as_deref() == Some(l.id.as_str())) {
if menu_item(ui, &l.name, true, current == Some(l.id.as_str())) {
chosen = Some(l.id.clone());
}
};
for l in all.iter().filter(|l| l.submenu.is_none()) {
for l in libs.iter().filter(|l| l.submenu.is_none()) {
item(ui, l);
}
let mut subs: Vec<&str> = vec![];
for s in all.iter().filter_map(|l| l.submenu) {
for s in libs.iter().filter_map(|l| l.submenu) {
if !subs.contains(&s) {
subs.push(s);
}
}
for s in subs {
ui.menu_button(s, |ui| {
for l in all.iter().filter(|l| l.submenu == Some(s)) {
for l in libs.iter().filter(|l| l.submenu == Some(s)) {
item(ui, l);
}
});
}
if let Some(id) = chosen {
open::<K>(app, &id);
}
K::menu_tail(app, ui);
chosen
}
#[cfg(test)]
+33 -2
View File
@@ -985,6 +985,38 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
fn library_refs(libs: Vec<swatchlib::LibraryInfo>) -> Vec<LibraryRef> {
libs.into_iter().map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect()
}
/// The swatch libraries with solid colours (not the gradient-only ones), for Limit to Library.
pub(crate) fn colour_libraries(app: &VectorcraftApp) -> Vec<LibraryRef> {
let has_colours = |id: &str| swatchlib::library(&app.session, id).is_some_and(|(_, lib)| lib.iter().any(|w| w.paint.color().is_some()));
library_refs(swatchlib::libraries(&app.session).into_iter().filter(|l| l.category != "gradients" && has_colours(&l.id)).collect())
}
/// What Limit to Library menus call the document's own swatches ([`swatchlib::DOCUMENT_SWATCHES`]).
pub(crate) const DOCUMENT_SWATCHES: &str = "Document Swatches";
/// The name of Limit to Library choice `key` (a library id or [`swatchlib::DOCUMENT_SWATCHES`]);
/// `None` for no limit or a library that is gone.
pub(crate) fn limit_name(app: &VectorcraftApp, key: &str) -> Option<String> {
match key {
"" => None,
swatchlib::DOCUMENT_SWATCHES => Some(DOCUMENT_SWATCHES.into()),
_ => swatchlib::library(&app.session, key).map(|(info, _)| info.name),
}
}
/// The Limit to Library key for `l`: a library's id (from its id or name), or
/// [`swatchlib::DOCUMENT_SWATCHES`] as it is.
pub(crate) fn limit_key(app: &VectorcraftApp, l: &str) -> Result<String, String> {
if l == swatchlib::DOCUMENT_SWATCHES {
return Ok(l.into());
}
swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}` (see swatch.library.list)"))
}
/// Swatch libraries in the library panel (engine: `swatch.library.*`).
pub(crate) struct SwatchLibraries;
@@ -997,8 +1029,7 @@ impl LibraryKind for SwatchLibraries {
type Item = Swatch;
fn list(app: &VectorcraftApp) -> Vec<LibraryRef> {
let libs = swatchlib::libraries(&app.session).into_iter();
libs.map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect()
library_refs(swatchlib::libraries(&app.session))
}
fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> {
swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib))
+5
View File
@@ -255,6 +255,10 @@ pub struct UiState {
/// Flattener Preview panel: highlight, overprints, preset and options (`ui.flattenerPreview`).
#[serde(default)]
pub flattener_preview: crate::panels::flattener_preview::Settings,
/// Color Guide panel: Limit to Library, a swatch library id, "document" (the document's
/// swatches) or "" (none) (`ui.colorGuideLimit`).
#[serde(default)]
pub color_guide_limit: String,
}
impl UiState {
@@ -309,6 +313,7 @@ impl Default for UiState {
color_guide: Default::default(),
library_panel: None,
flattener_preview: Default::default(),
color_guide_limit: String::new(),
}
}
}
+9 -2
View File
@@ -79,6 +79,13 @@ its variation grid; the panel draws the same grid. `ui.colorGuideOptions` opens
as `ui.color_guide` (`variation`, `steps`, `amount`). Save Colors as Swatches runs `swatch.new {colors: [...]}`
(one swatch per colour, one undo step).
Color Guide Limit to Library: `ui.colorGuideLimit {library}` (a swatch library id or name, `"document"` for the
document's swatches, `""` for none) limits the panel's colours; `ui.inspect` reports it as `ui.color_guide_limit`, and
`color.harmony {..., limitTo}` answers the limited guide (every colour snaps to the library's nearest colour, ΔE 2000).
Edit or Apply Colors runs `ui.recolorDialog {colors, library?}` with the harmony colours: on the selected art it
recolours as usual; with nothing selected the colours themselves are the rows and `ui.dialog.confirm` saves them as a
new colour group (`swatch.newGroup`, named by the `groupName` field).
Edit Colors dialogs: `ui.colorBalanceDialog` opens Adjust Colors (`colorBalance`: fields `mode` `gray`/`rgb`/`cmyk`/
`global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` / `tint` (global mode) from −100 to 100, `convert`, `fill`, `stroke`,
`preview`) and `ui.saturateDialog` opens Saturate (`saturate`: `intensity` −100..100, `preview`). Both preview on the
@@ -87,8 +94,8 @@ canvas while open; `ui.dialog.confirm` keeps the result as one undo step (`edit.
and spot colours exist; the dialog then stays open.
Recolor Artwork: `ui.recolorDialog {colors?, library?, group?}` opens the `recolor` dialog on the selected art
(`colors`: a count for an n-colour job, or colours to assign as the new colours; `library`: Limit to Library, `""` for
the first; `group`: a colour group, Edit or Apply Color Group). Fields: `rows` (`[{from: [keys], to: key, exclude?}]`
(`colors`: a count for an n-colour job, or colours to assign as the new colours; `library`: Limit to Library, a library
or `"document"`, `""` for the first library; `group`: a colour group, Edit or Apply Color Group). Fields: `rows` (`[{from: [keys], to: key, exclude?}]`
as `recolor.reduce` returns them), `colors` (null for Auto), `method`, `preserveWhite`, `preserveBlack`,
`preserveGrays`, `limitTo`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`. Setting
`colors`, a preserve flag or `limitTo` reduces the rows again (also on `ui.dialog.confirm`). The dialog previews
+3 -1
View File
@@ -106,7 +106,9 @@ Gray stays Gray, and `keepModel: true` keeps a colour as given. RGB documents ke
Colors blends, inversions and Recolor Artwork keep each colour's model (a blend between models takes the document's).
`swatch.new {colors: [...]}` saves several colours as swatches in one undo step. `color.harmony {color, rule, steps?, variation?, amount?}` answers what the Color
Guide panel shows: the harmony rule's colours, base first, and per colour its row of `2·steps+1` variations
(shades, cool or muted on the left, tints, warm or vivid on the right).
(shades, cool or muted on the left, tints, warm or vivid on the right). With `limitTo` (a swatch library id or name,
or `"document"` for the document's swatches) every colour snaps to the library's nearest colour (ΔE 2000), as the
panel's Limit to Library does (`ui.colorGuideLimit`); Recolor Artwork's `limitTo` takes the same values.
## Resources