diff --git a/crates/color/src/harmony.rs b/crates/color/src/harmony.rs index e8ef3483..a251bc79 100644 --- a/crates/color/src/harmony.rs +++ b/crates/color/src/harmony.rs @@ -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 { + let colors = self.apply(base); + let n = colors.len(); + let mut rows: Vec> = 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); + } } diff --git a/crates/engine/src/cmd/colortheme.rs b/crates/engine/src/cmd/colortheme.rs new file mode 100644 index 00000000..cc64deaa --- /dev/null +++ b/crates/engine/src/cmd/colortheme.rs @@ -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, + /// The harmony rule it was made with (a rule id), if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rule: Option, +} + +pub fn specs() -> Vec { + 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::>(), + "keys": t.colors.iter().map(|c| ColorKey::of(c).to_string()).collect::>(), + }); + if let Some(r) = &t.rule { + v["rule"] = json!(r); + } + v +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "themes": s.prefs.color_themes.iter().map(theme_json).collect::>() })) +} + +/// The index of the saved theme called `name`. +fn find(s: &Session, name: &str, cmd: &str) -> Result { + 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 { + 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 = 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::>()? + } + (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 { + 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 { + 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::>() }); + let r = s.execute("swatch.newGroup", ¶ms)?; + Ok(json!({ "name": r["name"], "swatches": r["swatches"] })) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index eaa2896b..5992c65e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ee96401c..2ab72a2e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, } 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; diff --git a/crates/engine/src/tests_colorthemes.rs b/crates/engine/src/tests_colorthemes.rs new file mode 100644 index 00000000..650838a8 --- /dev/null +++ b/crates/engine/src/tests_colorthemes.rs @@ -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 { + 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 = 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::>(), + theme.iter().map(Color::to_hex).collect::>() + ); + 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()); +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 3fe8fd87..bd260587 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -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, 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 { + (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 = 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 = (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::>(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 = 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| { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b13b54dc..2eed99a7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1467,6 +1467,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { 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"), diff --git a/crates/ui-egui/src/panels/color_themes.rs b/crates/ui-egui/src/panels/color_themes.rs new file mode 100644 index 00000000..0cde331a --- /dev/null +++ b/crates/ui-egui/src/panels/color_themes.rs @@ -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, + /// The harmony rule (`None`: Custom). + rule: Option, + /// The selected colour (the base a rule starts from). + sel: usize, + name: String, + /// The saved theme being edited: Save overwrites it. + editing: Option, +} + +impl Draft { + /// A new theme from `base` by `rule`. + fn new(base: Color, rule: Option) -> 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 { + 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::>(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 { + pstate::>(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::>(), ["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); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index acdea134..04ad1aec 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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)); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 1e3eacba..909e6d33 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 21b367f5..d2bf33f1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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, + /// 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) -> 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::>(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 +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3282f946..7334defb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 diff --git a/docs/mcp.md b/docs/mcp.md index fe1b85f0..9a902455 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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: ""` 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 |