mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
96a1a02436
commit
81582c8d4f
14 files changed
+430
-71
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<_>>() }))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)")))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user