diff --git a/crates/color/src/harmony.rs b/crates/color/src/harmony.rs index 5223673c..a251bc79 100644 --- a/crates/color/src/harmony.rs +++ b/crates/color/src/harmony.rs @@ -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 { + let colors = self.apply(base); + let n = colors.len(); + let mut rows: Vec> = 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); + } } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 0feb39ce..818ef40a 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Color Guide", [], None, - "{color, rule: complementary|complementary2|splitComplementary|leftComplement|rightComplement|analogous|analogous2|monochromatic|shades|triad|triad2|triad3|tetrad|tetrad2|tetrad3|compound|compound2|highContrast|highContrast2|highContrast3|pentagram (or its label), steps?: 1..20 (4), variation?: \"tintsShades\"|\"warmCool\"|\"vividMuted\", amount?: 0..100 (50; how far the outermost steps go)} the Color Guide for a base colour → {rule, colors: [\"#rrggbb\", the base first], grid: [per colour, 2·steps+1 variations from shades/cool/muted to tints/warm/vivid with the colour itself in the centre]}", + "{color, rule: complementary|complementary2|splitComplementary|leftComplement|rightComplement|analogous|analogous2|monochromatic|shades|triad|triad2|triad3|tetrad|tetrad2|tetrad3|compound|compound2|highContrast|highContrast2|highContrast3|pentagram (or its label), steps?: 1..20 (4), variation?: \"tintsShades\"|\"warmCool\"|\"vividMuted\", amount?: 0..100 (50; how far the outermost steps go), limitTo?: swatch library id or name, or \"document\" (the document's swatches): every colour snaps to its nearest colour there (ΔE 2000)} the Color Guide for a base colour → {rule, colors: [\"#rrggbb\", the base first], grid: [per colour, 2·steps+1 variations from shades/cool/muted to tints/warm/vivid with the colour itself in the centre]}", always, harmony ), @@ -539,7 +539,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { Ok(json!({ "changed": changed })) } -fn harmony(_: &mut Session, p: &Value) -> Result { +fn harmony(s: &mut Session, p: &Value) -> Result { const C: &str = "color.harmony"; let base = p.get("color").and_then(color_value).ok_or_else(|| bad(C, "missing or invalid `color`"))?; let rule = str_param(p, "rule").ok_or_else(|| bad(C, "missing `rule`"))?; @@ -557,7 +557,10 @@ fn harmony(_: &mut Session, p: &Value) -> Result { if let Some(a) = p.get("amount").and_then(Value::as_f64) { opts.amount = a as f32; } - let g = Guide::new(base, rule, &opts); + let mut g = Guide::new(base, rule, &opts); + if let Some(palette) = super::swatchlib::limit_param(s, p, C)? { + g = g.limited(&palette); + } let hex = |cs: &[Color]| cs.iter().map(Color::to_hex).collect::>(); Ok(json!({ "rule": rule.id(), "colors": hex(&g.colors), "grid": g.grid.iter().map(|r| hex(r)).collect::>() })) } diff --git a/crates/engine/src/cmd/colortheme.rs b/crates/engine/src/cmd/colortheme.rs new file mode 100644 index 00000000..cc64deaa --- /dev/null +++ b/crates/engine/src/cmd/colortheme.rs @@ -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, + /// The harmony rule it was made with (a rule id), if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rule: Option, +} + +pub fn specs() -> Vec { + 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::>(), + "keys": t.colors.iter().map(|c| ColorKey::of(c).to_string()).collect::>(), + }); + if let Some(r) = &t.rule { + v["rule"] = json!(r); + } + v +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "themes": s.prefs.color_themes.iter().map(theme_json).collect::>() })) +} + +/// The index of the saved theme called `name`. +fn find(s: &Session, name: &str, cmd: &str) -> Result { + 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 { + 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 = 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::>()? + } + (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 { + 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 { + 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::>() }); + let r = s.execute("swatch.newGroup", ¶ms)?; + Ok(json!({ "name": r["name"], "swatches": r["swatches"] })) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index eaa2896b..5992c65e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 }) } diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 74c5c779..48977dd0 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use serde_json::{Value, json}; -use vectorcraft_color::recolor::{ColorKey, Method, Palette, Preserve, Rng, RowMap, cluster}; +use vectorcraft_color::recolor::{ColorKey, Method, Preserve, Rng, RowMap, cluster}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::swatches::node_colors; use vectorcraft_doc::{AppearanceItem, Document, NodeId, NodeKind}; @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library (new colours snap to its nearest colour), group?: colour group rewritten with groupColors?: [colour] (default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) and the group, as one undo step → {changed}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name, or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group rewritten with groupColors?: [colour] (default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) and the group, as one undo step → {changed}", has_doc, apply ), @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Reduce Colors", [], None, - "{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply, default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: swatch library (new colours snap to it)} group the selection's colours into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}", + "{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply, default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: as recolor.apply (new colours snap to it)} group the selection's colours into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}", has_selection, reduce ), @@ -134,17 +134,6 @@ fn method_param(p: &Value, cmd: &str, default: Method) -> Result { } } -/// The colours of the `limitTo` library (`None` without one). -fn palette_param(s: &Session, p: &Value, cmd: &str) -> Result> { - let Some(key) = str_param(p, "limitTo").filter(|k| !k.is_empty()) else { return Ok(None) }; - let (_, lib) = super::swatchlib::library(s, key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)")))?; - let palette = Palette::new(lib.iter().filter_map(|w| w.paint.color())); - if palette.is_empty() { - return Err(bad(cmd, format!("swatch library `{key}` has no colours"))); - } - Ok(Some(palette)) -} - fn key_str(c: &Color) -> Value { json!(ColorKey::of(c).to_string()) } @@ -152,7 +141,7 @@ fn key_str(c: &Color) -> Value { fn reduce(s: &mut Session, p: &Value) -> Result { const C: &str = "recolor.reduce"; let method = method_param(p, C, Method::ScaleTints)?; - let palette = palette_param(s, p, C)?; + let palette = super::swatchlib::limit_param(s, p, C)?; let def = Preserve::default(); let flag = |k: &str, d: bool| p.get("preserve").map_or(d, |v| bool_or(v, k, d)); let preserve = Preserve { white: flag("white", def.white), black: flag("black", def.black), grays: flag("grays", def.grays) }; @@ -297,7 +286,7 @@ impl ColorMap { fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "recolor.apply"; let method = method_param(p, C, Method::Exact)?; - let palette = palette_param(s, p, C)?; + let palette = super::swatchlib::limit_param(s, p, C)?; let mut rows = parse_rows(p, C)?; if let Some(pl) = &palette { for r in &mut rows { diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 942ad5d8..171aee02 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::libraries::{BuiltinLibrary, GRADIENT_LIBRARIES, SWATCH_LIBRARIES, builtin_library}; use vectorcraft_color::palette_io::{self, PaletteFormat}; +use vectorcraft_color::recolor::Palette; use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches}; use super::fileio::{create_dir, read_file, write_file}; @@ -267,6 +268,31 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc Option { + let colors = |sw: &Swatch| sw.paint.color(); + if key == DOCUMENT_SWATCHES { + return Some(Palette::new(s.active()?.doc.swatches_iter().filter_map(colors))); + } + Some(Palette::new(library(s, key)?.1.iter().filter_map(colors))) +} + +/// The `limitTo` parameter's palette ([`limit_palette`]); `None` without one (or for ""). +pub(crate) fn limit_param(s: &Session, p: &Value, cmd: &str) -> Result> { + let Some(key) = str_param(p, "limitTo").filter(|k| !k.is_empty()) else { return Ok(None) }; + let palette = limit_palette(s, key) + .ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list; \"{DOCUMENT_SWATCHES}\": the document's swatches)")))?; + if palette.is_empty() { + return Err(bad(cmd, format!("`{key}` has no colours"))); + } + Ok(Some(palette)) +} + fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc)> { let key = str_param(p, "library").ok_or_else(|| bad(cmd, "missing `library` (see swatch.library.list)"))?; library(s, key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)"))) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4177870b..2ab72a2e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, } 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; diff --git a/crates/engine/src/tests_colorguidelib.rs b/crates/engine/src/tests_colorguidelib.rs new file mode 100644 index 00000000..41b592b8 --- /dev/null +++ b/crates/engine/src/tests_colorguidelib.rs @@ -0,0 +1,70 @@ +//! The Color Guide's Limit to Library: `color.harmony {limitTo}` snaps every colour to a library or +//! to the document's swatches, as Recolor Artwork's `limitTo` does. + +use std::collections::HashSet; + +use serde_json::{Value, json}; + +use super::*; + +fn hexes(v: &Value) -> Vec { + v.as_array().unwrap().iter().map(|h| h.as_str().unwrap().to_string()).collect() +} + +/// Every colour `color.harmony` answers: the harmony colours and the whole grid. +fn all_colours(r: &Value) -> Vec { + let mut out = hexes(&r["colors"]); + for row in r["grid"].as_array().unwrap() { + out.extend(hexes(row)); + } + out +} + +fn library_hexes(s: &mut Session, library: &str) -> HashSet { + let lib = s.execute("swatch.library.get", &json!({ "library": library })).unwrap(); + lib["swatches"].as_array().unwrap().iter().filter_map(|w| w["hex"].as_str()).map(str::to_string).collect() +} + +#[test] +fn every_guide_colour_is_a_library_member() { + let mut s = Session::new(); + for library in ["web-safe-216", "earth-tones"] { + let members = library_hexes(&mut s, library); + for rule in ["triad", "monochromatic", "highContrast3", "pentagram"] { + let r = s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": rule, "steps": 6, "limitTo": library})).unwrap(); + let out = all_colours(&r); + assert_eq!(out.len(), r["colors"].as_array().unwrap().len() * 14); + assert!(out.iter().all(|h| members.contains(h)), "{library} {rule}: {out:?}"); + } + } + // A name works as well as an id; "" and no limit leave the colours free. + let by_name = s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": "Web Safe 216"})).unwrap(); + let by_id = s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": "web-safe-216"})).unwrap(); + assert_eq!(by_name, by_id); + let free = s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": ""})).unwrap(); + assert_eq!(free["colors"][0], "#3a7bd5"); + assert_ne!(by_id["colors"][0], "#3a7bd5", "a library without the base snaps it too"); + assert!(s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": "nope"})).is_err()); + assert!(s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": "document"})).is_err(), "no document"); +} + +#[test] +fn document_swatches_limit_the_guide_and_recolor() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let doc_hexes = + |s: &Session| -> HashSet { s.doc().unwrap().doc.swatches_iter().filter_map(|w| w.paint.color()).map(|c| c.to_hex()).collect() }; + s.execute("swatch.newGroup", &json!({"name": "Mine", "colors": ["#123456", "#fedcba"]})).unwrap(); + let members = doc_hexes(&s); + assert!(members.contains("#123456"), "colour groups count"); + let r = s.execute("color.harmony", &json!({"color": "#103050", "rule": "splitComplementary", "limitTo": "document"})).unwrap(); + assert!(all_colours(&r).iter().all(|h| members.contains(h)), "{r}"); + assert_eq!(r["colors"][0], "#123456", "the base snaps to the nearest swatch"); + // Recolor Artwork limits to the document's swatches the same way. + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#112255"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let r = s.execute("recolor.reduce", &json!({"limitTo": "document"})).unwrap(); + let to = cmd::color_value(&r["map"][0]["to"]).unwrap().to_hex(); + assert!(members.contains(&to) && to != "#112255", "{r}"); +} diff --git a/crates/engine/src/tests_colorthemes.rs b/crates/engine/src/tests_colorthemes.rs new file mode 100644 index 00000000..650838a8 --- /dev/null +++ b/crates/engine/src/tests_colorthemes.rs @@ -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 { + 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 = 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::>(), + theme.iter().map(Color::to_hex).collect::>() + ); + 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()); +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 591de5e5..bd260587 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -9,22 +9,26 @@ //! scale together), a brightness slider and the harmony rules. The preset menu sets up a 1, 2 or 3 //! colour job or a library. Opened on a colour group (Edit or Apply Color Group), OK also rewrites //! the group with the new colours (renamed by `groupName`); without art only the group changes. +//! Opened on colours alone (the Color Guide's Edit or Apply Colors without art), OK saves the new +//! colours as a colour group named `groupName`. //! //! Fields (`ui.dialog.set`): `rows` ([{from: [keys], to: key, exclude?}]), `colors` (null: Auto, or a -//! count), `method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo` (library id, "" for -//! none), `group`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, -//! `preview`. Changing `colors`, a preserve flag or `limitTo` reduces the rows again. +//! count), `method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo` (library id, +//! "document" for the document's swatches, "" for none), `group`, `groupName`, `tab` (`assign` or +//! `edit`), `rule` (harmony id), `linked`, `preview`. Changing `colors`, a preserve flag or +//! `limitTo` reduces the rows again. -use egui::{Pos2, Rect, Sense, Ui, pos2, vec2}; +use 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 { let st = app.session.active().ok_or("no document open")?; let art = !st.selection.is_empty(); @@ -61,14 +66,16 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { } None => None, }; - if !art && group.is_none() { - return Err("select artwork (or a colour group) to recolor".into()); - } let assign: Vec = match (&group_colors, p.get("colors")) { (Some(cs), _) => cs.iter().map(key).collect(), (None, Some(Value::Array(cs))) => cs.iter().filter_map(color_value).map(|c| key(&c)).collect(), _ => vec![], }; + // Colours alone (no art, no group) become a new colour group. + let new_group = !art && group.is_none(); + if new_group && assign.is_empty() { + return Err("select artwork (or a colour group) to recolor".into()); + } let count = match p.get("colors").and_then(Value::as_u64) { Some(n) => json!(n.max(1)), None if !assign.is_empty() => json!(assign.len()), @@ -76,18 +83,23 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { }; let limit = match p.get("library").and_then(Value::as_str) { Some("") => first_library(app).unwrap_or_default(), - Some(l) => swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}`"))?, + Some(l) => limit_key(app, l)?, None => String::new(), }; // Without art the rows are the group's colours, as they are. - let rows: Vec = if art { vec![] } else { assign.iter().map(|k| json!({"from": [k], "to": k})).collect() }; + let start: Vec = if art { vec![] } else { assign.iter().map(|k| json!({"from": [k], "to": k})).collect() }; let fields = json!({ - "rows": rows, "colors": count, "method": Method::ScaleTints.id(), + "rows": start, "colors": count, "method": Method::ScaleTints.id(), "preserveWhite": true, "preserveBlack": true, "preserveGrays": false, "limitTo": limit, - "group": group, "groupName": group, "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true, - "preview": true, "__art": art, "__assign": assign, + "group": group, "groupName": if new_group { Some(NEW_GROUP) } else { group }, "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true, + "preview": true, "__art": art, "__assign": assign, "__newGroup": new_group, }); - app.ui.dialog = Some(Dialog::new(KIND, fields)); + let mut d = Dialog::new(KIND, fields); + // Without art the rows are there from the start: limit them now (art's rows snap as they reduce). + let mut rs = rows(&d); + snap(app, &d, &mut rs); + d.fields.insert("rows".into(), Value::Array(rs)); + app.ui.dialog = Some(d); Ok(Value::Null) } @@ -95,13 +107,11 @@ fn key(c: &Color) -> Value { json!(ColorKey::of(c).to_string()) } -/// The libraries with colours, for Limit to Library: (id, name). -fn colour_libraries(app: &VectorcraftApp) -> Vec<(String, String)> { - swatchlib::libraries(&app.session).into_iter().filter(|l| l.category != "gradients").map(|l| (l.id, l.name)).collect() -} +/// The name a new colour group (opened on colours alone) starts with. +const NEW_GROUP: &str = "Color Group"; fn first_library(app: &VectorcraftApp) -> Option { - colour_libraries(app).into_iter().next().map(|l| l.0) + colour_libraries(app).into_iter().next().map(|l| l.id) } /// `recolor.reduce` parameters (Method only picks each row's colour, so changing it keeps the rows). @@ -141,8 +151,7 @@ fn sync(app: &mut VectorcraftApp, d: &mut Dialog) { /// With Limit to Library, the rows' new colours snap to the library's nearest colours. fn snap(app: &VectorcraftApp, d: &Dialog, rows: &mut [Value]) { - let Some((_, lib)) = Some(d.str("limitTo")).filter(|l| !l.is_empty()).and_then(|l| swatchlib::library(&app.session, &l)) else { return }; - let palette = Palette::new(lib.iter().filter_map(|w| w.paint.color())); + let Some(palette) = Some(d.str("limitTo")).filter(|l| !l.is_empty()).and_then(|l| swatchlib::limit_palette(&app.session, &l)) else { return }; for r in rows { if let Some(c) = to_color(r) { r["to"] = key(&palette.nearest(c)); @@ -216,7 +225,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { snap(app, d, &mut rs); d.fields.insert("rows".into(), Value::Array(rs)); } - if d.fields.get("group").is_some_and(Value::is_string) { + if d.fields.get("group").is_some_and(Value::is_string) || d.bool("__newGroup") { ui.add_space(6.0); ui.horizontal(|ui| { ui.label(egui::RichText::new("Color Group:").color(t.text_dim)); @@ -230,10 +239,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { false } -/// OK: keep the previewed recolour as one undo step (or apply it, e.g. to a colour group only). +/// OK: keep the previewed recolour as one undo step (or apply it, e.g. to a colour group only; or +/// save colours opened alone as a new colour group). fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut d = d.clone(); sync(app, &mut d); + if d.bool("__newGroup") { + let colors: Vec = rows(&d).into_iter().filter(|r| !excluded(r)).map(|r| r["to"].clone()).collect(); + return form::commit_preview(app, "swatch.newGroup", json!({"name": d.str("groupName"), "colors": colors})); + } form::commit_preview(app, CMD, apply_params(&d)) } @@ -407,12 +421,15 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut } ui.end_row(); ui.label(egui::RichText::new("Limit to Library:").color(t.text_dim)); + // None, the document's swatches, then the colour libraries. let libs = colour_libraries(app); + let keys: Vec<&str> = ["", swatchlib::DOCUMENT_SWATCHES].into_iter().chain(libs.iter().map(|l| l.id.as_str())).collect(); + let labels: Vec = keys.iter().map(|k| limit_name(app, k).unwrap_or_else(|| "None".into())).collect(); let cur = d.str("limitTo"); - let name = libs.iter().find(|l| l.0 == cur).map_or("None", |l| l.1.as_str()); - let labels: Vec<&str> = std::iter::once("None").chain(libs.iter().map(|l| l.1.as_str())).collect(); + let name = keys.iter().position(|k| *k == cur).map_or("None", |i| labels[i].as_str()); + let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); if let Some(i) = widgets::dropdown(ui, "recolor-library", name, &labels, 180.0) { - d.fields.insert("limitTo".into(), json!(if i == 0 { String::new() } else { libs[i - 1].0.clone() })); + d.fields.insert("limitTo".into(), json!(keys[i])); } ui.end_row(); }); @@ -468,57 +485,23 @@ fn separate(rows: &mut Vec, 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 { + (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 = 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 = (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::>(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 = 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| { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2685173f..2eed99a7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -162,7 +162,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.recolorDialog", "Recolor Artwork…", "", - "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order), library?: id or name (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", + "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name, or \"document\" (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ("file.export.pdf", "Save as PDF…", "", "{path?, artboard? | artboards? | range?: \"1-3, 5\"} (document.export options)"), @@ -344,6 +344,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{id, index} open Width Point Edit for width point `index` of path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking a width point with the Width tool opens it too): OK runs stroke.widthPoint.set, discard: true (the Delete button) stroke.widthPoint.remove", ), + ( + "ui.colorGuideLimit", + "Limit Color Guide to Library", + "", + "{library: swatch library id or name (see swatch.library.list) | \"document\" (the document's swatches) | \"\" or null (no limit)} limit the Color Guide panel's colours to that library: every harmony colour and variation snaps to its nearest colour, as color.harmony {limitTo} answers (ui.inspect: ui.color_guide_limit) → {limitTo, name}", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -714,6 +720,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), }, "ui.widthPointEdit" => crate::dialogs::width_point::open(app, p), + "ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p), _ => return None, }; Some(r) @@ -1460,6 +1467,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { 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"), diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index eb26a181..449c403a 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -3,14 +3,18 @@ //! shades/cool/muted to the left and tints/warm/vivid to the right (steps and reach from Color //! Guide Options). The base colour stays put until "Set base color" takes the current colour. A //! click on a cell applies it to the active proxy and selects it; Shift- or Cmd/Ctrl-click adds -//! cells to the selection that Save Colors as Swatches saves. +//! cells to the selection that Save Colors as Swatches saves. Limit to Library (the library button, +//! `ui.colorGuideLimit`) snaps every colour to a swatch library or the document's swatches; Edit or +//! Apply Colors opens Recolor Artwork with the harmony colours, limited the same way. use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_color::harmony::{Guide, Harmony, Variation}; +use vectorcraft_color::harmony::{Guide, GuideOptions, Harmony, Variation}; use vectorcraft_color::{Color, Paint}; +use vectorcraft_engine::cmd::swatchlib; -use super::{active_paint, apply_click, c32, color_json, pstate, set_pstate}; +use super::swatches::{self, colour_libraries, limit_key, limit_name}; +use super::{active_paint, apply_click, c32, color_json, library_panel, pstate, set_pstate}; use crate::VectorcraftApp; use crate::widgets::{self, menu_item}; @@ -41,8 +45,79 @@ fn clear_selection(ctx: &egui::Context) { set_pstate(ctx, "cg-sel", Vec::<(usize, usize)>::new()); } +/// What the panel's guide is made from: with a library limit, snapping every cell is too slow to +/// redo each frame, so the guide is cached until one of these changes. +#[derive(Clone, PartialEq)] +struct GuideKey { + base: Color, + rule: Harmony, + opts: GuideOptions, + limit: String, + /// The document's identity and revision while limited to its swatches. + doc: Option<(u64, u64)>, +} + +/// The guide the panel shows, limited to the Limit to Library colours. fn guide(app: &VectorcraftApp, ctx: &egui::Context) -> Guide { - Guide::new(base_color(app, ctx), rule(ctx), &app.ui.color_guide) + let (base, rule, opts) = (base_color(app, ctx), rule(ctx), app.ui.color_guide); + let limit = &app.ui.color_guide_limit; + if limit.is_empty() { + return Guide::new(base, rule, &opts); + } + let doc = app.session.active().filter(|_| limit == swatchlib::DOCUMENT_SWATCHES).map(|d| (d.uid, d.revision)); + let key = GuideKey { base, rule, opts, limit: limit.clone(), doc }; + if let Some((k, g)) = pstate::>(ctx, "cg-guide") + && k == key + { + return g; + } + let g = Guide::new(base, rule, &opts); + let g = match swatchlib::limit_palette(&app.session, limit) { + Some(p) => g.limited(&p), + None => g, + }; + set_pstate(ctx, "cg-guide", Some((key, g.clone()))); + g +} + +/// `ui.colorGuideLimit {library}`: limit the panel's colours to a swatch library (an id or name), +/// the document's swatches ("document"), or nothing (`""` or null). → {limitTo, name} +pub(crate) fn set_limit(app: &mut VectorcraftApp, p: &Value) -> Result { + let key = match p.get("library").and_then(Value::as_str).unwrap_or_default() { + "" => String::new(), + l => limit_key(app, l)?, + }; + let name = limit_name(app, &key); + app.ui.color_guide_limit = key; + Ok(json!({"limitTo": app.ui.color_guide_limit, "name": name})) +} + +/// The Limit to Library popup: None, Document Swatches, then the colour libraries. +fn limit_menu(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.set_min_width(200.0); + let cur = app.ui.color_guide_limit.clone(); + let mut chosen = None; + for (key, label) in [("", "None"), (swatchlib::DOCUMENT_SWATCHES, swatches::DOCUMENT_SWATCHES)] { + if menu_item(ui, label, true, cur == key) { + chosen = Some(key.to_string()); + } + } + ui.separator(); + chosen = library_panel::library_items(ui, &colour_libraries(app), Some(&cur)).or(chosen); + if let Some(key) = chosen { + set_limit(app, &json!({ "library": key })).ok(); + clear_selection(ui.ctx()); + } +} + +/// Edit or Apply Colors: Recolor Artwork with the harmony colours as the new colours (of the +/// selected art, or of the colours alone), limited to the panel's library. +fn edit_or_apply(app: &mut VectorcraftApp, g: &Guide) { + let mut p = json!({ "colors": g.colors.iter().map(color_json).collect::>() }); + if limit_name(app, &app.ui.color_guide_limit).is_some() { + p["library"] = json!(app.ui.color_guide_limit); + } + app.run("ui.recolorDialog", p).ok(); } /// Save the selected cells' colours as swatches (one undo step). @@ -125,18 +200,28 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } let (left, right) = app.ui.color_guide.variation.sides(); + let limit = limit_name(app, &app.ui.color_guide_limit); ui.horizontal(|ui| { widgets::dim_label(ui, left); - ui.add_space((ui.available_width() - 40.0).max(0.0)); + // The library the colours are limited to, centred between the ends. + let middle = ui.available_width() - 40.0; + let label = limit.as_deref().unwrap_or_default(); + ui.add_sized([middle.max(0.0), 16.0], egui::Label::new(egui::RichText::new(label).size(11.0).color(t.text_dim)).truncate()); widgets::dim_label(ui, right); }); widgets::bottom_bar(ui, |ui| { - widgets::icon_button_enabled(ui, "library", "Limit to Swatch Library (on the roadmap)", false, false, 24.0); + let tip = match &limit { + Some(name) => format!("Limit colors to swatch library: {name}"), + None => "Limit colors to swatch library".into(), + }; + let lr = widgets::icon_button(ui, "library", &tip, limit.is_some(), 24.0); + egui::Popup::menu(&lr).show(|ui| limit_menu(app, ui)); ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); - // Recolor Artwork with the harmony colours as the new colours (the selected art's, if any). - let art = app.session.active().is_some_and(|d| !d.selection.is_empty()); - if widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors", false, art, 24.0).clicked() { - app.run("ui.recolorDialog", json!({ "colors": g.colors.iter().map(color_json).collect::>() })).ok(); + // Recolor Artwork with the harmony colours as the new colours: the selected art's, or + // (without art) the colours themselves, which OK saves as a colour group. + let doc = app.session.active().is_some(); + if widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors", false, doc, 24.0).clicked() { + edit_or_apply(app, &g); } if widgets::icon_button_enabled(ui, "dc-new-item", "Save selected colors as swatches", false, !sel.is_empty(), 24.0).clicked() { save_selected(app, &ctx); @@ -222,4 +307,75 @@ mod tests { let g = guide(&app, &ctx); assert_eq!(st.doc.swatches.last().unwrap().paint.color().map(|c| c.to_hex()), Some(g.grid[0][8].to_hex())); } + + /// Every colour of the panel's guide. + fn shown(app: &VectorcraftApp, ctx: &egui::Context) -> Vec { + let g = guide(app, ctx); + g.grid.iter().flatten().chain(&g.colors).map(Color::to_hex).collect() + } + + fn library_hexes(app: &VectorcraftApp, id: &str) -> Vec { + let (_, lib) = swatchlib::library(&app.session, id).unwrap(); + lib.iter().filter_map(|w| w.paint.color()).map(|c| c.to_hex()).collect() + } + + #[test] + fn limit_to_library_snaps_the_panel_and_follows_the_document_swatches() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("paint.setFill", json!({"color": "#3a7bd5"})).unwrap(); + frame(&mut app, &ctx); + assert!(shown(&app, &ctx).contains(&"#3a7bd5".to_string()), "no limit: the base itself"); + let r = app.run("ui.colorGuideLimit", json!({"library": "Earth Tones"})).unwrap(); + assert_eq!((r["limitTo"].as_str(), r["name"].as_str()), (Some("earth-tones"), Some("Earth Tones"))); + frame(&mut app, &ctx); + let members = library_hexes(&app, "earth-tones"); + assert!(shown(&app, &ctx).iter().all(|h| members.contains(h)), "every cell is a library colour"); + // The document's swatches: the cached guide follows a new swatch. + app.run("ui.colorGuideLimit", json!({"library": "document"})).unwrap(); + frame(&mut app, &ctx); + assert!(!shown(&app, &ctx).contains(&"#3a7bd4".to_string())); + app.run("swatch.new", json!({"colors": ["#3a7bd4"]})).unwrap(); + frame(&mut app, &ctx); + let doc: Vec = app.session.doc().unwrap().doc.swatches_iter().filter_map(|w| w.paint.color()).map(|c| c.to_hex()).collect(); + let cells = shown(&app, &ctx); + assert!(cells.iter().all(|h| doc.contains(h))); + assert_eq!(guide(&app, &ctx).colors[0].to_hex(), "#3a7bd4", "the base snaps to the new swatch"); + // The popup lists libraries with colours only, draws; unknown libraries are refused and "" + // lifts the limit. + let libs = colour_libraries(&app); + assert!(libs.iter().any(|l| l.id == "earth-tones") && !libs.iter().any(|l| l.id == "metallic-gradients")); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| limit_menu(&mut app, ui)); + out.textures_delta.clear(); + assert!(app.run("ui.colorGuideLimit", json!({"library": "nope"})).is_err()); + assert_eq!(app.ui.color_guide_limit, "document"); + assert_eq!(app.run("ui.colorGuideLimit", json!({"library": ""})).unwrap()["name"], Value::Null); + assert!(shown(&app, &ctx).contains(&"#3a7bd5".to_string())); + } + + #[test] + fn edit_or_apply_without_art_saves_the_limited_colours_as_a_group() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("paint.setFill", json!({"color": "#c04020"})).unwrap(); + app.run("select.none", json!({})).unwrap(); + app.run("ui.colorGuideLimit", json!({"library": "web-safe-216"})).unwrap(); + frame(&mut app, &ctx); + let g = guide(&app, &ctx); + edit_or_apply(&mut app, &g); + let d = app.ui.dialog.as_ref().expect("Recolor opens on the colours alone"); + assert_eq!((d.kind.as_str(), d.str("limitTo").as_str()), ("recolor", "web-safe-216"), "limited to the same library"); + let groups = app.session.doc().unwrap().doc.swatch_groups.len(); + crate::dialogs::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + assert_eq!(doc.swatch_groups.len(), groups + 1, "OK saves a new colour group"); + let made: Vec = doc.swatch_groups.last().unwrap().swatches.iter().filter_map(|w| w.paint.color()).map(|c| c.to_hex()).collect(); + assert_eq!(made, g.colors.iter().map(Color::to_hex).collect::>()); + let members = library_hexes(&app, "web-safe-216"); + assert!(made.iter().all(|h| members.contains(h))); + // Colours from elsewhere snap to the library as the dialog opens. + app.run("ui.recolorDialog", json!({"colors": ["#123457"], "library": "web-safe-216"})).unwrap(); + let to = app.ui.dialog.as_ref().unwrap().fields["rows"][0]["to"].clone(); + assert_eq!(vectorcraft_engine::cmd::color_value(&to).map(|c| c.to_hex()).as_deref(), Some("#003366")); + } } diff --git a/crates/ui-egui/src/panels/color_themes.rs b/crates/ui-egui/src/panels/color_themes.rs new file mode 100644 index 00000000..0cde331a --- /dev/null +++ b/crates/ui-egui/src/panels/color_themes.rs @@ -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, + /// The harmony rule (`None`: Custom). + rule: Option, + /// The selected colour (the base a rule starts from). + sel: usize, + name: String, + /// The saved theme being edited: Save overwrites it. + editing: Option, +} + +impl Draft { + /// A new theme from `base` by `rule`. + fn new(base: Color, rule: Option) -> 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 { + 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::>(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 { + pstate::>(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::>(), ["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); + } +} diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 5e68c3a6..35d81835 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -343,33 +343,38 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { pub(crate) fn library_menu(app: &mut VectorcraftApp, ui: &mut Ui) { K::menu_head(app, ui); let current = app.ui.library_panel.as_ref().filter(|o| o.kind == K::KIND).map(|o| o.id.clone()); - let all = K::list(app); + if let Some(id) = library_items(ui, &K::list(app), current.as_deref()) { + open::(app, &id); + } + K::menu_tail(app, ui); +} + +/// Menu rows for `libs`: the top-level ones, then a submenu per [`LibraryRef::submenu`], with +/// `current` checked. Returns the id of the one clicked. +pub(crate) fn library_items(ui: &mut Ui, libs: &[LibraryRef], current: Option<&str>) -> Option { let mut chosen = None; let mut item = |ui: &mut Ui, l: &LibraryRef| { - if menu_item(ui, &l.name, true, current.as_deref() == Some(l.id.as_str())) { + if menu_item(ui, &l.name, true, current == Some(l.id.as_str())) { chosen = Some(l.id.clone()); } }; - for l in all.iter().filter(|l| l.submenu.is_none()) { + for l in libs.iter().filter(|l| l.submenu.is_none()) { item(ui, l); } let mut subs: Vec<&str> = vec![]; - for s in all.iter().filter_map(|l| l.submenu) { + for s in libs.iter().filter_map(|l| l.submenu) { if !subs.contains(&s) { subs.push(s); } } for s in subs { ui.menu_button(s, |ui| { - for l in all.iter().filter(|l| l.submenu == Some(s)) { + for l in libs.iter().filter(|l| l.submenu == Some(s)) { item(ui, l); } }); } - if let Some(id) = chosen { - open::(app, &id); - } - K::menu_tail(app, ui); + chosen } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index acdea134..04ad1aec 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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)); } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 1254e23c..d846a293 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -985,6 +985,38 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } +fn library_refs(libs: Vec) -> Vec { + libs.into_iter().map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect() +} + +/// The swatch libraries with solid colours (not the gradient-only ones), for Limit to Library. +pub(crate) fn colour_libraries(app: &VectorcraftApp) -> Vec { + let has_colours = |id: &str| swatchlib::library(&app.session, id).is_some_and(|(_, lib)| lib.iter().any(|w| w.paint.color().is_some())); + library_refs(swatchlib::libraries(&app.session).into_iter().filter(|l| l.category != "gradients" && has_colours(&l.id)).collect()) +} + +/// What Limit to Library menus call the document's own swatches ([`swatchlib::DOCUMENT_SWATCHES`]). +pub(crate) const DOCUMENT_SWATCHES: &str = "Document Swatches"; + +/// The name of Limit to Library choice `key` (a library id or [`swatchlib::DOCUMENT_SWATCHES`]); +/// `None` for no limit or a library that is gone. +pub(crate) fn limit_name(app: &VectorcraftApp, key: &str) -> Option { + match key { + "" => None, + swatchlib::DOCUMENT_SWATCHES => Some(DOCUMENT_SWATCHES.into()), + _ => swatchlib::library(&app.session, key).map(|(info, _)| info.name), + } +} + +/// The Limit to Library key for `l`: a library's id (from its id or name), or +/// [`swatchlib::DOCUMENT_SWATCHES`] as it is. +pub(crate) fn limit_key(app: &VectorcraftApp, l: &str) -> Result { + if l == swatchlib::DOCUMENT_SWATCHES { + return Ok(l.into()); + } + swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}` (see swatch.library.list)")) +} + /// Swatch libraries in the library panel (engine: `swatch.library.*`). pub(crate) struct SwatchLibraries; @@ -997,8 +1029,7 @@ impl LibraryKind for SwatchLibraries { type Item = Swatch; fn list(app: &VectorcraftApp) -> Vec { - let libs = swatchlib::libraries(&app.session).into_iter(); - libs.map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect() + library_refs(swatchlib::libraries(&app.session)) } fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib)) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3cd49d0e..909e6d33 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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(), } } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 21b367f5..d2bf33f1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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, + /// 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) -> 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::>(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 +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ded1beaa..7334defb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 diff --git a/docs/mcp.md b/docs/mcp.md index 77018faf..9a902455 100644 --- a/docs/mcp.md +++ b/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: ""` 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