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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user