M3.53: Color Guide has 21 harmony rules, Steps and Variation options, a fixed base colour and Save Colors as Swatches

The harmony rules (Complementary 1/2, Split Complementary, Left/Right Complement, Analogous 1/2,
Monochromatic, Shades, Triad 1-3, Tetrad 1-3, Compound 1/2, High Contrast 1-3, Pentagram) live in
the color crate with our own math, base colour first. The variation grid moves there too
(`harmony::Guide`): a row per harmony colour with the colour in the centre column, shades/cool/muted
to the left and tints/warm/vivid to the right, 1-20 steps and a variation amount.

- `color.harmony {color, rule, steps?, variation?, amount?}` query returns {rule, colors, grid}.
- The panel keeps its base colour until "Set base color" takes the current one; Shift/Cmd-click
  selects several cells and Save Colors as Swatches saves them (`swatch.new {colors}`, one step).
- Color Guide Options dialog (`ui.colorGuideOptions`, kind `colorGuideOptions`) with a preview row;
  the options persist in `UiState::color_guide`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 00:13:36 +07:00
1 parent 6c25e54914
commit bfebf66404
13 files changed
+782 -152

No files matched your search

+324 -34
View File
@@ -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<Self> {
let key = |x: &str| x.chars().filter(|c| !c.is_whitespace()).collect::<String>().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<Color> {
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> {
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<Color> {
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<Color>,
pub grid: Vec<Vec<Color>>,
}
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);
}
}
+33
View File
@@ -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<CommandSpec> {
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<Value> {
})?;
Ok(json!({ "changed": changed }))
}
fn harmony(_: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "color.harmony";
let base = p.get("color").and_then(color_value).ok_or_else(|| bad(C, "missing or invalid `color`"))?;
let rule = str_param(p, "rule").ok_or_else(|| bad(C, "missing `rule`"))?;
let rule = Harmony::parse(rule).ok_or_else(|| {
let ids: Vec<String> = 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::<Vec<_>>();
Ok(json!({ "rule": rule.id(), "colors": hex(&g.colors), "grid": g.grid.iter().map(|r| hex(r)).collect::<Vec<_>>() }))
}
+44 -28
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
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<Paint> =
cs.iter().map(|c| color_value(c).map(Paint::solid)).collect::<Option<_>>().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::<Result<Vec<_>>>()?;
// 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.
+2
View File
@@ -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;
+72
View File
@@ -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<String> {
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::<f32>();
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::<Vec<_>>());
assert!(s.execute("swatch.new", &json!({"colors": []})).is_err());
assert!(s.execute("swatch.new", &json!({"colors": ["nope"]})).is_err());
}
@@ -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<Value, String> {
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");
}
}
+37
View File
@@ -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<f64>,
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,
}
}
+2
View File
@@ -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).
+10
View File
@@ -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<R
"ui.colorPicker" => 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)
+153 -90
View File
@@ -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::<Option<Color>>(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<Value> = 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::<Option<Color>>(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<Color> = 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()));
}
}
+4
View File
@@ -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(),
}
}
}
+6
View File
@@ -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).
+3
View File
@@ -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