mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
6c25e54914
commit
bfebf66404
13 files changed
+782
-152
No files matched your search
+324
-34
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<_>>() }))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user