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