diff --git a/crates/color/src/harmony.rs b/crates/color/src/harmony.rs index 5223673c..e8ef3483 100644 --- a/crates/color/src/harmony.rs +++ b/crates/color/src/harmony.rs @@ -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); + } } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 0feb39ce..818ef40a 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "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 { Ok(json!({ "changed": changed })) } -fn harmony(_: &mut Session, p: &Value) -> Result { +fn harmony(s: &mut Session, p: &Value) -> Result { 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 { 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::>(); Ok(json!({ "rule": rule.id(), "colors": hex(&g.colors), "grid": g.grid.iter().map(|r| hex(r)).collect::>() })) } diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 74c5c779..48977dd0 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -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 { "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 { "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 { } } -/// The colours of the `limitTo` library (`None` without one). -fn palette_param(s: &Session, p: &Value, cmd: &str) -> Result> { - 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 { 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 { 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 { diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 942ad5d8..171aee02 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -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 Option { + 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> { + 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)> { 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)"))) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4177870b..ee96401c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_colorguidelib.rs b/crates/engine/src/tests_colorguidelib.rs new file mode 100644 index 00000000..41b592b8 --- /dev/null +++ b/crates/engine/src/tests_colorguidelib.rs @@ -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 { + 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 { + 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 { + 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 { 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}"); +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 591de5e5..3fe8fd87 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -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 { 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 { } None => None, }; - if !art && group.is_none() { - return Err("select artwork (or a colour group) to recolor".into()); - } let assign: Vec = 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 { }; 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 = if art { vec![] } else { assign.iter().map(|k| json!({"from": [k], "to": k})).collect() }; + let start: Vec = 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 { - 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 { let mut d = d.clone(); sync(app, &mut d); + if d.bool("__newGroup") { + let colors: Vec = 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 = 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(); }); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2685173f..b13b54dc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 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) diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index eb26a181..449c403a 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -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::>(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 { + 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::>() }); + 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::>() })).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 { + 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 { + 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 = 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 = 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::>()); + 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")); + } } diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 5e68c3a6..35d81835 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -343,33 +343,38 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { pub(crate) fn library_menu(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::(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 { 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::(app, &id); - } - K::menu_tail(app, ui); + chosen } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 1254e23c..d846a293 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -985,6 +985,38 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } +fn library_refs(libs: Vec) -> Vec { + 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 { + 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 { + 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 { + 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 { - 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)) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3cd49d0e..1e3eacba 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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(), } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ded1beaa..3282f946 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 diff --git a/docs/mcp.md b/docs/mcp.md index 77018faf..fe1b85f0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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