diff --git a/crates/color/src/harmony.rs b/crates/color/src/harmony.rs index fe170d8f8..5223673ce 100644 --- a/crates/color/src/harmony.rs +++ b/crates/color/src/harmony.rs @@ -1,73 +1,298 @@ -//! Colour harmony rules (the Color Guide panel). +//! Colour harmony rules and the variation grid of the Color Guide panel (our own colour math). + +use serde::{Deserialize, Serialize}; use crate::{Color, keep_model}; +/// A harmony rule: a group of colours built from a base colour, base first. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Harmony { Complementary, + Complementary2, SplitComplementary, LeftComplement, RightComplement, Analogous, - Triad, - Tetrad, - Square, + Analogous2, + Monochromatic, Shades, + Triad, + Triad2, + Triad3, + Tetrad, + Tetrad2, + Tetrad3, + Compound, + Compound2, + HighContrast, + HighContrast2, + HighContrast3, + Pentagram, +} + +/// One colour of a rule: the base turned by `turn` degrees of hue, its saturation scaled by `sat` +/// and its brightness by `bright` (or flipped to the contrasting brightness). +#[derive(Clone, Copy)] +struct Tone { + turn: f32, + sat: f32, + bright: Bright, +} + +#[derive(Clone, Copy)] +enum Bright { + Times(f32), + /// Dark for a light base, light for a dark one ([`contrast`]). + Contrast, +} + +const fn sv(turn: f32, sat: f32, bright: f32) -> Tone { + Tone { turn, sat, bright: Bright::Times(bright) } +} +/// The base's hue turned by `turn`. +const fn t(turn: f32) -> Tone { + sv(turn, 1.0, 1.0) +} +/// A softer (half as saturated) partner. +const fn soft(turn: f32) -> Tone { + sv(turn, 0.5, 1.0) +} +/// A deeper (darker) partner. +const fn deep(turn: f32) -> Tone { + sv(turn, 1.0, 0.6) +} +/// A partner of contrasting brightness. +const fn hc(turn: f32) -> Tone { + Tone { turn, sat: 1.0, bright: Bright::Contrast } +} + +/// The brightness that contrasts with `v`. +fn contrast(v: f32) -> f32 { + if v >= 0.5 { v * 0.3 } else { 0.7 + 0.3 * v } } impl Harmony { - pub const ALL: [Harmony; 9] = [ + pub const ALL: [Harmony; 21] = [ Harmony::Complementary, + Harmony::Complementary2, Harmony::SplitComplementary, Harmony::LeftComplement, Harmony::RightComplement, Harmony::Analogous, - Harmony::Triad, - Harmony::Tetrad, - Harmony::Square, + Harmony::Analogous2, + Harmony::Monochromatic, Harmony::Shades, + Harmony::Triad, + Harmony::Triad2, + Harmony::Triad3, + Harmony::Tetrad, + Harmony::Tetrad2, + Harmony::Tetrad3, + Harmony::Compound, + Harmony::Compound2, + Harmony::HighContrast, + Harmony::HighContrast2, + Harmony::HighContrast3, + Harmony::Pentagram, ]; + pub fn label(self) -> &'static str { match self { Harmony::Complementary => "Complementary", + Harmony::Complementary2 => "Complementary 2", Harmony::SplitComplementary => "Split Complementary", Harmony::LeftComplement => "Left Complement", Harmony::RightComplement => "Right Complement", Harmony::Analogous => "Analogous", - Harmony::Triad => "Triad", - Harmony::Tetrad => "Tetrad", - Harmony::Square => "Square", + Harmony::Analogous2 => "Analogous 2", + Harmony::Monochromatic => "Monochromatic", Harmony::Shades => "Shades", + Harmony::Triad => "Triad", + Harmony::Triad2 => "Triad 2", + Harmony::Triad3 => "Triad 3", + Harmony::Tetrad => "Tetrad", + Harmony::Tetrad2 => "Tetrad 2", + Harmony::Tetrad3 => "Tetrad 3", + Harmony::Compound => "Compound", + Harmony::Compound2 => "Compound 2", + Harmony::HighContrast => "High Contrast", + Harmony::HighContrast2 => "High Contrast 2", + Harmony::HighContrast3 => "High Contrast 3", + Harmony::Pentagram => "Pentagram", } } - /// Colours of the harmony, base colour first, in the base colour's model. + + /// The rule's id in commands: its label in camelCase ("splitComplementary", "triad2"). + pub fn id(self) -> String { + let mut out = String::new(); + for (i, w) in self.label().split(' ').enumerate() { + let mut c = w.chars(); + if let Some(f) = c.next() { + if i == 0 { + out.extend(f.to_lowercase()); + } else { + out.extend(f.to_uppercase()); + } + out.push_str(c.as_str()); + } + } + out + } + + /// A rule from its id or label (case and spaces ignored). + pub fn parse(s: &str) -> Option { + let key = |x: &str| x.chars().filter(|c| !c.is_whitespace()).collect::().to_ascii_lowercase(); + let want = key(s); + Self::ALL.into_iter().find(|h| key(h.label()) == want) + } + + fn tones(self) -> &'static [Tone] { + match self { + Harmony::Complementary => const { &[t(0.0), t(180.0)] }, + Harmony::Complementary2 => const { &[t(0.0), soft(0.0), t(180.0), soft(180.0)] }, + Harmony::SplitComplementary => const { &[t(0.0), t(150.0), t(210.0)] }, + Harmony::LeftComplement => const { &[t(0.0), t(-30.0), t(150.0)] }, + Harmony::RightComplement => const { &[t(0.0), t(30.0), t(210.0)] }, + Harmony::Analogous => const { &[t(0.0), t(30.0), t(-30.0)] }, + Harmony::Analogous2 => const { &[t(0.0), t(30.0), t(60.0), t(-30.0), t(-60.0)] }, + Harmony::Monochromatic => const { &[t(0.0), sv(0.0, 0.5, 1.0), sv(0.0, 1.0, 0.6), sv(0.0, 0.25, 1.0), sv(0.0, 1.0, 0.35)] }, + Harmony::Shades => const { &[t(0.0), sv(0.0, 1.0, 0.8), sv(0.0, 1.0, 0.6), sv(0.0, 1.0, 0.4), sv(0.0, 1.0, 0.2)] }, + Harmony::Triad => const { &[t(0.0), t(120.0), t(240.0)] }, + Harmony::Triad2 => const { &[t(0.0), t(120.0), soft(120.0), t(240.0), soft(240.0)] }, + Harmony::Triad3 => const { &[t(0.0), t(120.0), deep(120.0), t(240.0), deep(240.0)] }, + Harmony::Tetrad => const { &[t(0.0), t(60.0), t(180.0), t(240.0)] }, + Harmony::Tetrad2 => const { &[t(0.0), t(90.0), t(180.0), t(270.0)] }, + Harmony::Tetrad3 => const { &[t(0.0), t(30.0), t(180.0), t(210.0)] }, + Harmony::Compound => const { &[t(0.0), t(40.0), t(160.0), t(200.0)] }, + Harmony::Compound2 => const { &[t(0.0), t(-40.0), t(160.0), t(200.0)] }, + Harmony::HighContrast => const { &[t(0.0), hc(180.0)] }, + Harmony::HighContrast2 => const { &[t(0.0), hc(0.0), t(180.0), hc(180.0)] }, + Harmony::HighContrast3 => const { &[t(0.0), hc(120.0), hc(240.0)] }, + Harmony::Pentagram => const { &[t(0.0), t(72.0), t(144.0), t(216.0), t(288.0)] }, + } + } + + /// The rule's colours in the base colour's model, the base colour itself first. pub fn apply(self, base: Color) -> Vec { let [h, s, v] = base.to_hsb(); - let hue = |d: f32| keep_model(base, Color::from_hsb(h + d, s, v)); + std::iter::once(base) + .chain(self.tones()[1..].iter().map(|tone| { + let b = match tone.bright { + Bright::Times(f) => v * f, + Bright::Contrast => contrast(v), + }; + keep_model(base, Color::from_hsb(h + tone.turn, (s * tone.sat).clamp(0.0, 1.0), b.clamp(0.0, 1.0))) + })) + .collect() + } +} + +/// What the variation grid varies: towards black and white, towards cool and warm, or towards +/// grey and full saturation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Variation { + #[default] + TintsShades, + WarmCool, + VividMuted, +} + +impl Variation { + pub const ALL: [Variation; 3] = [Variation::TintsShades, Variation::WarmCool, Variation::VividMuted]; + + pub fn id(self) -> &'static str { match self { - Harmony::Complementary => vec![base, hue(180.0)], - Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)], - Harmony::LeftComplement => vec![base, hue(150.0)], - Harmony::RightComplement => vec![base, hue(210.0)], - Harmony::Analogous => vec![hue(-60.0), hue(-30.0), base, hue(30.0), hue(60.0)], - Harmony::Triad => vec![base, hue(120.0), hue(240.0)], - Harmony::Tetrad => vec![base, hue(60.0), hue(180.0), hue(240.0)], - Harmony::Square => vec![base, hue(90.0), hue(180.0), hue(270.0)], - Harmony::Shades => (0..5).map(|i| keep_model(base, Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0)))).collect(), + Variation::TintsShades => "tintsShades", + Variation::WarmCool => "warmCool", + Variation::VividMuted => "vividMuted", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(s)) + } + /// The ends of the grid: (left, right). + pub fn sides(self) -> (&'static str, &'static str) { + match self { + Variation::TintsShades => ("Shades", "Tints"), + Variation::WarmCool => ("Cool", "Warm"), + Variation::VividMuted => ("Muted", "Vivid"), } } } -/// Tints and shades row for the Color Guide variation grid, in the base colour's model. -pub fn variations(base: Color, steps: usize) -> Vec { - let n = steps.max(1) as f32; - (0..steps) - .map(|i| { - let t = (i as f32 + 1.0) / (n + 1.0); - let c = if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) }; - keep_model(base, c) - }) - .collect() +/// The Color Guide's options: variation kind, steps on each side and how far they reach. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GuideOptions { + pub variation: Variation, + /// Variations on each side of the harmony colour (1..=20). + pub steps: u32, + /// How far the outermost variations go, 0..=100 (Less … More). + pub amount: f32, +} + +impl Default for GuideOptions { + fn default() -> Self { + Self { variation: Variation::TintsShades, steps: 4, amount: 50.0 } + } +} + +impl GuideOptions { + pub const MAX_STEPS: u32 = 20; + + /// The options with `steps` and `amount` clamped to their ranges. + pub fn clamped(self) -> Self { + Self { steps: self.steps.clamp(1, Self::MAX_STEPS), amount: self.amount.clamp(0.0, 100.0), ..self } + } + /// The share of the way to the target that the outermost steps reach (10% … 90%). + fn reach(&self) -> f32 { + 0.1 + 0.8 * self.amount.clamp(0.0, 100.0) / 100.0 + } +} + +/// Variation of `c` at `step` (−steps..=steps: negative towards shades/cool/muted, positive towards +/// tints/warm/vivid; 0 is `c` itself), in `c`'s model. +pub fn variation(c: Color, opts: &GuideOptions, step: i32) -> Color { + if step == 0 { + return c; + } + let f = (step.unsigned_abs() as f32 / opts.steps.max(1) as f32).min(1.0) * opts.reach(); + let v = match opts.variation { + Variation::TintsShades => c.lerp(if step < 0 { &Color::BLACK } else { &Color::WHITE }, f), + Variation::WarmCool => { + let target = if step < 0 { Color::rgb(0.2, 0.45, 1.0) } else { Color::rgb(1.0, 0.55, 0.1) }; + c.lerp(&target, f * 0.6) + } + Variation::VividMuted => { + let [h, s, b] = c.to_hsb(); + let s2 = if step < 0 { s * (1.0 - f) } else { s + (1.0 - s) * f }; + Color::from_hsb(h, s2.clamp(0.0, 1.0), b) + } + }; + keep_model(c, v) +} + +/// The Color Guide for a base colour: the harmony colours and, per colour, its row of variations +/// from `-steps` to `+steps` (the colour itself in the centre column). +#[derive(Clone, Debug, PartialEq)] +pub struct Guide { + pub colors: Vec, + pub grid: Vec>, +} + +impl Guide { + pub fn new(base: Color, rule: Harmony, opts: &GuideOptions) -> Self { + let opts = opts.clamped(); + let n = opts.steps as i32; + let colors = rule.apply(base); + let grid = colors.iter().map(|c| (-n..=n).map(|step| variation(*c, &opts, step)).collect()).collect(); + Self { colors, grid } + } + /// Index of the centre column (the harmony colours) in each grid row. + pub fn centre(&self) -> usize { + self.grid.first().map_or(0, |r| r.len() / 2) + } } #[cfg(test)] @@ -79,7 +304,20 @@ mod tests { let c = Harmony::Complementary.apply(Color::rgb(1.0, 0.0, 0.0)); assert_eq!(c[1].to_hex(), "#00ffff"); assert_eq!(Harmony::Triad.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hex(), "#00ff00"); - assert_eq!(variations(Color::rgb(1.0, 0.0, 0.0), 6).len(), 6); + assert_eq!(Harmony::Pentagram.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hsb()[0].round(), 72.0); + } + + #[test] + fn every_rule_puts_the_base_first() { + let counts = [2, 4, 3, 3, 3, 3, 5, 5, 5, 3, 5, 5, 4, 4, 4, 4, 4, 2, 4, 3, 5]; + assert_eq!(Harmony::ALL.len(), counts.len()); + for base in [Color::rgb8(230, 120, 40), Color::cmyk(0.1, 0.8, 0.3, 0.05), Color::gray(0.4)] { + for (h, n) in Harmony::ALL.into_iter().zip(counts) { + let c = h.apply(base); + assert_eq!(c.len(), n, "{}", h.label()); + assert_eq!(c[0], base, "{}: the base comes first, in its own model", h.label()); + } + } } #[test] @@ -90,6 +328,58 @@ mod tests { } let Color::Cmyk { c, m, y, .. } = Harmony::Complementary.apply(base)[1] else { unreachable!() }; assert!(c > m && c > y, "the complement of red is a cyan: {c} {m} {y}"); - assert!(variations(Color::gray(0.4), 4).iter().all(|c| matches!(c, Color::Gray { .. }))); + let g = Guide::new(Color::gray(0.4), Harmony::Triad, &GuideOptions::default()); + assert!(g.grid.iter().flatten().all(|c| matches!(c, Color::Gray { .. }))); + } + + #[test] + fn rules_parse_by_id_and_label() { + for h in Harmony::ALL { + assert_eq!(Harmony::parse(&h.id()), Some(h)); + assert_eq!(Harmony::parse(h.label()), Some(h)); + } + assert_eq!(Harmony::SplitComplementary.id(), "splitComplementary"); + assert_eq!(Harmony::HighContrast3.id(), "highContrast3"); + assert_eq!(Harmony::parse("square"), None); + } + + #[test] + fn high_contrast_flips_brightness() { + let light = Harmony::HighContrast.apply(Color::rgb(0.9, 0.8, 0.2)); + assert!(light[1].to_hsb()[2] < 0.5); + let dark = Harmony::HighContrast.apply(Color::rgb(0.2, 0.1, 0.05)); + assert!(dark[1].to_hsb()[2] > 0.5); + } + + #[test] + fn variations_step_out_from_the_colour() { + let c = Color::rgb(0.5, 0.5, 0.5); + let o = GuideOptions::default(); + assert_eq!(variation(c, &o, 0), c, "step 0 is the colour"); + assert!(variation(c, &o, 4).to_rgb()[0] > variation(c, &o, 2).to_rgb()[0]); + assert!(variation(c, &o, -4).to_rgb()[0] < 0.5); + let red = Color::rgb(1.0, 0.2, 0.2); + let vm = GuideOptions { variation: Variation::VividMuted, ..o }; + assert!(variation(red, &vm, -2).to_hsb()[1] < red.to_hsb()[1]); + let warm = variation(c, &GuideOptions { variation: Variation::WarmCool, ..o }, 2).to_rgb(); + assert!(warm[0] > warm[2]); + // More variation reaches further. + let more = GuideOptions { amount: 100.0, ..o }; + assert!(variation(c, &more, -4).to_rgb()[0] < variation(c, &o, -4).to_rgb()[0]); + } + + #[test] + fn guide_rows_hold_the_colour_in_the_centre() { + let base = Color::rgb8(30, 120, 200); + let g = Guide::new(base, Harmony::Triad, &GuideOptions { steps: 3, ..Default::default() }); + assert_eq!(g.colors.len(), 3); + assert!(g.grid.iter().all(|r| r.len() == 7)); + assert_eq!(g.centre(), 3); + for (row, c) in g.grid.iter().zip(&g.colors) { + assert_eq!(row[3], *c); + } + // Steps are clamped to 1..=20. + let g = Guide::new(base, Harmony::Triad, &GuideOptions { steps: 99, ..Default::default() }); + assert_eq!(g.grid[0].len(), 41); } } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 630bef24e..8d0708b5a 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::cms::Model; +use vectorcraft_color::harmony::{Guide, GuideOptions, Harmony, Variation}; use vectorcraft_color::{Color, Paint, keep_model}; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; @@ -93,6 +94,15 @@ pub fn specs() -> Vec { has_selection, |s, _| blend(s, BlendOrder::Vertical) ), + cmd!( + query "color.harmony", + "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]}", + always, + harmony + ), ] } @@ -322,3 +332,26 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { })?; Ok(json!({ "changed": changed })) } + +fn harmony(_: &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`"))?; + let rule = Harmony::parse(rule).ok_or_else(|| { + let ids: Vec = Harmony::ALL.iter().map(|h| h.id()).collect(); + bad(C, format!("unknown rule `{rule}` ({})", ids.join("|"))) + })?; + let mut opts = GuideOptions::default(); + if let Some(n) = p.get("steps").and_then(Value::as_u64) { + opts.steps = n.min(u32::MAX as u64) as u32; + } + if let Some(v) = str_param(p, "variation") { + opts.variation = Variation::parse(v).ok_or_else(|| bad(C, format!("unknown variation `{v}` (tintsShades|warmCool|vividMuted)")))?; + } + 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 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/swatch.rs b/crates/engine/src/cmd/swatch.rs index 63b35f35f..3be1648b4 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "New Swatch", ["Window", "Swatches"], None, - "{name?, color? | swatch? | gradient? | pattern?: name (default: the current fill), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name}", + "{name?, color? | colors?: [colour] (one swatch each, in one undo step) | swatch? | gradient? | pattern?: name (default: the current fill), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name, names: [every new swatch]}", has_doc, swatch_new ), @@ -118,41 +118,57 @@ pub fn specs() -> Vec { fn swatch_new(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.new"; - let paint = match str_param(p, "pattern") { - Some(name) if s.doc()?.doc.pattern(name).is_some() => pattern_paint(name), - Some(name) => return Err(bad(C, format!("no pattern `{name}`"))), - None => paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()), + let paints = match (p.get("colors").and_then(Value::as_array), str_param(p, "pattern")) { + (Some(cs), _) => { + let cs: Vec = + cs.iter().map(|c| color_value(c).map(Paint::solid)).collect::>().ok_or_else(|| bad(C, "bad colour in `colors`"))?; + if cs.is_empty() { + return Err(bad(C, "`colors` is empty")); + } + cs + } + (None, Some(name)) if s.doc()?.doc.pattern(name).is_some() => vec![pattern_paint(name)], + (None, Some(name)) => return Err(bad(C, format!("no pattern `{name}`"))), + (None, None) => vec![paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone())], }; let spot = bool_or(p, "spot", false); let global = spot || bool_or(p, "global", false); let mode = str_param(p, "mode"); let group = str_param(p, "group"); - let paint = match paint { - // The swatch holds the colour itself, not a link to the swatch it may have come from. - Paint::Solid { color, .. } => Paint::solid(match mode { - Some(m) => convert_to_mode(color, m, C)?, - None => color, - }), - Paint::None => return Err(bad(C, "a swatch needs a colour, gradient or pattern")), - _ if spot || mode.is_some() || group.is_some() => return Err(bad(C, "spot, mode and group apply to solid colours only")), - other => other, - }; - let requested = name_param(p, "name"); - let name = s.edit("New Swatch", |d, _| { - let name = match requested { - Some(n) => d.free_swatch_name(&n), - None => d.new_swatch_name(&paint), - }; - let swatch = Swatch { name: name.clone(), paint, global, spot }; - match group { - Some(g) => { - d.swatch_groups.iter_mut().find(|x| x.name == g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?.swatches.push(swatch) + let paints = paints + .into_iter() + .map(|paint| match paint { + // The swatch holds the colour itself, not a link to the swatch it may have come from. + Paint::Solid { color, .. } => Ok(Paint::solid(match mode { + Some(m) => convert_to_mode(color, m, C)?, + None => color, + })), + Paint::None => Err(bad(C, "a swatch needs a colour, gradient or pattern")), + _ if spot || mode.is_some() || group.is_some() => Err(bad(C, "spot, mode and group apply to solid colours only")), + other => Ok(other), + }) + .collect::>>()?; + // A name applies to a single new swatch. + let requested = name_param(p, "name").filter(|_| paints.len() == 1); + let names = s.edit("New Swatch", |d, _| { + let mut names = vec![]; + for paint in paints { + let name = match &requested { + Some(n) => d.free_swatch_name(n), + None => d.new_swatch_name(&paint), + }; + let swatch = Swatch { name: name.clone(), paint, global, spot }; + match group { + Some(g) => { + d.swatch_groups.iter_mut().find(|x| x.name == g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?.swatches.push(swatch) + } + None => d.swatches.push(swatch), } - None => d.swatches.push(swatch), + names.push(name); } - Ok(name) + Ok(names) })?; - Ok(json!({ "name": name })) + Ok(json!({ "name": names[0], "names": names })) } /// The `names` list and `name` parameters, without repeats; at least one is required. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 88e86f1cd..885f0f9aa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -748,6 +748,8 @@ mod tests_clip; #[cfg(test)] mod tests_cmdsplit; #[cfg(test)] +mod tests_colorguide; +#[cfg(test)] mod tests_colormgmt; #[cfg(test)] mod tests_dashalign; diff --git a/crates/engine/src/tests_colorguide.rs b/crates/engine/src/tests_colorguide.rs new file mode 100644 index 000000000..e5b979717 --- /dev/null +++ b/crates/engine/src/tests_colorguide.rs @@ -0,0 +1,72 @@ +//! The Color Guide: `color.harmony` and saving several colours as swatches at once. + +use serde_json::{Value, json}; +use vectorcraft_color::Color; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn hexes(v: &Value) -> Vec { + v.as_array().unwrap().iter().map(|h| h.as_str().unwrap().to_string()).collect() +} + +#[test] +fn harmony_query_returns_the_group_and_its_grid() { + let mut s = Session::new(); + let r = s.execute("color.harmony", &json!({"color": "#ff0000", "rule": "triad", "steps": 2})).unwrap(); + assert_eq!(r["rule"], "triad"); + assert_eq!(hexes(&r["colors"]), ["#ff0000", "#00ff00", "#0000ff"], "the base comes first"); + let grid = r["grid"].as_array().unwrap(); + assert_eq!(grid.len(), 3, "a row per harmony colour"); + for (row, c) in grid.iter().zip(hexes(&r["colors"])) { + let row = hexes(row); + assert_eq!(row.len(), 5, "2·steps + 1 columns"); + assert_eq!(row[2], c, "step 0 is the colour itself"); + } + // Tints and shades by default: darker on the left, lighter on the right. + let first = hexes(&grid[0]); + let lum = |h: &str| Color::from_hex(h).unwrap().to_rgb().iter().sum::(); + assert!(lum(&first[0]) < lum(&first[2]) && lum(&first[4]) > lum(&first[2])); + // Labels work as rule names, other variations and amounts change the grid. + let r2 = + s.execute("color.harmony", &json!({"color": "#ff0000", "rule": "Split Complementary", "variation": "vividMuted", "amount": 100})).unwrap(); + assert_eq!(r2["rule"], "splitComplementary"); + assert_eq!(r2["grid"][0].as_array().unwrap().len(), 9, "4 steps by default"); + // Every rule answers, base first. + for h in vectorcraft_color::harmony::Harmony::ALL { + let r = s.execute("color.harmony", &json!({"color": "#3366cc", "rule": h.id()})).unwrap(); + assert_eq!(r["colors"][0], "#3366cc", "{}", h.label()); + } + assert!(s.execute("color.harmony", &json!({"color": "#ff0000", "rule": "square"})).is_err()); + assert!(s.execute("color.harmony", &json!({"rule": "triad"})).is_err()); + assert!(s.execute("color.harmony", &json!({"color": "#ff0000", "rule": "triad", "variation": "loud"})).is_err()); +} + +#[test] +fn new_swatch_saves_several_colours_in_one_step() { + let mut s = session(); + let before = s.doc().unwrap().doc.swatches.len(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("swatch.new", &json!({"colors": ["#112233", "#445566", {"c": 10, "m": 20, "y": 30, "k": 0}]})).unwrap(); + assert_eq!(hexes(&r["names"]).len(), 3); + let st = s.doc().unwrap(); + assert_eq!(st.doc.swatches.len(), before + 3); + assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); + assert_eq!(r["name"], "R=17 G=34 B=51"); + assert!(st.doc.swatch("C=10 M=20 Y=30 K=0").is_some()); + // Into a colour group, as global swatches. + s.execute("swatch.newGroup", &json!({"name": "Guide"})).unwrap(); + let r = s.execute("swatch.new", &json!({"colors": ["#010203", "#040506"], "group": "Guide", "global": true})).unwrap(); + let d = &s.doc().unwrap().doc; + let g = d.swatch_groups.iter().find(|g| g.name == "Guide").unwrap(); + assert_eq!(g.swatches.len(), 2); + assert!(g.swatches.iter().all(|w| w.global)); + assert_eq!(hexes(&r["names"]), g.swatches.iter().map(|w| w.name.clone()).collect::>()); + assert!(s.execute("swatch.new", &json!({"colors": []})).is_err()); + assert!(s.execute("swatch.new", &json!({"colors": ["nope"]})).is_err()); +} diff --git a/crates/ui-egui/src/dialogs/color_guide_options.rs b/crates/ui-egui/src/dialogs/color_guide_options.rs new file mode 100644 index 000000000..55975166c --- /dev/null +++ b/crates/ui-egui/src/dialogs/color_guide_options.rs @@ -0,0 +1,92 @@ +//! Color Guide Options: the variation steps on each side of the harmony colours (1–20) and how far +//! they reach (Less … More), with the base colour's variations as a preview. OK sets the Color +//! Guide panel's options (`UiState::color_guide`). +//! +//! Fields: `steps` (1–20) and `amount` (0–100). + +use egui::{Rect, Sense, vec2}; +use serde_json::{Value, json}; +use vectorcraft_color::harmony::{GuideOptions, variation}; + +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::panels::{c32, color_guide::base_color}; +use crate::state::Dialog; +use crate::theme::Tokens; + +/// The dialog kind of Color Guide Options. +pub const KIND: &str = "colorGuideOptions"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Color Guide Options".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM }; + +/// Open the dialog with the panel's current options. +pub fn open(app: &mut VectorcraftApp) { + let o = app.ui.color_guide; + app.ui.dialog = Some(Dialog::new(KIND, json!({"steps": o.steps, "amount": o.amount}))); +} + +/// The panel's options with the dialog's steps and amount. +fn options(app: &VectorcraftApp, d: &Dialog) -> GuideOptions { + let o = app.ui.color_guide; + GuideOptions { steps: d.f64("steps", o.steps as f64).round().max(1.0) as u32, amount: d.f64("amount", o.amount as f64) as f32, ..o }.clamped() +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let max = GuideOptions::MAX_STEPS as f64; + let ramp = |x: f32| { + let v = (60.0 + 160.0 * x) as u8; + egui::Color32::from_rgb(v, v, v) + }; + form::slider(ui, d, "steps", "Steps:", 1.0..=max, "", &ramp); + form::slider(ui, d, "amount", "Variation:", 0.0..=100.0, "%", &ramp); + ui.horizontal(|ui| { + ui.add_space(form::SLIDER_LABEL + ui.spacing().item_spacing.x); + let (r, _) = ui.allocate_exact_size(vec2(form::SLIDER_WIDTH, 14.0), Sense::hover()); + let font = egui::FontId::proportional(11.0); + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, "Less", font.clone(), t.text_dim); + ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, "More", font, t.text_dim); + }); + ui.add_space(10.0); + // Preview: the base colour's row of variations. + let o = options(app, d); + let base = base_color(app, ui.ctx()); + let n = o.steps as i32; + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); + let w = row.width() / (2 * n + 1) as f32; + for (i, step) in (-n..=n).enumerate() { + let r = Rect::from_min_size(row.min + vec2(i as f32 * w, 0.0), vec2(w, row.height())); + ui.painter().rect_filled(r, 0.0, c32(&variation(base, &o, step))); + } + ui.painter().rect_stroke(row, 0.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Outside); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.color_guide = options(app, d); + app.ui.dialog = None; + Ok(json!(app.ui.color_guide)) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + #[test] + fn ok_sets_the_panel_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("ui.colorGuideOptions", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("steps".into(), json!(30)); + d.fields.insert("amount".into(), json!(80)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!((app.ui.color_guide.steps, app.ui.color_guide.amount), (20, 80.0), "steps are clamped to 20"); + } +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 5a2744d7d..2a206e152 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -137,3 +137,40 @@ pub(super) fn humanize(k: &str) -> String { _ => format!("{s}:"), } } + +/// Widths of [`slider`]'s label column and rail. +pub(super) const SLIDER_LABEL: f32 = 64.0; +pub(super) const SLIDER_WIDTH: f32 = 180.0; + +/// A labelled slider with a value field for the number `d.fields[key]` in `range` (whole numbers, +/// `suffix` after the value); `track(t)` colours the rail at 0..1. Returns true when it changed. +pub(super) fn slider( + ui: &mut egui::Ui, + d: &mut Dialog, + key: &str, + label: &str, + range: std::ops::RangeInclusive, + suffix: &str, + track: &dyn Fn(f32) -> egui::Color32, +) -> bool { + let t = Tokens::get(ui.ctx()); + let (min, max) = (*range.start(), *range.end()); + let v = d.f64(key, 0.0).clamp(min, max); + let mut new = None; + ui.horizontal(|ui| { + ui.add_sized([SLIDER_LABEL, 22.0], egui::Label::new(egui::RichText::new(label).color(t.text))); + if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) { + new = Some((min + x as f64 * (max - min)).round()); + } + if let Some(x) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0) { + new = Some(x.round().clamp(min, max)); + } + }); + match new { + Some(n) if n != v => { + d.fields.insert(key.into(), json!(n)); + true + } + _ => false, + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 0b12280e0..88c09f79a 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -9,6 +9,7 @@ mod about; mod all_tools; mod artboard_options; +pub mod color_guide_options; mod color_picker; mod command; pub mod confirm; @@ -144,6 +145,7 @@ registry! { ColorPicker: [color_picker::KIND] => color_picker::SPEC, GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC, EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC, + ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c682e7b0..9b8f14362 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -201,6 +201,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `command` running eyedropper.setOptions, fields appearance, transparency)", ), + ( + "ui.colorGuideOptions", + "Color Guide Options…", + "", + "{} open Color Guide Options (dialog `colorGuideOptions`, fields `steps` 1–20 and `amount` 0–100): OK sets the Color Guide panel's variation grid (read back with `ui.inspect`: ui.color_guide; engine: color.harmony)", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -522,6 +528,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_color_picker(app, p), "ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()), "tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()), + "ui.colorGuideOptions" => { + crate::dialogs::color_guide_options::open(app); + Ok(Value::Null) + } _ => return None, }; Some(r) diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index d124c477a..abdda9c2b 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -1,124 +1,144 @@ -//! Color Guide panel: base colour, harmony-rule dropdown with the harmony strip, and a variation -//! grid (tints/shades, warm/cool or vivid/muted) whose cells apply to the active proxy. +//! Color Guide panel: the base colour, the harmony-rule dropdown with the harmony strip, and the +//! variation grid: a row per harmony colour with the colour itself in the centre column, +//! 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. use egui::{Rect, Sense, Ui, vec2}; -use serde_json::json; -use vectorcraft_color::harmony::Harmony; +use serde_json::{Value, json}; +use vectorcraft_color::harmony::{Guide, Harmony, Variation}; use vectorcraft_color::{Color, Paint}; -use super::{active_paint, apply_click, color_json, pstate, set_pstate}; +use super::{active_paint, apply_click, c32, color_json, pstate, set_pstate}; use crate::VectorcraftApp; use crate::widgets::{self, menu_item}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Variation { - #[default] - TintsShades, - WarmCool, - VividMuted, +/// The base colour before one is set: an orange. +const DEFAULT_BASE: Color = Color::Rgb { r: 230.0 / 255.0, g: 120.0 / 255.0, b: 40.0 / 255.0 }; + +/// The guide's base colour: fixed once set; the current colour when the panel first shows. +pub(crate) fn base_color(app: &VectorcraftApp, ctx: &egui::Context) -> Color { + if let Some(c) = pstate::>(ctx, "cg-base") { + return c; + } + let c = active_paint(app).color().unwrap_or(DEFAULT_BASE); + set_pstate(ctx, "cg-base", Some(c)); + c } -/// Rows above/below the base row in the variation grid. -pub const STEPS: i32 = 3; - -/// Variation of `c` at `step` (−STEPS..=STEPS; 0 = the colour itself). -pub fn variation(c: &Color, mode: Variation, step: i32) -> Color { - if step == 0 { - return *c; - } - let f = step.unsigned_abs() as f32 / (STEPS as f32 + 1.0); - match mode { - Variation::TintsShades => { - if step < 0 { - c.lerp(&Color::BLACK, f) - } else { - c.lerp(&Color::WHITE, f) - } - } - Variation::WarmCool => { - let target = if step < 0 { Color::rgb(0.2, 0.45, 1.0) } else { Color::rgb(1.0, 0.55, 0.1) }; - c.lerp(&target, f * 0.6) - } - Variation::VividMuted => { - let [h, s, b] = c.to_hsb(); - let s2 = if step < 0 { s * (1.0 - f) } else { s + (1.0 - s) * f }; - Color::from_hsb(h, s2.clamp(0.0, 1.0), b) - } - } -} - -fn harmony(ui: &Ui) -> Harmony { - let i: usize = pstate(ui.ctx(), "cg-harmony"); +fn rule(ctx: &egui::Context) -> Harmony { + let i: usize = pstate(ctx, "cg-harmony"); Harmony::ALL[i.min(Harmony::ALL.len() - 1)] } +/// Selected grid cells: (row, column). +fn selected(ctx: &egui::Context) -> Vec<(usize, usize)> { + pstate(ctx, "cg-sel") +} + +fn clear_selection(ctx: &egui::Context) { + set_pstate(ctx, "cg-sel", Vec::<(usize, usize)>::new()); +} + +fn guide(app: &VectorcraftApp, ctx: &egui::Context) -> Guide { + Guide::new(base_color(app, ctx), rule(ctx), &app.ui.color_guide) +} + +/// Save the selected cells' colours as swatches (one undo step). +fn save_selected(app: &mut VectorcraftApp, ctx: &egui::Context) { + let g = guide(app, ctx); + let colors: Vec = selected(ctx).into_iter().filter_map(|(r, c)| g.grid.get(r)?.get(c)).map(color_json).collect(); + if !colors.is_empty() { + app.run("swatch.new", json!({ "colors": colors })).ok(); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = crate::theme::Tokens::get(ui.ctx()); - let base = match active_paint(app) { - Paint::Solid { color, .. } => color, - _ => pstate::>(ui.ctx(), "cg-base").unwrap_or(Color::rgb8(230, 120, 40)), - }; - let h = harmony(ui); - let colors = h.apply(base); - let mode: Variation = pstate(ui.ctx(), "cg-mode"); + let ctx = ui.ctx().clone(); + let h = rule(&ctx); + let g = guide(app, &ctx); + let base = g.colors[0]; let mut chosen: Option = None; ui.horizontal(|ui| { let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); widgets::swatch_tile(ui, r, &Paint::solid(base), false, resp.hovered()); - if resp.on_hover_text("Set base color to the current color").clicked() { - set_pstate(ui.ctx(), "cg-base", Some(base)); + if resp.on_hover_text("Set base color to the current color").clicked() + && let Some(c) = active_paint(app).color() + { + set_pstate(&ctx, "cg-base", Some(c)); + clear_selection(&ctx); } let labels: Vec<&str> = Harmony::ALL.iter().map(|h| h.label()).collect(); if let Some(i) = widgets::dropdown(ui, "cg-rule", h.label(), &labels, ui.available_width() - 4.0) { - set_pstate(ui.ctx(), "cg-harmony", i); + set_pstate(&ctx, "cg-harmony", i); + clear_selection(&ctx); } }); // Harmony strip. let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover()); - let w = strip.width() / colors.len() as f32; - for (i, c) in colors.iter().enumerate() { + let w = strip.width() / g.colors.len() as f32; + for (i, c) in g.colors.iter().enumerate() { let r = Rect::from_min_size(strip.min + vec2(i as f32 * w, 0.0), vec2(w, strip.height())); let resp = ui.interact(r, ui.id().with(("cg-h", i)), Sense::click()); - ui.painter().rect_filled(r, 0.0, super::c32(c)); + ui.painter().rect_filled(r, 0.0, c32(c)); if resp.on_hover_text(c.to_hex()).clicked() { chosen = Some(*c); } } ui.add_space(6.0); - // Variation grid: columns = harmony colours, rows = variations. - let cols = colors.len().max(1) as f32; - let cell_w = (ui.available_width() / cols).floor(); - for step in -STEPS..=STEPS { - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); - for (i, c) in colors.iter().enumerate() { - let v = variation(c, mode, step); - let r = Rect::from_min_size(row.min + vec2(i as f32 * cell_w, 0.0), vec2(cell_w - 1.0, 17.0)); - let resp = ui.interact(r, ui.id().with(("cg-v", step, i)), Sense::click()); - ui.painter().rect_filled(r, 0.0, super::c32(&v)); - if step == 0 { + // Variation grid: a row per harmony colour, the colour itself in the centre column. + let mut sel = selected(&ctx); + let cols = g.grid.first().map_or(1, Vec::len); + let centre = g.centre(); + let cell_w = ui.available_width() / cols as f32; + let gap = if cell_w >= 8.0 { 1.0 } else { 0.0 }; + // Cell edges on whole pixels across the full width. + let x = |i: usize| (i as f32 * cell_w).round(); + let multi = ui.input(|i| i.modifiers.shift || i.modifiers.command); + for (ri, row) in g.grid.iter().enumerate() { + let (rr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); + for (ci, v) in row.iter().enumerate() { + let r = Rect::from_min_size(rr.min + vec2(x(ci), 0.0), vec2(x(ci + 1) - x(ci) - gap, 17.0)); + let resp = ui.interact(r, ui.id().with(("cg-v", ri, ci)), Sense::click()); + ui.painter().rect_filled(r, 0.0, c32(v)); + if sel.contains(&(ri, ci)) { + ui.painter().rect_stroke(r.shrink(1.0), 0.0, egui::Stroke::new(1.0, egui::Color32::WHITE), egui::StrokeKind::Inside); + ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Outside); + } else if ci == centre { ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.text_strong), egui::StrokeKind::Inside); } if resp.on_hover_text(v.to_hex()).clicked() { - chosen = Some(v); + if !multi { + sel.clear(); + chosen = Some(*v); + } + match sel.iter().position(|s| *s == (ri, ci)) { + Some(i) => { + sel.remove(i); + } + None => sel.push((ri, ci)), + } + set_pstate(&ctx, "cg-sel", sel.clone()); } } } - let label = match mode { - Variation::TintsShades => ("Shades", "Tints"), - Variation::WarmCool => ("Cool", "Warm"), - Variation::VividMuted => ("Muted", "Vivid"), - }; + let (left, right) = app.ui.color_guide.variation.sides(); ui.horizontal(|ui| { - widgets::dim_label(ui, label.0); + widgets::dim_label(ui, left); ui.add_space((ui.available_width() - 40.0).max(0.0)); - widgets::dim_label(ui, label.1); + 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); - ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); - widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors (Recolor Artwork is on the roadmap)", false, false, 24.0); + ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); + widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors (on the roadmap)", false, false, 24.0); + 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); + } if widgets::icon_button(ui, "dc-folder", "Save color group to Swatch panel", false, 24.0).clicked() { - let cs: Vec<_> = colors.iter().map(color_json).collect(); + let cs: Vec<_> = g.colors.iter().map(color_json).collect(); app.run("swatch.newGroup", json!({"name": h.label(), "colors": cs})).ok(); } }); @@ -127,32 +147,75 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } -pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) { - let mode: Variation = pstate(ui.ctx(), "cg-mode"); +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let mode = app.ui.color_guide.variation; for (m, l) in [(Variation::TintsShades, "Show Tints/Shades"), (Variation::WarmCool, "Show Warm/Cool"), (Variation::VividMuted, "Show Vivid/Muted")] { if menu_item(ui, l, true, m == mode) { - set_pstate(ui.ctx(), "cg-mode", m); + app.ui.color_guide.variation = m; } } ui.separator(); - menu_item(ui, "Color Guide Options…", false, false); + let ctx = ui.ctx().clone(); + if menu_item(ui, "Save Colors as Swatches", !selected(&ctx).is_empty(), false) { + save_selected(app, &ctx); + } + if menu_item(ui, "Color Guide Options…", true, false) { + app.run("ui.colorGuideOptions", json!({})).ok(); + } } #[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(); + } #[test] - fn variations() { - let c = Color::rgb(0.5, 0.5, 0.5); - assert_eq!(variation(&c, Variation::TintsShades, 0), c); - assert!(variation(&c, Variation::TintsShades, STEPS).to_rgb()[0] > 0.5); - assert!(variation(&c, Variation::TintsShades, -STEPS).to_rgb()[0] < 0.5); - let red = Color::rgb(1.0, 0.2, 0.2); - assert!(variation(&red, Variation::VividMuted, -2).to_hsb()[1] < red.to_hsb()[1]); - let warm = variation(&c, Variation::WarmCool, 2).to_rgb(); - assert!(warm[0] > warm[2]); + fn base_stays_until_set_and_the_grid_follows_the_options() { + let mut app = app(); + app.run("paint.setFill", json!({"color": "#3366cc"})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx); + assert_eq!(base_color(&app, &ctx).to_hex(), "#3366cc", "the first show takes the current colour"); + // Another current colour leaves the base alone. + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + frame(&mut app, &ctx); + assert_eq!(base_color(&app, &ctx).to_hex(), "#3366cc"); + app.ui.color_guide.steps = 6; + frame(&mut app, &ctx); + let g = guide(&app, &ctx); + assert_eq!(g.grid[0].len(), 13); + assert_eq!(g.grid[0][6].to_hex(), "#3366cc", "the base sits in the centre of the first row"); + } + + #[test] + fn save_colors_as_swatches_saves_each_selected_cell() { + let mut app = app(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx); + let before = app.session.doc().unwrap().doc.swatches.len(); + set_pstate(&ctx, "cg-sel", vec![(0usize, 0usize), (1, 4), (0, 8)]); + let undo = app.session.doc().unwrap().history.undo.len(); + save_selected(&mut app, &ctx); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.swatches.len(), before + 3); + assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); + 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())); } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5cdbbcdd3..3ccc56c09 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -246,6 +246,9 @@ pub struct UiState { /// Engine preferences (Edit → Preferences), persisted alongside the UI state. #[serde(default)] pub engine_prefs: Value, + /// Color Guide panel: variation kind, steps and amount (Color Guide Options). + #[serde(default)] + pub color_guide: vectorcraft_color::harmony::GuideOptions, } impl UiState { @@ -297,6 +300,7 @@ impl Default for UiState { recent_files: vec![], recent_fonts: vec![], engine_prefs: Value::Null, + color_guide: Default::default(), } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5369c64af..63b59eb96 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -64,3 +64,9 @@ value: true}` then confirm opens a fresh one that adds another, `ui.dialog.cance Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for `edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`, `includeSpotBlacks`); `ui.dialog.set` then `ui.dialog.confirm` runs it on the selection. + +Color Guide: `color.harmony {color, rule, steps?, variation?, amount?}` returns the harmony group (base first) and +its variation grid; the panel draws the same grid. `ui.colorGuideOptions` opens the `colorGuideOptions` dialog +(fields `steps` 1–20, `amount` 0–100) and `ui.dialog.confirm` sets the panel's options, which `ui.inspect` reports +as `ui.color_guide` (`variation`, `steps`, `amount`). Save Colors as Swatches runs `swatch.new {colors: [...]}` +(one swatch per colour, one undo step). diff --git a/docs/mcp.md b/docs/mcp.md index c429fe9d2..a51114779 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -104,6 +104,9 @@ Document colour mode: `file.new {colorMode: "cmyk"}` starts a CMYK document with colours applied to it (`paint.setFill`/`setStroke` colours and gradient stops, `swatch.new`) are stored as CMYK; Gray stays Gray, and `keepModel: true` keeps a colour as given. RGB documents keep colours as given. Harmonies, Edit 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). ## Resources