mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06: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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
81582c8d4f
commit
be648089be
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user