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:
bflatasticandClaude Opus 5.5 committed 2026-10-05 05:08:28 +07:00
1 parent 81582c8d4f
commit be648089be
13 files changed
+936 -109

No files matched your search

+84 -1
View File
@@ -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);
}
}
+148
View File
@@ -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", &params)?;
Ok(json!({ "name": r["name"], "swatches": r["swatches"] }))
}
+2
View File
@@ -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
})
}
+7
View File
@@ -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;
+106
View File
@@ -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());
}
+26 -107
View File
@@ -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| {
+1
View File
@@ -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"),
+441
View File
@@ -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);
}
}
+3
View File
@@ -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));
}
+1
View File
@@ -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.
+101 -1
View File
@@ -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
}
+4
View File
@@ -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
View File
@@ -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 |