Merge pull request #60 from storytold/pr/m3-color-guide-limit-themes
M3: Color Guide Limit to Library, Edit or Apply without art, Color Themes panel (M3.95, M3.96)
This commit is contained in:
commit
0038e00382
|
|
@ -1,7 +1,9 @@
|
|||
//! Colour harmony rules and the variation grid of the Color Guide panel (our own colour math).
|
||||
//! Colour harmony rules, the variation grid of the Color Guide panel and the five-colour themes of
|
||||
//! the Color Themes panel (our own colour math).
|
||||
|
||||
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.
|
||||
|
|
@ -185,6 +187,47 @@ impl Harmony {
|
|||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A [`THEME_SIZE`]-colour theme from `base`: the rule's colours, each followed by the lighter or
|
||||
/// darker variations of it that fill the remaining places (the rule's first colours get them
|
||||
/// first). The base colour comes first, every colour in its model.
|
||||
pub fn theme(self, base: Color) -> Vec<Color> {
|
||||
let colors = self.apply(base);
|
||||
let n = colors.len();
|
||||
let mut rows: Vec<Vec<Color>> = colors.iter().map(|c| vec![*c]).collect();
|
||||
let opts = GuideOptions::default();
|
||||
for k in 0..THEME_SIZE.saturating_sub(n) {
|
||||
let (i, pass) = (k % n, k / n);
|
||||
// Alternate lighter and darker along the rule and from one pass to the next.
|
||||
let step = if (i + pass) % 2 == 0 { 3 } else { -3 };
|
||||
rows[i].push(variation(colors[i], &opts, step));
|
||||
}
|
||||
rows.into_iter().flatten().take(THEME_SIZE).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of colours in a colour theme.
|
||||
pub const THEME_SIZE: usize = 5;
|
||||
|
||||
/// Move colour `i` of a harmony on the hue and saturation wheel to `hsb` (hue in degrees,
|
||||
/// saturation and brightness 0..1), keeping its model. With `linked` the other colours keep their
|
||||
/// relation to it: they turn by the same hue, scale their saturation alike and shift their
|
||||
/// brightness by as much.
|
||||
pub fn move_on_wheel(colors: &mut [Color], i: usize, hsb: [f32; 3], linked: bool) {
|
||||
let Some(&old) = colors.get(i) else { return };
|
||||
let [h0, s0, v0] = old.to_hsb();
|
||||
let (dh, ratio, dv) = (hsb[0] - h0, if s0 > 0.01 { hsb[1] / s0 } else { 1.0 }, hsb[2] - v0);
|
||||
for (j, c) in colors.iter_mut().enumerate() {
|
||||
let new = if j == i {
|
||||
hsb
|
||||
} else if linked {
|
||||
let [h, s, v] = c.to_hsb();
|
||||
[h + dh, (s * ratio).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0)]
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
*c = keep_model(*c, Color::from_hsb(new[0], new[1], new[2]));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the variation grid varies: towards black and white, towards cool and warm, or towards
|
||||
|
|
@ -293,6 +336,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 +442,66 @@ 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn themes_have_five_colours_base_first() {
|
||||
for base in [Color::rgb8(230, 120, 40), Color::cmyk(0.1, 0.8, 0.3, 0.05)] {
|
||||
for rule in Harmony::ALL {
|
||||
let t = rule.theme(base);
|
||||
assert_eq!(t.len(), THEME_SIZE, "{}", rule.label());
|
||||
assert_eq!(t[0], base, "{}", rule.label());
|
||||
assert!(t.iter().all(|c| c.model() == base.model()), "{}: colours keep the base model", rule.label());
|
||||
}
|
||||
}
|
||||
// A two-colour rule fills in variations next to the colour they vary.
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
let t = Harmony::Complementary.theme(red);
|
||||
assert_eq!(t[3].to_hex(), "#00ffff", "the complement follows the base's variations");
|
||||
let l = |c: &Color| c.to_lab().l;
|
||||
assert!(l(&t[1]) > l(&t[0]) && l(&t[2]) < l(&t[0]), "the base, a lighter and a darker red");
|
||||
assert!(l(&t[4]) < l(&t[3]));
|
||||
for (i, a) in t.iter().enumerate() {
|
||||
assert!(t[i + 1..].iter().all(|b| b.to_hex() != a.to_hex()), "{} repeats", a.to_hex());
|
||||
}
|
||||
// Five-colour rules are themes as they are.
|
||||
assert_eq!(Harmony::Pentagram.theme(red), Harmony::Pentagram.apply(red));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_linked_colour_moves_the_others_alike() {
|
||||
let mut c = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 1.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)];
|
||||
move_on_wheel(&mut c, 0, [120.0, 1.0, 1.0], false);
|
||||
assert_eq!(c[0].to_hex(), "#00ff00");
|
||||
assert_eq!(c[1].to_hex(), "#ffff00", "unlinked: only that colour moves");
|
||||
move_on_wheel(&mut c, 0, [240.0, 0.5, 1.0], true);
|
||||
assert_eq!(c[0].to_hex(), "#8080ff");
|
||||
let [h, s, v] = c[1].to_hsb();
|
||||
assert!((h - 180.0).abs() < 0.5 && (s - 0.5).abs() < 0.01 && v > 0.99, "it turned and paled as much: {h} {s} {v}");
|
||||
assert!(matches!(c[2], Color::Cmyk { .. }), "colours keep their model");
|
||||
// An index past the end moves nothing.
|
||||
let before = c;
|
||||
move_on_wheel(&mut c, 5, [0.0, 0.0, 0.0], true);
|
||||
assert_eq!(c, before);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<_>>() }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
//! Window → Color Themes: five-colour themes made on a harmony wheel, kept in a local library in the
|
||||
//! preferences (no online service). `colorTheme.addToSwatches` adds a theme to the Swatches panel as a
|
||||
//! colour group.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::harmony::{Harmony, THEME_SIZE};
|
||||
use vectorcraft_color::recolor::ColorKey;
|
||||
use vectorcraft_tools::params::color_json;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A saved colour theme ([`crate::Prefs::color_themes`]).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ColorTheme {
|
||||
pub name: String,
|
||||
/// 1 to [`THEME_SIZE`] colours, each in its own model.
|
||||
pub colors: Vec<Color>,
|
||||
/// The harmony rule it was made with (a rule id), if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rule: Option<String>,
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "colorTheme.list",
|
||||
"Color Themes",
|
||||
[],
|
||||
None,
|
||||
"{} the saved colour themes, in order → {themes: [{name, colors: [\"#rrggbb\"], keys: [colour keys, exact in each colour's model], rule?}]}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
"colorTheme.save",
|
||||
"Save Theme",
|
||||
["Window", "Color Themes"],
|
||||
None,
|
||||
"{colors: [1–5 colours] | color + rule (a harmony rule id or label, see color.harmony: its 5-colour theme from that base colour, base first), rule?: the rule it was made with, name?: (default \"Theme N\"), replace?: a saved theme's name (overwrite that theme in place, e.g. after editing it; it may be renamed)} save a colour theme to the local theme library (the preferences) → {name, count: themes saved}",
|
||||
always,
|
||||
save
|
||||
),
|
||||
cmd!(
|
||||
"colorTheme.delete",
|
||||
"Delete Theme",
|
||||
["Window", "Color Themes"],
|
||||
None,
|
||||
"{name} delete a saved colour theme → {deleted}",
|
||||
has_themes,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"colorTheme.addToSwatches",
|
||||
"Add to Swatches",
|
||||
["Window", "Color Themes"],
|
||||
None,
|
||||
"{name} add a saved theme's colours to the Swatches panel as a colour group named after it (as swatch.newGroup), as one undo step → {name: the group's, swatches: [names]}",
|
||||
has_doc,
|
||||
add_to_swatches
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn has_themes(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.prefs.color_themes.is_empty() { Err("no saved themes".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// A theme as the commands list it.
|
||||
pub fn theme_json(t: &ColorTheme) -> Value {
|
||||
let mut v = json!({
|
||||
"name": t.name,
|
||||
"colors": t.colors.iter().map(Color::to_hex).collect::<Vec<_>>(),
|
||||
"keys": t.colors.iter().map(|c| ColorKey::of(c).to_string()).collect::<Vec<_>>(),
|
||||
});
|
||||
if let Some(r) = &t.rule {
|
||||
v["rule"] = json!(r);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "themes": s.prefs.color_themes.iter().map(theme_json).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// The index of the saved theme called `name`.
|
||||
fn find(s: &Session, name: &str, cmd: &str) -> Result<usize> {
|
||||
s.prefs.color_themes.iter().position(|t| t.name == name).ok_or_else(|| bad(cmd, format!("no saved theme called {name} (see colorTheme.list)")))
|
||||
}
|
||||
|
||||
/// The first free "Theme N".
|
||||
fn next_name(s: &Session) -> String {
|
||||
(1..).map(|n| format!("Theme {n}")).find(|n| !s.prefs.color_themes.iter().any(|t| t.name.eq_ignore_ascii_case(n))).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "colorTheme.save";
|
||||
let rule = match str_param(p, "rule") {
|
||||
Some(r) => Some(Harmony::parse(r).ok_or_else(|| bad(C, format!("unknown rule `{r}` (see color.harmony)")))?),
|
||||
None => None,
|
||||
};
|
||||
let colors: Vec<Color> = match (p.get("colors"), p.get("color")) {
|
||||
(Some(Value::Array(cs)), _) => {
|
||||
cs.iter().map(|c| color_value(c).ok_or_else(|| bad(C, format!("invalid colour {c}")))).collect::<Result<_>>()?
|
||||
}
|
||||
(None, Some(c)) => {
|
||||
let base = color_value(c).ok_or_else(|| bad(C, "invalid `color`"))?;
|
||||
rule.ok_or_else(|| bad(C, "`color` needs a `rule` to make the theme from"))?.theme(base)
|
||||
}
|
||||
_ => return Err(bad(C, "give `colors`, or `color` and `rule`")),
|
||||
};
|
||||
if colors.is_empty() || colors.len() > THEME_SIZE {
|
||||
return Err(bad(C, format!("a theme has 1 to {THEME_SIZE} colours")));
|
||||
}
|
||||
let replace = str_param(p, "replace").map(|r| find(s, r, C)).transpose()?;
|
||||
let name = match str_param(p, "name").map(str::trim) {
|
||||
Some("") => return Err(bad(C, "the name is empty")),
|
||||
Some(n) => n.to_string(),
|
||||
None => replace.map_or_else(|| next_name(s), |i| s.prefs.color_themes[i].name.clone()),
|
||||
};
|
||||
if s.prefs.color_themes.iter().enumerate().any(|(i, t)| Some(i) != replace && t.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(bad(C, format!("a theme is already called {name} (replace: \"{name}\" overwrites it)")));
|
||||
}
|
||||
let theme = ColorTheme { name: name.clone(), colors, rule: rule.map(Harmony::id) };
|
||||
match replace {
|
||||
Some(i) => s.prefs.color_themes[i] = theme,
|
||||
None => s.prefs.color_themes.push(theme),
|
||||
}
|
||||
Ok(json!({ "name": name, "count": s.prefs.color_themes.len() }))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "colorTheme.delete";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let i = find(s, name, C)?;
|
||||
Ok(json!({ "deleted": s.prefs.color_themes.remove(i).name }))
|
||||
}
|
||||
|
||||
fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "colorTheme.addToSwatches";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let t = &s.prefs.color_themes[find(s, name, C)?];
|
||||
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>() });
|
||||
let r = s.execute("swatch.newGroup", ¶ms)?;
|
||||
Ok(json!({ "name": r["name"], "swatches": r["swatches"] }))
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ mod buildcmds;
|
|||
pub mod clipboard;
|
||||
mod colorcmds;
|
||||
pub mod colormgmt;
|
||||
pub mod colortheme;
|
||||
mod create;
|
||||
pub(crate) mod distortcmds;
|
||||
mod docinfo;
|
||||
|
|
@ -208,6 +209,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v.extend(newart::specs());
|
||||
v.extend(colortheme::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")))
|
||||
|
|
|
|||
|
|
@ -343,6 +343,10 @@ pub struct Prefs {
|
|||
/// Appearance panel → New Art Has Basic Appearance (on): new art takes one fill and stroke;
|
||||
/// off, the whole appearance of the last selection (`appearance.setNewArtBasic`).
|
||||
pub new_art_basic: bool,
|
||||
/// The Color Themes panel's saved themes (`colorTheme.*`): a local library, not a Preferences
|
||||
/// dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting keeps it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub color_themes: Vec<cmd::colortheme::ColorTheme>,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -465,6 +469,7 @@ impl Default for Prefs {
|
|||
width_profiles: vec![],
|
||||
japanese_crop_marks: false,
|
||||
new_art_basic: true,
|
||||
color_themes: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -849,8 +854,12 @@ mod tests_cmykflatten;
|
|||
#[cfg(test)]
|
||||
mod tests_colorguide;
|
||||
#[cfg(test)]
|
||||
mod tests_colorguidelib;
|
||||
#[cfg(test)]
|
||||
mod tests_colormgmt;
|
||||
#[cfg(test)]
|
||||
mod tests_colorthemes;
|
||||
#[cfg(test)]
|
||||
mod tests_containers;
|
||||
#[cfg(test)]
|
||||
mod tests_dashalign;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
//! Color Themes: saving, editing and deleting themes in the local theme library (the preferences),
|
||||
//! and adding one to the Swatches panel as a colour group.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::harmony::Harmony;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn names(s: &mut Session) -> Vec<String> {
|
||||
run(s, "colorTheme.list", json!({}))["themes"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn themes_save_list_edit_and_delete() {
|
||||
let mut s = Session::new();
|
||||
assert!(names(&mut s).is_empty());
|
||||
assert!(s.execute("colorTheme.delete", &json!({"name": "Theme 1"})).is_err(), "nothing to delete");
|
||||
// From colours, keeping each colour's model.
|
||||
let r = run(&mut s, "colorTheme.save", json!({"colors": ["#ff0000", "cmyk 0 100 100 0", {"gray": 0.4}]}));
|
||||
assert_eq!(r, json!({"name": "Theme 1", "count": 1}));
|
||||
let t = &run(&mut s, "colorTheme.list", json!({}))["themes"][0];
|
||||
assert_eq!(t["colors"], json!(["#ff0000", Color::cmyk(0.0, 1.0, 1.0, 0.0).to_hex(), Color::gray(0.4).to_hex()]));
|
||||
assert_eq!(t["keys"], json!(["rgb 255 0 0", "cmyk 0 100 100 0", "gray 40"]));
|
||||
assert!(t.get("rule").is_none());
|
||||
// From a base colour and a harmony rule: its five colours, base first.
|
||||
let r = run(&mut s, "colorTheme.save", json!({"color": "#3366cc", "rule": "Triad", "name": " Sea "}));
|
||||
assert_eq!(r["name"], "Sea");
|
||||
let t = &run(&mut s, "colorTheme.list", json!({}))["themes"][1];
|
||||
let want: Vec<String> = Harmony::Triad.theme(Color::from_hex("#3366cc").unwrap()).iter().map(Color::to_hex).collect();
|
||||
assert_eq!(t["colors"], json!(want));
|
||||
assert_eq!(t["rule"], "triad");
|
||||
// Names stay unique (ignoring case); replace edits a theme in place, renaming it if asked.
|
||||
assert!(s.execute("colorTheme.save", &json!({"colors": ["#000000"], "name": "sea"})).is_err());
|
||||
run(&mut s, "colorTheme.save", json!({"colors": ["#111111", "#222222"], "replace": "Sea"}));
|
||||
assert_eq!(names(&mut s), ["Theme 1", "Sea"]);
|
||||
assert_eq!(run(&mut s, "colorTheme.list", json!({}))["themes"][1]["colors"], json!(["#111111", "#222222"]));
|
||||
run(&mut s, "colorTheme.save", json!({"colors": ["#111111"], "replace": "Theme 1", "name": "Dark"}));
|
||||
assert_eq!(names(&mut s), ["Dark", "Sea"]);
|
||||
assert!(s.execute("colorTheme.save", &json!({"colors": ["#111111"], "replace": "Dark", "name": "Sea"})).is_err());
|
||||
assert!(s.execute("colorTheme.save", &json!({"colors": ["#111111"], "replace": "Nope"})).is_err());
|
||||
assert_eq!(run(&mut s, "colorTheme.save", json!({"colors": ["#123456"]}))["name"], "Theme 1", "the first free name");
|
||||
// Bad colours, counts and rules.
|
||||
for bad in [
|
||||
json!({"colors": []}),
|
||||
json!({"colors": ["#000000", "#111111", "#222222", "#333333", "#444444", "#555555"]}),
|
||||
json!({"colors": ["nope"]}),
|
||||
json!({"color": "#ff0000"}),
|
||||
json!({"color": "#ff0000", "rule": "square"}),
|
||||
json!({"colors": ["#000000"], "name": " "}),
|
||||
json!({}),
|
||||
] {
|
||||
assert!(s.execute("colorTheme.save", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(run(&mut s, "colorTheme.delete", json!({"name": "Sea"}))["deleted"], "Sea");
|
||||
assert_eq!(names(&mut s), ["Dark", "Theme 1"]);
|
||||
assert!(s.execute("colorTheme.delete", &json!({"name": "Sea"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn themes_round_trip_with_the_preferences() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "colorTheme.save", json!({"colors": ["#ff8800", "cmyk 10 20 30 40", "lab 55 60 -40"], "rule": "analogous", "name": "Warm"}));
|
||||
let saved = s.prefs.to_json();
|
||||
assert_eq!(saved["colorThemes"][0]["name"], "Warm");
|
||||
let back: Prefs = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(back, s.prefs, "every colour comes back in its model");
|
||||
// Without themes nothing is written, and older preference files load with none.
|
||||
assert!(Prefs::default().to_json().get("colorThemes").is_none());
|
||||
let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap();
|
||||
assert!(old.color_themes.is_empty());
|
||||
// Resetting the preferences keeps the library, and a restarted session lists it again.
|
||||
s.execute("prefs.reset", &json!({})).unwrap();
|
||||
assert_eq!(s.prefs.color_themes.len(), 1);
|
||||
let mut fresh = Session::new();
|
||||
fresh.apply_prefs(back);
|
||||
assert_eq!(names(&mut fresh), ["Warm"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_swatches_makes_a_colour_group_in_one_step() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "colorTheme.save", json!({"color": "#cc3300", "rule": "splitComplementary", "name": "Tide"}));
|
||||
assert!(s.execute("colorTheme.addToSwatches", &json!({"name": "Tide"})).is_err(), "no document");
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = run(&mut s, "colorTheme.addToSwatches", json!({"name": "Tide"}));
|
||||
assert_eq!(r["name"], "Tide");
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
|
||||
let g = st.doc.swatch_groups.iter().find(|g| g.name == "Tide").expect("the group");
|
||||
let theme = &s.prefs.color_themes[0].colors;
|
||||
assert_eq!(g.swatches.len(), 5);
|
||||
assert_eq!(
|
||||
g.swatches.iter().filter_map(|w| w.paint.color()).map(|c| c.to_hex()).collect::<Vec<_>>(),
|
||||
theme.iter().map(Color::to_hex).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(r["swatches"].as_array().unwrap().len(), 5);
|
||||
// Again: a second group with a free name.
|
||||
assert_eq!(run(&mut s, "colorTheme.addToSwatches", json!({"name": "Tide"}))["name"], "Tide 2");
|
||||
assert!(s.execute("colorTheme.addToSwatches", &json!({"name": "Nope"})).is_err());
|
||||
}
|
||||
|
|
@ -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 egui::{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::harmony::{Harmony, move_on_wheel};
|
||||
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();
|
||||
});
|
||||
|
|
@ -468,57 +485,23 @@ fn separate(rows: &mut Vec<Value>, sel: &[usize]) {
|
|||
*rows = out;
|
||||
}
|
||||
|
||||
/// Position of hue `h` (degrees) and saturation `s` on a wheel of radius `r` around `c`.
|
||||
fn wheel_pos(c: Pos2, r: f32, h: f32, s: f32) -> Pos2 {
|
||||
let a = h.to_radians();
|
||||
c + vec2(a.cos(), -a.sin()) * r * s
|
||||
/// The rows the Edit tab shows: those with a new colour, not excluded.
|
||||
fn active_rows(rows: &[Value]) -> Vec<usize> {
|
||||
(0..rows.len()).filter(|i| !excluded(&rows[*i]) && to_color(&rows[*i]).is_some()).collect()
|
||||
}
|
||||
|
||||
/// The hue and saturation wheel at brightness `v`: hue around, saturation outwards.
|
||||
fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape {
|
||||
const SEG: u32 = 72;
|
||||
const RINGS: u32 = 8;
|
||||
let mut mesh = egui::Mesh::default();
|
||||
mesh.colored_vertex(c, c32(&Color::from_hsb(0.0, 0.0, v)));
|
||||
for ring in 1..=RINGS {
|
||||
let s = ring as f32 / RINGS as f32;
|
||||
for k in 0..SEG {
|
||||
let h = k as f32 * 360.0 / SEG as f32;
|
||||
mesh.colored_vertex(wheel_pos(c, r, h, s), c32(&Color::from_hsb(h, s, v)));
|
||||
}
|
||||
}
|
||||
let at = |ring: u32, k: u32| if ring == 0 { 0 } else { 1 + (ring - 1) * SEG + k % SEG };
|
||||
for ring in 0..RINGS {
|
||||
for k in 0..SEG {
|
||||
if ring == 0 {
|
||||
mesh.add_triangle(0, at(1, k), at(1, k + 1));
|
||||
} else {
|
||||
mesh.add_triangle(at(ring, k), at(ring + 1, k), at(ring + 1, k + 1));
|
||||
mesh.add_triangle(at(ring, k), at(ring + 1, k + 1), at(ring, k + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::Shape::mesh(mesh)
|
||||
}
|
||||
|
||||
/// Set row `i`'s new colour to hue/saturation/brightness `hsb` (keeping its model); with `linked`
|
||||
/// every other row turns by the same hue, scales its saturation and shifts its brightness alike.
|
||||
/// Excluded rows stay.
|
||||
/// Set row `i`'s new colour to hue/saturation/brightness `hsb` ([`move_on_wheel`]: with `linked`
|
||||
/// the other rows keep their relation to it). Excluded rows stay.
|
||||
fn move_color(rows: &mut [Value], i: usize, hsb: [f32; 3], linked: bool) {
|
||||
let Some(old) = rows.get(i).and_then(to_color) else { return };
|
||||
let [h0, s0, v0] = old.to_hsb();
|
||||
let (dh, ratio, dv) = (hsb[0] - h0, if s0 > 0.01 { hsb[1] / s0 } else { 1.0 }, hsb[2] - v0);
|
||||
for (j, row) in rows.iter_mut().enumerate() {
|
||||
let Some(c) = to_color(row).filter(|_| !excluded(row)) else { continue };
|
||||
let new = if j == i {
|
||||
hsb
|
||||
} else if linked {
|
||||
let [h, s, v] = c.to_hsb();
|
||||
[h + dh, (s * ratio).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0)]
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
row["to"] = key(&keep_model(c, Color::from_hsb(new[0], new[1], new[2])));
|
||||
let active = active_rows(rows);
|
||||
let Some(k) = active.iter().position(|&j| j == i) else { return };
|
||||
let before: Vec<Color> = active.iter().filter_map(|&j| to_color(&rows[j])).collect();
|
||||
let mut colors = before.clone();
|
||||
move_on_wheel(&mut colors, k, hsb, linked);
|
||||
for ((&j, c), old) in active.iter().zip(&colors).zip(&before) {
|
||||
if c != old {
|
||||
rows[j]["to"] = key(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -527,7 +510,7 @@ fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool {
|
|||
let t = Tokens::get(ui.ctx());
|
||||
let mut changed = false;
|
||||
let linked = d.bool("linked");
|
||||
let active: Vec<usize> = (0..rows.len()).filter(|i| !excluded(&rows[*i]) && to_color(&rows[*i]).is_some()).collect();
|
||||
let active = active_rows(rows);
|
||||
let base = selection(d).into_iter().find(|i| active.contains(i)).or(active.first().copied());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Harmony Rules:").color(t.text_dim));
|
||||
|
|
@ -549,62 +532,15 @@ fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool {
|
|||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let v = base.and_then(|b| to_color(&rows[b])).map_or(1.0, |c| c.to_hsb()[2]);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(((ui.available_width() - WHEEL) / 2.0).max(0.0));
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(WHEEL, WHEEL), Sense::click_and_drag());
|
||||
let (c, r) = (rect.center(), WHEEL / 2.0 - 10.0);
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.add(wheel_shape(c, r, v.max(0.15)));
|
||||
painter.circle_stroke(c, r, egui::Stroke::new(1.0, t.border));
|
||||
let at = |rows: &[Value], i: usize| {
|
||||
to_color(&rows[i]).map(|col| {
|
||||
let [h, s, _] = col.to_hsb();
|
||||
wheel_pos(c, r, h, s)
|
||||
})
|
||||
};
|
||||
for &i in &active {
|
||||
let (Some(p), Some(col)) = (at(rows, i), to_color(&rows[i])) else { continue };
|
||||
let big = Some(i) == base;
|
||||
painter.line_segment([c, p], egui::Stroke::new(1.0, egui::Color32::from_white_alpha(160)));
|
||||
painter.circle(p, if big { 8.0 } else { 5.5 }, c32(&col), egui::Stroke::new(1.5, egui::Color32::WHITE));
|
||||
painter.circle_stroke(p, if big { 9.0 } else { 6.5 }, egui::Stroke::new(1.0, egui::Color32::BLACK));
|
||||
}
|
||||
// Drag a marker: the nearest one within reach of the press.
|
||||
let id = ui.id().with("recolor-wheel-drag");
|
||||
if (resp.drag_started() || resp.clicked())
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let near = active.iter().filter_map(|&i| Some((i, at(rows, i)?.distance(p)))).filter(|x| x.1 <= 14.0).min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
ui.data_mut(|m| m.insert_temp(id, near.map(|x| x.0)));
|
||||
if let Some((i, _)) = near {
|
||||
d.fields.insert("__sel".into(), json!([i]));
|
||||
}
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let (Some(i), Some(p)) = (ui.data(|m| m.get_temp::<Option<usize>>(id)).flatten(), resp.interact_pointer_pos())
|
||||
&& let Some(old) = to_color(&rows[i])
|
||||
{
|
||||
let dv = p - c;
|
||||
let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0);
|
||||
move_color(rows, i, [h, (dv.length() / r).clamp(0.0, 1.0), old.to_hsb()[2]], linked);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(((ui.available_width() - WHEEL - 76.0) / 2.0).max(0.0));
|
||||
ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new("Brightness").color(t.text_dim)));
|
||||
if let Some(b) = base
|
||||
&& let Some(col) = to_color(&rows[b])
|
||||
{
|
||||
let [h, s, _] = col.to_hsb();
|
||||
if let (Some(nv), _) = widgets::color_slider(ui, "recolor-brightness", v, WHEEL, &|x| c32(&Color::from_hsb(h, s, x))) {
|
||||
move_color(rows, b, [h, s, nv], linked);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
let colors: Vec<Color> = active.iter().filter_map(|&i| to_color(&rows[i])).collect();
|
||||
let w = widgets::harmony_wheel(ui, "recolor-wheel", WHEEL, &colors, base.and_then(|b| active.iter().position(|&i| i == b)));
|
||||
if let Some(k) = w.pressed {
|
||||
d.fields.insert("__sel".into(), json!([active[k]]));
|
||||
}
|
||||
if let Some((k, hsb)) = w.moved {
|
||||
move_color(rows, active[k], hsb, linked);
|
||||
changed = true;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
// The new colours: click one to make it the base colour.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1460,6 +1467,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
panel("Brushes", "brushes"),
|
||||
panel("Color", "color"),
|
||||
panel("Color Guide", "colorGuide"),
|
||||
panel("Color Themes", "colorThemes"),
|
||||
panel("Document Info", "docInfo"),
|
||||
panel("Flattener Preview", crate::panels::flattener_preview::ID),
|
||||
panel("Gradient", "gradient"),
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,441 @@
|
|||
//! Color Themes panel (local only): the Create tab makes a five-colour theme on a harmony wheel (a
|
||||
//! harmony rule from the selected colour, or Custom: each colour moves on its own; with a rule the
|
||||
//! colours move together), the My Themes tab lists the themes saved in the preferences. Themes are
|
||||
//! saved, edited, deleted and added to Swatches as colour groups through `colorTheme.*`; the theme
|
||||
//! being made is panel state.
|
||||
|
||||
use egui::{Rect, Sense, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::harmony::{Harmony, THEME_SIZE, move_on_wheel};
|
||||
use vectorcraft_engine::cmd::colortheme::ColorTheme;
|
||||
|
||||
use super::{active_paint, c32, color_json, empty_state, pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::widgets::{self, menu_item};
|
||||
|
||||
/// The rule a new theme starts with.
|
||||
const DEFAULT_RULE: Harmony = Harmony::Analogous2;
|
||||
/// The base colour before one is picked: a teal.
|
||||
const DEFAULT_BASE: Color = Color::Rgb { r: 0.1, g: 0.55, b: 0.6 };
|
||||
/// Diameter of the harmony wheel.
|
||||
const WHEEL: f32 = 150.0;
|
||||
|
||||
/// The theme being made on the Create tab.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct Draft {
|
||||
colors: Vec<Color>,
|
||||
/// The harmony rule (`None`: Custom).
|
||||
rule: Option<Harmony>,
|
||||
/// The selected colour (the base a rule starts from).
|
||||
sel: usize,
|
||||
name: String,
|
||||
/// The saved theme being edited: Save overwrites it.
|
||||
editing: Option<String>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
/// A new theme from `base` by `rule`.
|
||||
fn new(base: Color, rule: Option<Harmony>) -> Self {
|
||||
let colors = rule.map_or_else(|| DEFAULT_RULE.theme(base), |r| r.theme(base));
|
||||
Self { colors, rule, ..Default::default() }
|
||||
}
|
||||
/// Set the selected colour to `c`: with a rule the theme follows from it as the new base.
|
||||
fn set_selected(&mut self, c: Color) {
|
||||
match self.rule {
|
||||
Some(r) => {
|
||||
self.colors = r.theme(c);
|
||||
self.sel = 0;
|
||||
}
|
||||
None => {
|
||||
if let Some(x) = self.colors.get_mut(self.sel) {
|
||||
*x = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `colorTheme.save` parameters.
|
||||
fn save_params(&self) -> Value {
|
||||
let mut p = json!({ "colors": colors_json(&self.colors) });
|
||||
if let Some(r) = self.rule {
|
||||
p["rule"] = json!(r.id());
|
||||
}
|
||||
if !self.name.trim().is_empty() {
|
||||
p["name"] = json!(self.name.trim());
|
||||
}
|
||||
if let Some(e) = &self.editing {
|
||||
p["replace"] = json!(e);
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// Colours as command params, exact in their models.
|
||||
fn colors_json(colors: &[Color]) -> Vec<Value> {
|
||||
colors.iter().map(color_json).collect()
|
||||
}
|
||||
|
||||
/// The theme being made: from the current colour when the panel first shows.
|
||||
fn draft(app: &VectorcraftApp, ctx: &egui::Context) -> Draft {
|
||||
if let Some(d) = pstate::<Option<Draft>>(ctx, "ct-draft") {
|
||||
return d;
|
||||
}
|
||||
let d = Draft::new(active_paint(app).color().unwrap_or(DEFAULT_BASE), Some(DEFAULT_RULE));
|
||||
set_pstate(ctx, "ct-draft", Some(d.clone()));
|
||||
d
|
||||
}
|
||||
|
||||
fn set_draft(ctx: &egui::Context, d: Draft) {
|
||||
set_pstate(ctx, "ct-draft", Some(d));
|
||||
}
|
||||
|
||||
/// The saved theme picked on the My Themes tab.
|
||||
fn picked(app: &VectorcraftApp, ctx: &egui::Context) -> Option<String> {
|
||||
pstate::<Option<String>>(ctx, "ct-picked").filter(|n| app.session.prefs.color_themes.iter().any(|t| t.name == *n))
|
||||
}
|
||||
|
||||
/// The tab shown: 0 Create, 1 My Themes.
|
||||
fn tab(ctx: &egui::Context) -> usize {
|
||||
pstate(ctx, "ct-tab")
|
||||
}
|
||||
|
||||
/// Save the theme being made (overwriting the theme it edits); it then edits the saved theme.
|
||||
fn save(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let mut d = draft(app, ctx);
|
||||
if let Ok(r) = app.run("colorTheme.save", d.save_params()) {
|
||||
let name = r["name"].as_str().unwrap_or_default().to_string();
|
||||
set_pstate(ctx, "ct-picked", Some(name.clone()));
|
||||
d.name = name.clone();
|
||||
d.editing = Some(name);
|
||||
set_draft(ctx, d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load saved theme `t` into the Create tab to edit it.
|
||||
fn edit(ctx: &egui::Context, t: &ColorTheme) {
|
||||
let rule = t.rule.as_deref().and_then(Harmony::parse);
|
||||
set_draft(ctx, Draft { colors: t.colors.clone(), rule, sel: 0, name: t.name.clone(), editing: Some(t.name.clone()) });
|
||||
set_pstate(ctx, "ct-tab", 0usize);
|
||||
}
|
||||
|
||||
/// Add the theme being made to Swatches as a colour group (one undo step).
|
||||
fn add_draft_to_swatches(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let d = draft(app, ctx);
|
||||
let name = if d.name.trim().is_empty() { "Color Theme" } else { d.name.trim() };
|
||||
app.run("swatch.newGroup", json!({ "name": name, "colors": colors_json(&d.colors) })).ok();
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
ui.horizontal(|ui| {
|
||||
let cur = tab(&ctx);
|
||||
for (i, label) in ["Create", "My Themes"].into_iter().enumerate() {
|
||||
if ui.selectable_label(cur == i, egui::RichText::new(label).font(theme::semibold(12.5))).clicked() {
|
||||
set_pstate(&ctx, "ct-tab", i);
|
||||
}
|
||||
}
|
||||
});
|
||||
widgets::divider(ui);
|
||||
ui.add_space(4.0);
|
||||
if tab(&ctx) == 1 { my_themes(app, ui) } else { create(app, ui) }
|
||||
}
|
||||
|
||||
fn create(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut d = draft(app, &ctx);
|
||||
let before = d.clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Rule:").color(t.text_dim));
|
||||
let labels: Vec<&str> = std::iter::once("Custom").chain(Harmony::ALL.iter().map(|h| h.label())).collect();
|
||||
let cur = d.rule.map_or("Custom", Harmony::label);
|
||||
// A rule makes the theme from the selected colour; Custom frees the colours.
|
||||
if let Some(i) = widgets::dropdown(ui, "ct-rule", cur, &labels, ui.available_width() - 4.0) {
|
||||
d.rule = i.checked_sub(1).map(|i| Harmony::ALL[i]);
|
||||
if let (Some(r), Some(&base)) = (d.rule, d.colors.get(d.sel)) {
|
||||
d.colors = r.theme(base);
|
||||
d.sel = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let w = widgets::harmony_wheel(ui, "ct-wheel", WHEEL, &d.colors, Some(d.sel));
|
||||
if let Some(k) = w.pressed {
|
||||
d.sel = k;
|
||||
}
|
||||
if let Some((k, hsb)) = w.moved {
|
||||
move_on_wheel(&mut d.colors, k, hsb, d.rule.is_some());
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// The theme's colours: click one to select it.
|
||||
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
|
||||
let cw = row.width() / THEME_SIZE as f32;
|
||||
for (i, c) in d.colors.iter().enumerate() {
|
||||
let r = Rect::from_min_size(row.min + vec2(i as f32 * cw, 0.0), vec2(cw - 3.0, row.height()));
|
||||
let resp = ui.interact(r, ui.id().with(("ct-chip", i)), Sense::click());
|
||||
ui.painter().rect_filled(r, 2.0, c32(c));
|
||||
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
if i == d.sel {
|
||||
ui.painter().rect_stroke(r.expand(1.5), 3.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Outside);
|
||||
}
|
||||
if resp.on_hover_text(c.to_hex()).clicked() {
|
||||
d.sel = i;
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let hex = d.colors.get(d.sel).map(|c| c.to_hex().trim_start_matches('#').to_string()).unwrap_or_default();
|
||||
if let Some(c) = widgets::hex_field(ui, "ct-hex", &hex).and_then(|h| Color::from_hex(&h)) {
|
||||
d.set_selected(c);
|
||||
}
|
||||
let current = active_paint(app).color();
|
||||
if widgets::icon_button_enabled(ui, "pipette", "Set the selected color to the current color", false, current.is_some(), 24.0).clicked()
|
||||
&& let Some(c) = current
|
||||
{
|
||||
d.set_selected(c);
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Name:").color(t.text_dim));
|
||||
if let Some(n) = widgets::text_field(ui, "ct-name", Some(&d.name), ui.available_width() - 4.0, 1) {
|
||||
d.name = n;
|
||||
}
|
||||
});
|
||||
if d != before {
|
||||
set_draft(&ctx, d.clone());
|
||||
}
|
||||
widgets::bottom_bar(ui, |ui| {
|
||||
let tip = match &d.editing {
|
||||
Some(n) => format!("Save changes to {n}"),
|
||||
None => "Save theme to My Themes".into(),
|
||||
};
|
||||
if widgets::icon_button(ui, "save", &tip, false, 24.0).clicked() {
|
||||
save(app, &ctx);
|
||||
}
|
||||
let doc = app.session.active().is_some();
|
||||
if widgets::icon_button_enabled(ui, "dc-folder", "Add to Swatches", false, doc, 24.0).clicked() {
|
||||
add_draft_to_swatches(app, &ctx);
|
||||
}
|
||||
ui.add_space((ui.available_width() - 28.0).max(0.0));
|
||||
if widgets::icon_button(ui, "file-plus", "New theme from the current color", false, 24.0).clicked() {
|
||||
let base = active_paint(app).color().unwrap_or(DEFAULT_BASE);
|
||||
set_draft(&ctx, Draft::new(base, d.rule.or(Some(DEFAULT_RULE))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What a click on the My Themes tab asks for.
|
||||
enum Action {
|
||||
Pick(String),
|
||||
Edit(String),
|
||||
}
|
||||
|
||||
fn my_themes(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ctx = ui.ctx().clone();
|
||||
let pick = picked(app, &ctx);
|
||||
let mut action = None;
|
||||
if app.session.prefs.color_themes.is_empty() {
|
||||
empty_state(ui, "sun", "No saved themes", "Make a theme on Create and save it.");
|
||||
} else {
|
||||
widgets::list_box(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("ct-list").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for th in &app.session.prefs.color_themes {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
|
||||
let resp = ui.interact(r, ui.id().with(("ct-theme", &th.name)), Sense::click());
|
||||
let selected = pick.as_deref() == Some(th.name.as_str());
|
||||
if selected {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
let inner = r.shrink2(vec2(6.0, 4.0));
|
||||
ui.painter().text(inner.left_top(), egui::Align2::LEFT_TOP, &th.name, egui::FontId::proportional(12.0), t.text);
|
||||
let strip = Rect::from_min_max(inner.left_bottom() - vec2(0.0, 18.0), inner.right_bottom());
|
||||
let w = strip.width() / th.colors.len().max(1) as f32;
|
||||
for (i, c) in th.colors.iter().enumerate() {
|
||||
ui.painter().rect_filled(Rect::from_min_size(strip.min + vec2(i as f32 * w, 0.0), vec2(w, strip.height())), 0.0, c32(c));
|
||||
}
|
||||
ui.painter().rect_stroke(strip, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
|
||||
if resp.double_clicked() {
|
||||
action = Some(Action::Edit(th.name.clone()));
|
||||
} else if resp.on_hover_text("Click to select, double-click to edit").clicked() {
|
||||
action = Some(Action::Pick(th.name.clone()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
match action {
|
||||
Some(Action::Pick(n)) => set_pstate(&ctx, "ct-picked", Some(n)),
|
||||
Some(Action::Edit(n)) => edit_saved(app, &ctx, &n),
|
||||
None => {}
|
||||
}
|
||||
let pick = picked(app, &ctx);
|
||||
widgets::bottom_bar(ui, |ui| {
|
||||
let some = pick.is_some();
|
||||
if widgets::icon_button_enabled(ui, "pencil", "Edit theme", false, some, 24.0).clicked()
|
||||
&& let Some(n) = &pick
|
||||
{
|
||||
edit_saved(app, &ctx, n);
|
||||
}
|
||||
let doc = app.session.active().is_some();
|
||||
if widgets::icon_button_enabled(ui, "dc-folder", "Add to Swatches", false, some && doc, 24.0).clicked()
|
||||
&& let Some(n) = &pick
|
||||
{
|
||||
app.run("colorTheme.addToSwatches", json!({ "name": n })).ok();
|
||||
}
|
||||
ui.add_space((ui.available_width() - 28.0).max(0.0));
|
||||
if widgets::icon_button_enabled(ui, "trash-2", "Delete theme", false, some, 24.0).clicked()
|
||||
&& let Some(n) = &pick
|
||||
{
|
||||
delete(app, &ctx, n);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Edit saved theme `name` on the Create tab.
|
||||
fn edit_saved(app: &VectorcraftApp, ctx: &egui::Context, name: &str) {
|
||||
if let Some(th) = app.session.prefs.color_themes.iter().find(|t| t.name == name) {
|
||||
edit(ctx, th);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete saved theme `name`; the theme being made no longer edits it.
|
||||
fn delete(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str) {
|
||||
if app.run("colorTheme.delete", json!({ "name": name })).is_ok() {
|
||||
let mut d = draft(app, ctx);
|
||||
if d.editing.as_deref() == Some(name) {
|
||||
d.editing = None;
|
||||
set_draft(ctx, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let doc = app.session.active().is_some();
|
||||
if tab(&ctx) == 1 {
|
||||
let pick = picked(app, &ctx);
|
||||
if menu_item(ui, "Edit Theme", pick.is_some(), false)
|
||||
&& let Some(n) = &pick
|
||||
{
|
||||
edit_saved(app, &ctx, n);
|
||||
}
|
||||
if menu_item(ui, "Add to Swatches", pick.is_some() && doc, false)
|
||||
&& let Some(n) = &pick
|
||||
{
|
||||
app.run("colorTheme.addToSwatches", json!({ "name": n })).ok();
|
||||
}
|
||||
if menu_item(ui, "Delete Theme", pick.is_some(), false)
|
||||
&& let Some(n) = &pick
|
||||
{
|
||||
delete(app, &ctx, n);
|
||||
}
|
||||
} else {
|
||||
if menu_item(ui, "Save Theme", true, false) {
|
||||
save(app, &ctx);
|
||||
}
|
||||
if menu_item(ui, "Add to Swatches", doc, false) {
|
||||
add_draft_to_swatches(app, &ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
show(app, ui);
|
||||
menu(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn themes(app: &VectorcraftApp) -> &[ColorTheme] {
|
||||
&app.session.prefs.color_themes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_save_edit_and_resave_a_theme() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
app.run("paint.setFill", json!({"color": "#cc4422"})).unwrap();
|
||||
frame(&mut app, &ctx);
|
||||
let d = draft(&app, &ctx);
|
||||
assert_eq!(d.colors, DEFAULT_RULE.theme(Color::from_hex("#cc4422").unwrap()), "a theme from the current colour");
|
||||
save(&mut app, &ctx);
|
||||
assert_eq!(themes(&app).len(), 1);
|
||||
assert_eq!((themes(&app)[0].name.as_str(), &themes(&app)[0].colors), ("Theme 1", &d.colors));
|
||||
assert_eq!(themes(&app)[0].rule.as_deref(), Some("analogous2"));
|
||||
assert_eq!(draft(&app, &ctx).editing.as_deref(), Some("Theme 1"), "it now edits the saved theme");
|
||||
// Another rule from the selected colour, then save: the same theme changes in place.
|
||||
let mut d = draft(&app, &ctx);
|
||||
d.sel = 2;
|
||||
let base = d.colors[2];
|
||||
d.rule = Some(Harmony::Triad);
|
||||
d.set_selected(base);
|
||||
assert_eq!((d.colors.clone(), d.sel), (Harmony::Triad.theme(base), 0));
|
||||
set_draft(&ctx, d.clone());
|
||||
save(&mut app, &ctx);
|
||||
assert_eq!(themes(&app).len(), 1, "saving an edited theme overwrites it");
|
||||
assert_eq!(themes(&app)[0].colors, d.colors);
|
||||
// Custom: only the selected colour changes; a new name saves a new theme.
|
||||
let mut d = Draft { rule: None, sel: 1, editing: None, name: "Mine".into(), ..d };
|
||||
d.set_selected(Color::from_hex("#00ff00").unwrap());
|
||||
assert_eq!(d.colors[1].to_hex(), "#00ff00");
|
||||
assert_eq!(d.colors[0], themes(&app)[0].colors[0]);
|
||||
set_draft(&ctx, d);
|
||||
save(&mut app, &ctx);
|
||||
assert_eq!(themes(&app).iter().map(|t| t.name.as_str()).collect::<Vec<_>>(), ["Theme 1", "Mine"]);
|
||||
assert!(themes(&app)[1].rule.is_none());
|
||||
// Both tabs draw.
|
||||
set_pstate(&ctx, "ct-tab", 1usize);
|
||||
frame(&mut app, &ctx);
|
||||
assert_eq!(picked(&app, &ctx).as_deref(), Some("Mine"), "the theme just saved is picked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn my_themes_edit_add_to_swatches_and_delete() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
set_pstate(&ctx, "ct-tab", 1usize);
|
||||
frame(&mut app, &ctx);
|
||||
app.run("colorTheme.save", json!({"color": "#2266aa", "rule": "complementary", "name": "Harbor"})).unwrap();
|
||||
frame(&mut app, &ctx);
|
||||
set_pstate(&ctx, "ct-picked", Some("Harbor".to_string()));
|
||||
edit_saved(&app, &ctx, "Harbor");
|
||||
assert_eq!(tab(&ctx), 0, "editing shows the Create tab");
|
||||
let d = draft(&app, &ctx);
|
||||
assert_eq!((d.rule, d.editing.as_deref(), d.colors.len()), (Some(Harmony::Complementary), Some("Harbor"), 5));
|
||||
frame(&mut app, &ctx);
|
||||
// Add to Swatches (the picked saved theme), one colour group in one undo step.
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
app.run("colorTheme.addToSwatches", json!({"name": "Harbor"})).unwrap();
|
||||
let st = app.session.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
assert_eq!(st.doc.swatch_groups.last().map(|g| (g.name.as_str(), g.swatches.len())), Some(("Harbor", 5)));
|
||||
// The theme being made goes to Swatches as it is.
|
||||
add_draft_to_swatches(&mut app, &ctx);
|
||||
assert_eq!(app.session.doc().unwrap().doc.swatch_groups.last().map(|g| g.name.as_str()), Some("Harbor 2"));
|
||||
// Deleting it frees the theme being made from editing it.
|
||||
delete(&mut app, &ctx, "Harbor");
|
||||
assert!(themes(&app).is_empty());
|
||||
assert_eq!(draft(&app, &ctx).editing, None);
|
||||
assert_eq!(picked(&app, &ctx), None);
|
||||
frame(&mut app, &ctx);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ pub mod brushes;
|
|||
pub mod character;
|
||||
pub mod color;
|
||||
pub mod color_guide;
|
||||
pub mod color_themes;
|
||||
pub mod doc_info;
|
||||
pub mod flattener_preview;
|
||||
pub mod glyphs;
|
||||
|
|
@ -92,6 +93,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
|||
"tabs" => tabs::show(app, ui),
|
||||
flattener_preview::ID => flattener_preview::show(app, ui),
|
||||
"attributes" => attributes::show(app, ui),
|
||||
"colorThemes" => color_themes::show(app, ui),
|
||||
_ => {
|
||||
dim_label(ui, "This panel is on the roadmap (see the parity plan).");
|
||||
}
|
||||
|
|
@ -132,6 +134,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
|||
"tabs" => tabs::menu(app, ui),
|
||||
flattener_preview::ID => flattener_preview::menu(app, ui),
|
||||
"attributes" => attributes::menu(app, ui),
|
||||
"colorThemes" => color_themes::menu(app, ui),
|
||||
_ => {
|
||||
ui.add_enabled(false, egui::Button::new("No options").frame(false));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
|||
("magicWand", "Magic Wand", "wand-sparkles"),
|
||||
(crate::panels::flattener_preview::ID, "Flattener Preview", "eye"),
|
||||
("attributes", "Attributes", "settings"),
|
||||
("colorThemes", "Color Themes", "sun"),
|
||||
];
|
||||
|
||||
/// Groups of icon panels separated by dividers in the collapsed column.
|
||||
|
|
@ -255,6 +256,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 +314,7 @@ impl Default for UiState {
|
|||
color_guide: Default::default(),
|
||||
library_panel: None,
|
||||
flattener_preview: Default::default(),
|
||||
color_guide_limit: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Shared widgets in Illustrator's panel style. Colours come from theme tokens.
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::Unit;
|
||||
|
||||
use crate::icons;
|
||||
|
|
@ -1085,3 +1085,103 @@ pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64)
|
|||
});
|
||||
Some(tex)
|
||||
}
|
||||
|
||||
/// Position of hue `h` (degrees) and saturation `s` on a wheel of radius `r` around `c`.
|
||||
fn wheel_pos(c: Pos2, r: f32, h: f32, s: f32) -> Pos2 {
|
||||
let a = h.to_radians();
|
||||
c + vec2(a.cos(), -a.sin()) * r * s
|
||||
}
|
||||
|
||||
/// The hue and saturation wheel at brightness `v`: hue around, saturation outwards.
|
||||
fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape {
|
||||
const SEG: u32 = 72;
|
||||
const RINGS: u32 = 8;
|
||||
let mut mesh = egui::Mesh::default();
|
||||
mesh.colored_vertex(c, crate::panels::c32(&Color::from_hsb(0.0, 0.0, v)));
|
||||
for ring in 1..=RINGS {
|
||||
let s = ring as f32 / RINGS as f32;
|
||||
for k in 0..SEG {
|
||||
let h = k as f32 * 360.0 / SEG as f32;
|
||||
mesh.colored_vertex(wheel_pos(c, r, h, s), crate::panels::c32(&Color::from_hsb(h, s, v)));
|
||||
}
|
||||
}
|
||||
let at = |ring: u32, k: u32| if ring == 0 { 0 } else { 1 + (ring - 1) * SEG + k % SEG };
|
||||
for ring in 0..RINGS {
|
||||
for k in 0..SEG {
|
||||
if ring == 0 {
|
||||
mesh.add_triangle(0, at(1, k), at(1, k + 1));
|
||||
} else {
|
||||
mesh.add_triangle(at(ring, k), at(ring + 1, k), at(ring + 1, k + 1));
|
||||
mesh.add_triangle(at(ring, k), at(ring + 1, k + 1), at(ring, k + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::Shape::mesh(mesh)
|
||||
}
|
||||
|
||||
/// What a [`harmony_wheel`] did this frame.
|
||||
#[derive(Default)]
|
||||
pub struct WheelResponse {
|
||||
/// The colour whose marker was pressed (it becomes the selected one).
|
||||
pub pressed: Option<usize>,
|
||||
/// A colour moved to a new hue, saturation and brightness (`[h°, s, v]`), by dragging its
|
||||
/// marker or (the selected colour) the brightness slider.
|
||||
pub moved: Option<(usize, [f32; 3])>,
|
||||
}
|
||||
|
||||
/// The harmony wheel of Recolor Artwork's Edit tab and the Color Themes panel: a hue and saturation
|
||||
/// wheel `size` across (hue around, saturation outwards) at the selected colour's brightness, a
|
||||
/// marker per colour joined to the centre (the selected colour, `base`, larger) and a Brightness
|
||||
/// slider for the selected colour below it.
|
||||
pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: Option<usize>) -> WheelResponse {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut out = WheelResponse::default();
|
||||
let base_hsb = base.and_then(|b| colors.get(b)).map(Color::to_hsb);
|
||||
let v = base_hsb.map_or(1.0, |c| c[2]);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(((ui.available_width() - size) / 2.0).max(0.0));
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag());
|
||||
let (c, r) = (rect.center(), size / 2.0 - 10.0);
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.add(wheel_shape(c, r, v.max(0.15)));
|
||||
painter.circle_stroke(c, r, Stroke::new(1.0, t.border));
|
||||
let at = |col: &Color| {
|
||||
let [h, s, _] = col.to_hsb();
|
||||
wheel_pos(c, r, h, s)
|
||||
};
|
||||
for (i, col) in colors.iter().enumerate() {
|
||||
let (p, big) = (at(col), Some(i) == base);
|
||||
painter.line_segment([c, p], Stroke::new(1.0, Color32::from_white_alpha(160)));
|
||||
painter.circle(p, if big { 8.0 } else { 5.5 }, crate::panels::c32(col), Stroke::new(1.5, Color32::WHITE));
|
||||
painter.circle_stroke(p, if big { 9.0 } else { 6.5 }, Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
// Drag a marker: the nearest one within reach of the press.
|
||||
let drag = ui.id().with((id, "drag"));
|
||||
if (resp.drag_started() || resp.clicked())
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let near = colors.iter().map(at).enumerate().map(|(i, m)| (i, m.distance(p))).filter(|x| x.1 <= 14.0).min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
out.pressed = near.map(|x| x.0);
|
||||
ui.data_mut(|m| m.insert_temp(drag, out.pressed));
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let (Some(i), Some(p)) = (ui.data(|m| m.get_temp::<Option<usize>>(drag)).flatten(), resp.interact_pointer_pos())
|
||||
&& let Some(old) = colors.get(i)
|
||||
{
|
||||
let dv = p - c;
|
||||
let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0);
|
||||
out.moved = Some((i, [h, (dv.length() / r).clamp(0.0, 1.0), old.to_hsb()[2]]));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0));
|
||||
ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new("Brightness").color(t.text_dim)));
|
||||
if let (Some(b), Some([h, s, _])) = (base, base_hsb)
|
||||
&& let (Some(nv), _) = color_slider(ui, (id, "brightness"), v, size, &|x| crate::panels::c32(&Color::from_hsb(h, s, x)))
|
||||
{
|
||||
out.moved = Some((b, [h, s, nv]));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,17 @@ 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).
|
||||
|
||||
Color Themes panel (`window.panel {panel: "colorThemes"}`): its Create tab makes a theme on a harmony wheel and its
|
||||
My Themes tab lists the saved themes; every action runs a command (`colorTheme.save`, `colorTheme.delete`,
|
||||
`colorTheme.addToSwatches`, `swatch.newGroup` for the theme being made), and `colorTheme.list` reads the library.
|
||||
|
||||
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 +98,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
|
||||
|
|
|
|||
16
docs/mcp.md
16
docs/mcp.md
|
|
@ -106,7 +106,21 @@ 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.
|
||||
|
||||
Color Themes (local only: no online service) keeps five-colour themes in the preferences. `colorTheme.save
|
||||
{colors: [...]}` saves up to five colours as they are, `colorTheme.save {color, rule}` saves the five-colour theme a
|
||||
harmony rule makes from a base colour (base first; `name?` defaults to "Theme N", `replace: "<name>"` overwrites
|
||||
that theme in place). `colorTheme.list` answers `{themes: [{name, colors: [hex], keys: [colour keys], rule?}]}`,
|
||||
`colorTheme.delete {name}` removes one and `colorTheme.addToSwatches {name}` adds one to the Swatches panel as a
|
||||
colour group (one undo step).
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"colorTheme.save","params":{"color":"#2266aa","rule":"splitComplementary","name":"Harbor"}}}
|
||||
{"name":"run_command","arguments":{"command":"colorTheme.addToSwatches","params":{"name":"Harbor"}}}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue