diff --git a/crates/color/src/harmony.rs b/crates/color/src/harmony.rs index 01336d20..fe170d8f 100644 --- a/crates/color/src/harmony.rs +++ b/crates/color/src/harmony.rs @@ -1,6 +1,6 @@ //! Colour harmony rules (the Color Guide panel). -use crate::Color; +use crate::{Color, keep_model}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Harmony { @@ -40,10 +40,10 @@ impl Harmony { Harmony::Shades => "Shades", } } - /// Colours of the harmony, base colour first. + /// Colours of the harmony, base colour first, in the base colour's model. pub fn apply(self, base: Color) -> Vec { let [h, s, v] = base.to_hsb(); - let hue = |d: f32| Color::from_hsb(h + d, s, v); + let hue = |d: f32| keep_model(base, Color::from_hsb(h + d, s, v)); match self { Harmony::Complementary => vec![base, hue(180.0)], Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)], @@ -53,18 +53,19 @@ impl Harmony { Harmony::Triad => vec![base, hue(120.0), hue(240.0)], Harmony::Tetrad => vec![base, hue(60.0), hue(180.0), hue(240.0)], Harmony::Square => vec![base, hue(90.0), hue(180.0), hue(270.0)], - Harmony::Shades => (0..5).map(|i| Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0))).collect(), + Harmony::Shades => (0..5).map(|i| keep_model(base, Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0)))).collect(), } } } -/// Tints and shades row for the Color Guide variation grid. +/// Tints and shades row for the Color Guide variation grid, in the base colour's model. pub fn variations(base: Color, steps: usize) -> Vec { let n = steps.max(1) as f32; (0..steps) .map(|i| { let t = (i as f32 + 1.0) / (n + 1.0); - if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) } + let c = if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) }; + keep_model(base, c) }) .collect() } @@ -80,4 +81,15 @@ mod tests { assert_eq!(Harmony::Triad.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hex(), "#00ff00"); assert_eq!(variations(Color::rgb(1.0, 0.0, 0.0), 6).len(), 6); } + + #[test] + fn harmonies_keep_the_base_model() { + let base = Color::cmyk(0.0, 1.0, 1.0, 0.0); + for h in Harmony::ALL { + assert!(h.apply(base).iter().all(|c| matches!(c, Color::Cmyk { .. })), "{}", h.label()); + } + let Color::Cmyk { c, m, y, .. } = Harmony::Complementary.apply(base)[1] else { unreachable!() }; + assert!(c > m && c > y, "the complement of red is a cyan: {c} {m} {y}"); + assert!(variations(Color::gray(0.4), 4).iter().all(|c| matches!(c, Color::Gray { .. }))); + } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index a115f88e..a3dc3692 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -205,6 +205,36 @@ impl Color { let b = other.to_rgb(); Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) } + /// The colour model this colour is authored in. + pub fn model(&self) -> cms::Model { + match self { + Color::Rgb { .. } => cms::Model::Rgb, + Color::Cmyk { .. } => cms::Model::Cmyk, + Color::Gray { .. } => cms::Model::Gray, + } + } + /// This colour expressed in `model` with the profile-free formulas Edit Colors uses (RGB from + /// the display colour, CMYK by [`Color::to_cmyk`], Gray as ink from luminance); unchanged when it + /// is in `model` already. + pub fn in_model(self, model: cms::Model) -> Color { + if self.model() == model { + return self; + } + match model { + cms::Model::Rgb => { + let [r, g, b] = self.to_rgb(); + Color::rgb(r, g, b) + } + cms::Model::Cmyk => { + let [c, m, y, k] = self.to_cmyk(); + Color::cmyk(c, m, y, k) + } + cms::Model::Gray => { + let [r, g, b] = self.to_rgb(); + Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0)) + } + } + } pub fn model_name(&self) -> &'static str { match self { Color::Rgb { .. } => "RGB", @@ -214,6 +244,12 @@ impl Color { } } +/// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations +/// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from. +pub fn keep_model(orig: Color, new: Color) -> Color { + new.in_model(orig.model()) +} + /// What fills or strokes an object. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] @@ -322,6 +358,19 @@ mod tests { assert_eq!(Color::rgb(1.0, 1.0, 0.0).invert_keep_model(), Color::rgb(0.0, 0.0, 1.0)); } + #[test] + fn colours_convert_between_models_and_keep_them() { + use cms::Model; + let red = Color::rgb(1.0, 0.0, 0.0); + assert_eq!(red.in_model(Model::Cmyk), Color::cmyk(0.0, 1.0, 1.0, 0.0)); + assert_eq!(red.in_model(Model::Rgb), red, "already RGB: unchanged"); + assert_eq!(Color::gray(0.25).in_model(Model::Gray), Color::gray(0.25)); + assert_eq!(Color::WHITE.in_model(Model::Gray), Color::gray(0.0)); + assert_eq!(keep_model(Color::cmyk(0.1, 0.2, 0.3, 0.0), red).model(), Model::Cmyk); + assert_eq!(keep_model(Color::gray(0.5), red).model(), Model::Gray); + assert_eq!(keep_model(red, Color::rgb(0.0, 0.5, 1.0)), Color::rgb(0.0, 0.5, 1.0)); + } + #[test] fn paint_serde() { let p = Paint::solid(Color::rgb8(10, 20, 30)); diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index 8423371a..e87b5555 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; +use crate::cms::Model; use crate::{Color, Gradient, GradientKind, GradientStop, Paint}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -26,9 +27,33 @@ fn solid(name: &str, hex: &str) -> Swatch { Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap()), global: false, spot: false } } +/// The default swatches of a document in colour `model`: [`base_swatches`] with their RGB colours +/// (gradient stops too) as whole-percent CMYK in a CMYK document. Greys stay Gray. +pub fn default_swatches(model: Model) -> (Vec, Vec) { + let (mut s, mut g) = base_swatches(); + if model == Model::Cmyk { + let cmyk = |c: &mut Color| { + if let Color::Rgb { .. } = c { + let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round() / 100.0; + let [cc, m, y, k] = c.to_cmyk(); + *c = Color::cmyk(pct(cc), pct(m), pct(y), pct(k)); + } + }; + for w in s.iter_mut().chain(g.iter_mut().flat_map(|g| g.swatches.iter_mut())) { + match &mut w.paint { + Paint::Solid { color, .. } => cmyk(color), + Paint::Gradient(gp) => gp.gradient.stops.iter_mut().for_each(|st| cmyk(&mut st.color)), + _ => {} + } + } + } + (s, g) +} + /// The default document swatches: None, Registration-like black, white, black, a spectrum, greys, -/// gradients. (Composition is ours; Illustrator's layout grammar — None first, then specials — is kept.) -pub fn default_swatches() -> (Vec, Vec) { +/// gradients. (The composition is ours; the reference app's layout grammar — None first, then +/// specials — is kept.) +fn base_swatches() -> (Vec, Vec) { let mut s = vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, solid("White", "#ffffff"), solid("Black", "#000000")]; let spectrum = [ @@ -110,10 +135,28 @@ mod tests { #[test] fn defaults_start_with_none() { - let (s, g) = default_swatches(); + let (s, g) = default_swatches(Model::Rgb); assert!(s[0].paint.is_none()); assert!(s.len() > 30); assert_eq!(g.len(), 2); assert_eq!(g[0].swatches.len(), 9); } + + #[test] + fn cmyk_defaults_are_cmyk_with_the_same_names() { + let (rgb, _) = default_swatches(Model::Rgb); + let (s, g) = default_swatches(Model::Cmyk); + assert_eq!(s.iter().map(|w| &w.name).collect::>(), rgb.iter().map(|w| &w.name).collect::>()); + let colors = s.iter().chain(g.iter().flat_map(|g| &g.swatches)).flat_map(|w| match &w.paint { + Paint::Solid { color, .. } => vec![*color], + Paint::Gradient(gp) => gp.gradient.stops.iter().map(|st| st.color).collect(), + _ => vec![], + }); + assert!(colors.clone().all(|c| !matches!(c, Color::Rgb { .. }))); + assert!(colors.clone().any(|c| matches!(c, Color::Gray { .. })), "greys stay Gray"); + let white = s.iter().find(|w| w.name == "White").and_then(|w| w.paint.color()); + assert_eq!(white, Some(Color::cmyk(0.0, 0.0, 0.0, 0.0))); + let black = s.iter().find(|w| w.name == "Black").and_then(|w| w.paint.color()); + assert_eq!(black, Some(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a5274314..f0bdd7ce 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -207,6 +207,16 @@ pub enum ColorMode { Cmyk, } +impl ColorMode { + /// The colour model new colours take in a document of this mode. + pub fn model(self) -> vectorcraft_color::cms::Model { + match self { + ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb, + ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Artboard { pub id: u32, @@ -389,15 +399,20 @@ fn ppi72() -> f64 { pub const FORMAT_VERSION: u32 = 1; impl Document { - /// A new document with one artboard of `size` and one layer ("Layer 1"). + /// A new RGB document with one artboard of `size` and one layer ("Layer 1"). pub fn new(width: f64, height: f64) -> Self { - let (swatches, swatch_groups) = vectorcraft_color::default_swatches(); + Self::new_with_mode(width, height, ColorMode::Rgb) + } + + /// [`Document::new`] in colour `mode`, with that mode's default swatches. + pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self { + let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model()); let mut d = Self { version: FORMAT_VERSION, title: "Untitled-1".into(), template: false, units: Unit::Points, - color_mode: ColorMode::Rgb, + color_mode: mode, artboards: vec![Artboard { id: 1, name: "Artboard 1".into(), diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 6ff1c173..4e99240b 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -105,6 +105,23 @@ impl Document { } } +impl Document { + /// Visit every paint held by the art, symbol definitions, pattern tiles and graphic styles + /// (fills, strokes and text runs), and the colour of every gradient-mesh point as a solid paint. + pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) { + let mut tree = |n: &Node| { + n.walk(&mut |m| { + node_paints(m).for_each(&mut *f); + if let NodeKind::Mesh(mesh) = &m.kind { + mesh.points.iter().for_each(|p| f(&Paint::solid(p.color))); + } + }) + }; + self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art)).for_each(|n| tree(n)); + self.graphic_styles.iter().flat_map(|gs| &gs.appearance.items).for_each(|it| f(item_paint(it))); + } +} + /// The default name of a solid swatch: its values in its own colour model ("C=10 M=20 Y=30 K=0", /// "R=255 G=128 B=0", "Gray K=40"). pub fn color_name(c: Color) -> String { diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 632c49e8..630bef24 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::cms::Model; +use vectorcraft_color::{Color, Paint, keep_model}; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; use super::edit::selected_roots; @@ -100,29 +101,12 @@ pub(crate) fn invert(c: Color) -> Color { c.invert_keep_model() } -/// Express `new` in the colour model of `orig`. -fn keep_model(orig: Color, new: Color) -> Color { - match orig { - Color::Rgb { .. } => { - let [r, g, b] = new.to_rgb(); - Color::rgb(r, g, b) - } - Color::Cmyk { .. } => to_cmyk(new), - Color::Gray { .. } => to_gray(new), - } -} - pub(crate) fn to_cmyk(c: Color) -> Color { - let [c, m, y, k] = c.to_cmyk(); - Color::cmyk(c, m, y, k) + c.in_model(Model::Cmyk) } pub(crate) fn to_gray(c: Color) -> Color { - if let Color::Gray { .. } = c { - return c; - } - let [r, g, b] = c.to_rgb(); - Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0)) + c.in_model(Model::Gray) } /// Apply `f` to a paint; returns whether anything changed. @@ -318,6 +302,8 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { } let first = d.node(ids[0]).and_then(fill_color).unwrap_or_default(); let last = d.node(*ids.last().unwrap()).and_then(fill_color).unwrap_or_default(); + // Ends in one model blend in it; mixed ones give colours in the document's model. + let model = if first.model() == last.model() { first.model() } else { d.color_mode.model() }; let n = ids.len(); let label = match order { BlendOrder::Stack => "Blend Front to Back", @@ -327,7 +313,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { let changed = s.edit(label, |d, _| { let mut changed = 0; for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { - let c = lerp_model(first, last, i as f32 / (n - 1) as f32); + let c = lerp_model(first, last, i as f32 / (n - 1) as f32).in_model(model); if let Some(node) = d.node_mut(*id) { changed += map_node_colors(node, &|_| c, true, false); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4f269465..02ba945f 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "New…", ["File"], Some("Cmd+N"), - "{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\"}", + "{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK)}", always, file_new ), @@ -58,14 +58,15 @@ fn file_new(s: &mut Session, p: &Value) -> Result { if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 { return Err(bad("file.new", "width/height must be positive and within the canvas")); } - let mut d = Document::new(w, h); + let mode = match str_param(p, "colorMode") { + Some(m) if m.eq_ignore_ascii_case("cmyk") => vectorcraft_doc::ColorMode::Cmyk, + _ => vectorcraft_doc::ColorMode::Rgb, + }; + let mut d = Document::new_with_mode(w, h, mode); d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled()); if let Some(u) = str_param(p, "units") { d.units = parse_unit(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?; } - if str_param(p, "colorMode").is_some_and(|m| m.eq_ignore_ascii_case("cmyk")) { - d.color_mode = vectorcraft_doc::ColorMode::Cmyk; - } let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; for i in 1..n { let r = vectorcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 73c5c550..fa45d6e7 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy)} sets the selection's fill and the default (new art fits a gradient to itself)", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", has_doc, |s, p| set_paint(s, p, true) ), @@ -261,8 +261,21 @@ fn stroke_param(s: &Session, p: &Value) -> bool { p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active) } -/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. +/// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in +/// an RGB one, or with `keepModel`). +fn new_color_model(s: &Session, p: &Value) -> Option { + let mode = s.active()?.doc.color_mode; + (mode != vectorcraft_doc::ColorMode::Rgb && !bool_or(p, "keepModel", false)).then(|| mode.model()) +} + +/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A +/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]). pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { + let model = new_color_model(s, p); + let new_color = |c: Color| match (c, model) { + (Color::Rgb { .. }, Some(m)) => c.in_model(m), + _ => c, + }; if bool_or(p, "none", false) { return Ok(Some(Paint::None)); } @@ -282,12 +295,13 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { return Ok(Some(paint)); } if let Some(g) = p.get("gradient") { - let gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?; + let mut gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?; + gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color)); return Ok(Some(Paint::Gradient(Box::new(gp)))); } if let Some(c) = p.get("color") { let c = color_value(c).ok_or_else(|| bad("paint", format!("bad color {c}")))?; - return Ok(Some(Paint::solid(c))); + return Ok(Some(Paint::solid(new_color(c)))); } Ok(None) } diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 2ab32e3a..d76f9b1e 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{Color, Paint, keep_model}; use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use super::*; @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included)", + "{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included); each new colour keeps the model of the one it replaces", has_selection, apply ), @@ -81,7 +81,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { if let Some(n) = d.node_mut(*id) { visit_paints(n, &mut |c| { if let Some(to) = map.get(&c.to_hex()) { - *c = *to; + *c = keep_model(*c, *to); changed += 1; } }); diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 0aedd729..63b35f35 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -59,6 +59,60 @@ pub fn specs() -> Vec { has_doc, swatch_list ), + cmd!( + "swatch.move", + "Move Swatch", + ["Window", "Swatches"], + None, + "{name? | names?: [swatch names, in order; or colour group names to reorder the groups], to?: index in the destination (they go before the swatch or group now there; default: the end), group?: destination colour group (default: the ungrouped swatches; groups hold solid colours only)} reorder swatches or move them into or out of colour groups, as one undo step → {moved}", + has_doc, + swatch_move + ), + cmd!( + "swatch.addUsedColors", + "Add Used Colors", + ["Window", "Swatches"], + None, + "{selection?: false (only the selected art's colours; default: all the art's), global?: false (the new swatches are global and the matching unlinked colours in that art link to them)} add a swatch for each colour used (fills, strokes, text, gradient stops, mesh points) that no solid swatch has yet, as one undo step → {added: [names], linked}", + has_doc, + swatch_add_used + ), + cmd!( + query "swatch.unused", + "Select All Unused", + ["Window", "Swatches"], + None, + "{} → {names: [the swatches nothing uses, in panel order]}: a colour is used when a paint links to it or an unlinked paint, gradient stop or mesh point has its colour; a gradient when a gradient links to it or has its stops; a pattern when it fills or strokes anything (art, symbols, pattern tiles and graphic styles count). None is never listed", + has_doc, + swatch_unused + ), + cmd!( + "swatch.merge", + "Merge Swatches", + ["Window", "Swatches"], + None, + "{names: [two or more solid-colour swatches]} keep the first (its name and colour) and delete the others; paints linked to them take its colour and link to it (or unlink when it isn't global), as one undo step → {name, merged: [deleted names], relinked}", + has_doc, + swatch_merge + ), + cmd!( + "swatch.ungroup", + "Ungroup Color Group", + ["Window", "Swatches"], + None, + "{name: colour group} move its swatches to the end of the ungrouped swatches and remove the group, as one undo step → {swatches: [names]}", + has_doc, + swatch_ungroup + ), + cmd!( + "swatch.sortByKind", + "Sort by Kind", + ["Window", "Swatches"], + None, + "{} order the swatches of each list by kind (None first, then process colours, spot colours, gradients, patterns; colour groups stay after them), keeping their order within a kind, as one undo step", + has_doc, + swatch_sort_by_kind + ), ] } @@ -101,8 +155,8 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": name })) } -fn swatch_delete(s: &mut Session, p: &Value) -> Result { - const C: &str = "swatch.delete"; +/// The `names` list and `name` parameters, without repeats; at least one is required. +fn names_param(p: &Value, cmd: &str) -> Result> { let mut names: Vec = vec![]; for n in str_list(p, "names").into_iter().chain(str_param(p, "name").map(str::to_string)) { if !names.contains(&n) { @@ -110,8 +164,14 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { } } if names.is_empty() { - return Err(bad(C, "give `name` or `names`")); + return Err(bad(cmd, "give `name` or `names`")); } + Ok(names) +} + +fn swatch_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.delete"; + let names = names_param(p, C)?; let d = &s.doc()?.doc; // Global swatches going away (named ones and those in named groups): their links are dropped. let mut global: Vec = vec![]; @@ -161,6 +221,27 @@ fn artwork_colors(d: &Document, ids: &[NodeId], tints: bool, swatches: &mut Vec< } } +/// Link the solid paints in the subtrees of `ids` whose colour is one of `links`' to that swatch, +/// unless they already link to an existing swatch. Returns the number of paints linked. +fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> usize { + if links.is_empty() { + return 0; + } + let live: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); + d.map_solid_paints_in(ids, &mut |c, link| { + if link.as_ref().is_some_and(|l| live.contains(l)) { + return false; + } + match links.iter().find(|(lc, _)| lc == c) { + Some((_, n)) => { + *link = Some(n.clone()); + true + } + None => false, + } + }) +} + fn swatch_new_group(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.newGroup"; let mut names = str_list(p, "swatches"); @@ -201,28 +282,73 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { } let members: Vec = group.swatches.iter().map(|w| w.name.clone()).collect(); d.swatch_groups.push(group); - let linked = if links.is_empty() { - 0 - } else { - let live: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); - d.map_solid_paints_in(&sel.objects, &mut |c, link| { - if link.as_ref().is_some_and(|l| live.contains(l)) { - return false; - } - match links.iter().find(|(lc, _)| lc == c) { - Some((_, n)) => { - *link = Some(n.clone()); - true - } - None => false, - } - }) - }; + let linked = link_colors(d, &sel.objects, &links); Ok((name, members, linked)) })?; Ok(json!({"name": name, "swatches": members, "linked": linked})) } +/// The name of the item that items moving to index `to` of `list` go before: the first one at or +/// after `to` that isn't moving (`None`: the end). +fn anchor(list: &[T], to: Option, moving: &[String], name: impl Fn(&T) -> &str) -> Option { + list.iter().skip(to?).map(&name).find(|n| !moving.iter().any(|m| m == n)).map(str::to_string) +} + +/// Insert `items` into `list` before the item named `anchor` (at the end without one). +fn insert_before(list: &mut Vec, items: Vec, anchor: Option<&str>, name: impl Fn(&T) -> &str) { + let at = anchor.and_then(|a| list.iter().position(|x| name(x) == a)).unwrap_or(list.len()); + list.splice(at..at, items); +} + +fn swatch_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.move"; + let names = names_param(p, C)?; + let to = p.get("to").and_then(Value::as_u64).map(|v| v as usize); + let group = str_param(p, "group").map(str::to_string); + let moved = names.len(); + s.edit("Move Swatch", |d, _| { + let is_group = |d: &Document, n: &str| d.swatch_groups.iter().any(|g| g.name == n); + if names.iter().all(|n| is_group(d, n)) { + if group.is_some() { + return Err(bad(C, "colour groups can't go into colour groups")); + } + let at = anchor(&d.swatch_groups, to, &names, |g| &g.name); + let mut items = vec![]; + for n in &names { + if let Some(i) = d.swatch_groups.iter().position(|g| g.name == *n) { + items.push(d.swatch_groups.remove(i)); + } + } + insert_before(&mut d.swatch_groups, items, at.as_deref(), |g| &g.name); + return Ok(()); + } + for n in &names { + let sw = d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}` (swatches and colour groups move separately)")))?; + if sw.paint.is_none() { + return Err(bad(C, format!("`{n}` can't be moved"))); + } + if group.is_some() && sw.paint.color().is_none() { + return Err(bad(C, format!("`{n}`: colour groups hold solid colours only"))); + } + } + let dest = match &group { + Some(g) => Some(d.swatch_groups.iter().position(|x| x.name == *g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?), + None => None, + }; + fn list(d: &mut Document, dest: Option) -> &mut Vec { + match dest { + Some(i) => &mut d.swatch_groups[i].swatches, + None => &mut d.swatches, + } + } + let at = anchor(list(d, dest), to, &names, |w| &w.name); + let items: Vec = names.iter().filter_map(|n| d.remove_swatch(n)).collect(); + insert_before(list(d, dest), items, at.as_deref(), |w| &w.name); + Ok(()) + })?; + Ok(json!({ "moved": moved })) +} + fn swatch_duplicate(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string(); let new = s.edit("Duplicate Swatch", |d, _| { @@ -405,3 +531,120 @@ fn swatch_sort(s: &mut Session, _: &Value) -> Result { })?; ok() } + +fn swatch_add_used(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.addUsedColors"; + let global = bool_or(p, "global", false); + let only_selection = bool_or(p, "selection", false); + let (added, linked) = s.edit("Add Used Colors", |d, sel| { + let ids: Vec = if only_selection { sel.objects.clone() } else { d.layers.iter().map(|l| l.id).collect() }; + if only_selection && ids.is_empty() { + return Err(bad(C, "select artwork to add its colours")); + } + let (mut linked_to, mut colors) = (vec![], vec![]); + artwork_colors(d, &ids, false, &mut linked_to, &mut colors); + // Colours a solid swatch already has are skipped. + colors.retain(|c| !d.swatches_iter().any(|w| w.paint.color() == Some(*c))); + let mut links = vec![]; + for c in colors { + let name = d.free_swatch_name(&color_name(c)); + d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(c), global, spot: false }); + links.push((c, name)); + } + let linked = if global { link_colors(d, &ids, &links) } else { 0 }; + Ok((links.into_iter().map(|(_, n)| n).collect::>(), linked)) + })?; + Ok(json!({"added": added, "linked": linked})) +} + +fn swatch_unused(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let (mut links, mut colors, mut gradients, mut patterns) = (vec![], vec![], vec![], vec![]); + d.visit_paints(&mut |p| match p { + Paint::Solid { swatch: Some(l), .. } if d.swatch(l).is_some() => links.push(l.clone()), + Paint::Solid { color, .. } => colors.push(*color), + Paint::Gradient(g) => { + links.extend(g.swatch.clone()); + colors.extend(g.gradient.stops.iter().map(|s| s.color)); + gradients.push(g.gradient.clone()); + } + Paint::Pattern { pattern, .. } => patterns.push(pattern.clone()), + Paint::None => {} + }); + let used = |w: &Swatch| { + links.contains(&w.name) + || match &w.paint { + Paint::None => true, + Paint::Solid { color, .. } => colors.contains(color), + Paint::Gradient(g) => gradients.contains(&g.gradient), + Paint::Pattern { pattern, .. } => patterns.contains(pattern), + } + }; + let names: Vec<&str> = d.swatches_iter().filter(|w| !used(w)).map(|w| w.name.as_str()).collect(); + Ok(json!({ "names": names })) +} + +fn swatch_merge(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.merge"; + let names = names_param(p, C)?; + let d = &s.doc()?.doc; + for n in &names { + if d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?.paint.color().is_none() { + return Err(bad(C, format!("`{n}` isn't a solid colour"))); + } + } + let (keep, merged) = names.split_first().ok_or_else(|| bad(C, "give two or more swatches"))?; + if merged.is_empty() { + return Err(bad(C, "give two or more swatches")); + } + let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?; + let (color, link) = (kept.paint.color().unwrap_or_default(), kept.global.then(|| keep.clone())); + let mut relink = |c: &mut Color, l: &mut Option| { + if !l.as_ref().is_some_and(|l| merged.contains(l)) { + return false; + } + *c = color; + *l = link.clone(); + true + }; + let relinked = s.edit("Merge Swatches", |d, _| { + for n in merged { + d.remove_swatch(n); + } + Ok(d.map_solid_paints(&mut relink)) + })?; + map_default_paints(s, &mut relink); + Ok(json!({"name": keep, "merged": merged, "relinked": relinked})) +} + +fn swatch_ungroup(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.ungroup"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); + let moved = s.edit("Ungroup Color Group", |d, _| { + let i = d.swatch_groups.iter().position(|g| g.name == name).ok_or_else(|| bad(C, format!("no colour group `{name}`")))?; + let g = d.swatch_groups.remove(i); + let names: Vec = g.swatches.iter().map(|w| w.name.clone()).collect(); + d.swatches.extend(g.swatches); + Ok(names) + })?; + Ok(json!({ "swatches": moved })) +} + +fn swatch_sort_by_kind(s: &mut Session, _: &Value) -> Result { + let rank = |w: &Swatch| match &w.paint { + Paint::None => 0, + Paint::Solid { .. } if !w.spot => 1, + Paint::Solid { .. } => 2, + Paint::Gradient(_) => 3, + Paint::Pattern { .. } => 4, + }; + s.edit("Sort Swatches", |d, _| { + // A stable sort: swatches keep their order within a kind. + d.swatches.sort_by_key(rank); + for g in &mut d.swatch_groups { + g.swatches.sort_by_key(rank); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b83c085b..91362a01 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -778,6 +778,8 @@ mod tests_strokegeom; #[cfg(test)] mod tests_styles; #[cfg(test)] +mod tests_swatchcmds; +#[cfg(test)] mod tests_swatches; #[cfg(test)] mod tests_textedit; diff --git a/crates/engine/src/tests_swatchcmds.rs b/crates/engine/src/tests_swatchcmds.rs new file mode 100644 index 00000000..96ce2936 --- /dev/null +++ b/crates/engine/src/tests_swatchcmds.rs @@ -0,0 +1,243 @@ +//! Swatches panel commands: moving swatches between colour groups, the panel menu's Add Used +//! Colors, Select All Unused, Merge, Ungroup and Sort by Kind, and colours following the +//! document's colour mode. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn doc(s: &Session) -> &vectorcraft_doc::Document { + &s.doc().unwrap().doc +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The swatch names of colour group `group`, or of the ungrouped swatches. +fn names(s: &Session, group: Option<&str>) -> Vec { + let d = doc(s); + let list = match group { + Some(g) => &d.swatch_groups.iter().find(|x| x.name == g).unwrap().swatches, + None => &d.swatches, + }; + list.iter().map(|w| w.name.clone()).collect() +} + +#[test] +fn move_between_groups_is_one_undo_step() { + let mut s = session(); + let before = doc(&s).clone(); + let undo = undo_len(&s); + // Two ungrouped colours into Brights, before its second swatch, in the order given. + run(&mut s, "swatch.move", json!({"names": ["Orange", "Red"], "group": "Brights", "to": 1})); + assert_eq!(names(&s, Some("Brights"))[..4], ["Bright Red", "Orange", "Red", "Bright Yellow"]); + assert!(!names(&s, None).contains(&"Red".to_string())); + assert_eq!(undo_len(&s), undo + 1); + // Out of a group to the front of the ungrouped colours (`to` counts the current entries). + run(&mut s, "swatch.move", json!({"name": "Bright Green", "to": 1})); + assert_eq!(names(&s, None)[..3], ["[None]", "Bright Green", "White"]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); + // Reordering within a list; past the end goes last. + run(&mut s, "swatch.move", json!({"name": "White", "to": 999})); + assert_eq!(names(&s, None).last().map(String::as_str), Some("White")); +} + +#[test] +fn move_reorders_groups_and_refuses_bad_moves() { + let mut s = session(); + run(&mut s, "swatch.move", json!({"name": "Brights", "to": 0})); + let groups: Vec<&str> = doc(&s).swatch_groups.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(groups, ["Brights", "Grays"]); + let undo = undo_len(&s); + for p in [ + json!({"name": "Sunset", "group": "Grays"}), + json!({"name": "[None]"}), + json!({"name": "Grays", "group": "Brights"}), + json!({"names": ["Red", "Grays"]}), + json!({"name": "Red", "group": "Nope"}), + json!({}), + ] { + assert!(s.execute("swatch.move", &p).is_err(), "{p}"); + } + assert_eq!(undo_len(&s), undo, "failed moves leave no undo step"); + assert!(doc(&s).swatch("Sunset").is_some_and(|w| matches!(w.paint, Paint::Gradient(_)))); +} + +fn rect_filled(s: &mut Session, fill: Value) -> NodeId { + let id = NodeId(run(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + let mut p = fill; + p["ids"] = json!([id.0]); + run(s, "paint.setFill", p); + run(s, "paint.setStroke", json!({"ids": [id.0], "none": true})); + id +} + +fn fill(s: &Session, id: NodeId) -> Paint { + doc(s).node(id).unwrap().appearance.fill_paint() +} + +#[test] +fn add_used_colors_skips_colours_that_have_swatches() { + let mut s = session(); + rect_filled(&mut s, json!({"color": "#123456"})); + rect_filled(&mut s, json!({"color": "#ed1c24"})); + rect_filled(&mut s, json!({"swatch": "Orange"})); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.addUsedColors", json!({})); + assert_eq!(r, json!({"added": ["R=18 G=52 B=86"], "linked": 0}), "Red and Orange are swatches already"); + assert_eq!(undo_len(&s), undo + 1); + assert_eq!(run(&mut s, "swatch.addUsedColors", json!({}))["added"], json!([])); + // Only the selection's colours, as global swatches its paints link to. + let d = rect_filled(&mut s, json!({"color": "#abcdef"})); + rect_filled(&mut s, json!({"color": "#fedcba"})); + run(&mut s, "select.set", json!({"ids": [d.0]})); + let r = run(&mut s, "swatch.addUsedColors", json!({"selection": true, "global": true})); + assert_eq!(r, json!({"added": ["R=171 G=205 B=239"], "linked": 1})); + assert_eq!(fill(&s, d), Paint::Solid { color: vectorcraft_color::Color::from_hex("#abcdef").unwrap(), swatch: Some("R=171 G=205 B=239".into()) }); + assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global); +} + +#[test] +fn unused_leaves_out_linked_colours_gradients_and_patterns_in_use() { + let mut s = session(); + let all = run(&mut s, "swatch.unused", json!({})); + assert!(!all["names"].as_array().unwrap().contains(&json!("[None]"))); + assert!(all["names"].as_array().unwrap().contains(&json!("Sunset"))); + run(&mut s, "swatch.edit", json!({"name": "Teal", "global": true})); + rect_filled(&mut s, json!({"swatch": "Teal"})); + run(&mut s, "swatch.edit", json!({"name": "Teal", "color": "#010203"})); + rect_filled(&mut s, json!({"swatch": "Sunset"})); + rect_filled(&mut s, json!({"color": "#ed1c24"})); + let r = rect_filled(&mut s, json!({"color": "#ff0000"})); + run(&mut s, "select.set", json!({"ids": [r.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + rect_filled(&mut s, json!({"swatch": "Dots"})); + let names = run(&mut s, "swatch.unused", json!({}))["names"].clone(); + let names: Vec<&str> = names.as_array().unwrap().iter().filter_map(Value::as_str).collect(); + for used in ["Teal", "Sunset", "Red", "Dots", "White"] { + assert!(!names.contains(&used), "{used} is used: {names:?}"); + } + for unused in ["Orange", "Ocean", "Bright Red", "K=50"] { + assert!(names.contains(&unused), "{unused} is unused: {names:?}"); + } +} + +#[test] +fn merge_keeps_the_first_and_relinks_the_others_art() { + let mut s = session(); + for n in ["Red", "Orange"] { + run(&mut s, "swatch.edit", json!({"name": n, "global": true})); + } + let a = rect_filled(&mut s, json!({"swatch": "Red"})); + let b = rect_filled(&mut s, json!({"swatch": "Orange"})); + let red = doc(&s).swatch("Red").unwrap().paint.color().unwrap(); + let before = doc(&s).clone(); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.merge", json!({"names": ["Red", "Orange"]})); + assert_eq!(r, json!({"name": "Red", "merged": ["Orange"], "relinked": 1})); + assert!(doc(&s).swatch("Orange").is_none()); + assert_eq!(fill(&s, b), Paint::Solid { color: red, swatch: Some("Red".into()) }); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); + // A kept swatch that isn't global leaves the merged swatches' art unlinked, in its colour. + run(&mut s, "swatch.merge", json!({"names": ["Lime", "Red", "Bright Red"]})); + let lime = doc(&s).swatch("Lime").unwrap().paint.color().unwrap(); + assert_eq!(fill(&s, a), Paint::solid(lime)); + assert!(doc(&s).swatch("Red").is_none() && doc(&s).swatch("Bright Red").is_none()); + for bad in [json!({"names": ["Lime"]}), json!({"names": ["Lime", "Sunset"]}), json!({"names": ["Lime", "Nope"]})] { + assert!(s.execute("swatch.merge", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn ungroup_and_sort_by_kind_are_one_undo_step_each() { + let mut s = session(); + let undo = undo_len(&s); + assert_eq!(run(&mut s, "swatch.ungroup", json!({"name": "Brights"}))["swatches"].as_array().unwrap().len(), 5); + assert!(doc(&s).swatch_groups.iter().all(|g| g.name != "Brights")); + assert_eq!(names(&s, None).last().map(String::as_str), Some("Bright Violet")); + assert!(s.execute("swatch.ungroup", &json!({"name": "Red"})).is_err()); + // A gradient first, a spot colour among the process ones. + run(&mut s, "swatch.move", json!({"name": "Ocean", "to": 0})); + run(&mut s, "swatch.edit", json!({"name": "Orange", "spot": true})); + run(&mut s, "swatch.sortByKind", json!({})); + assert_eq!(undo_len(&s), undo + 4); + let rank = |w: &vectorcraft_color::Swatch| match &w.paint { + Paint::None => 0, + Paint::Solid { .. } if !w.spot => 1, + Paint::Solid { .. } => 2, + Paint::Gradient(_) => 3, + Paint::Pattern { .. } => 4, + }; + let ranks: Vec = doc(&s).swatches.iter().map(rank).collect(); + assert!(ranks.is_sorted(), "{ranks:?}"); + let n = names(&s, None); + assert_eq!((n[0].as_str(), n[1].as_str()), ("[None]", "White"), "the order within a kind is kept"); + assert_eq!(n[n.iter().position(|x| x == "Orange").unwrap() + 1], "Ocean", "the spot colour comes after the process ones, then the gradients"); +} + +#[test] +fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() { + use vectorcraft_color::Color; + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"})); + assert_eq!(doc(&s).color_mode, vectorcraft_doc::ColorMode::Cmyk); + assert!(doc(&s).swatches_iter().filter_map(|w| w.paint.color()).all(|c| !matches!(c, Color::Rgb { .. }))); + assert_eq!(doc(&s).swatch("Black").and_then(|w| w.paint.color()), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + let a = rect_filled(&mut s, json!({"color": "#ff0000"})); + assert_eq!(fill(&s, a), Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0))); + // Gray stays Gray; keepModel keeps RGB; gradient stops follow the document too. + run(&mut s, "paint.setFill", json!({"ids": [a.0], "color": {"gray": 0.4}})); + assert_eq!(fill(&s, a), Paint::solid(Color::gray(0.4))); + run(&mut s, "paint.setFill", json!({"ids": [a.0], "color": "#ff0000", "keepModel": true})); + assert_eq!(fill(&s, a), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + run( + &mut s, + "paint.setFill", + json!({"ids": [a.0], "gradient": {"stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#0000ff"}]}}), + ); + let Paint::Gradient(g) = fill(&s, a) else { panic!("a gradient") }; + assert!(g.gradient.stops.iter().all(|st| matches!(st.color, Color::Cmyk { .. }))); + // A swatch made from a hex colour is CMYK as well; the document round-trips natively. + let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].clone(); + assert_eq!(name, "C=100 M=0 Y=100 K=0"); + let json = serde_json::to_string(doc(&s)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), *doc(&s)); +} + +#[test] +fn rgb_documents_keep_colours_as_given_and_edits_keep_the_source_model() { + use vectorcraft_color::Color; + let mut s = session(); + assert_eq!(doc(&s).swatch("Black").and_then(|w| w.paint.color()), Some(Color::rgb(0.0, 0.0, 0.0))); + let a = rect_filled(&mut s, json!({"color": "#ff0000"})); + assert_eq!(fill(&s, a), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let b = rect_filled(&mut s, json!({"color": {"c": 0.0, "m": 1.0, "y": 0.0, "k": 0.0}})); + assert_eq!(fill(&s, b), Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), "an RGB document doesn't convert CMYK either"); + // Recolor Artwork keeps the replaced colour's model. + run(&mut s, "select.set", json!({"ids": [b.0]})); + let hex = Color::cmyk(0.0, 1.0, 0.0, 0.0).to_hex(); + run(&mut s, "recolor.apply", json!({"map": {hex: "#00ff00"}})); + assert!(matches!(fill(&s, b).color(), Some(Color::Cmyk { .. }))); + // Blending ends of different models gives the document's model. + let c = rect_filled(&mut s, json!({"color": "#808080"})); + run(&mut s, "select.set", json!({"ids": [a.0, c.0, b.0]})); + run(&mut s, "edit.colors.blendFrontToBack", json!({})); + assert!(matches!(fill(&s, c).color(), Some(Color::Rgb { .. }))); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7382f4f2..3825d092 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -136,6 +136,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { handle_input(app, ui, &resp, rect); let v = *app.view().unwrap_or(&View::default()); let xf = Xf::new(rect, &v); + paint_drop(app, ui, &resp, &xf); let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); @@ -667,14 +668,38 @@ fn node_outline(n: &Node) -> BezPath { bp } +/// The topmost editable object under document point `p` at `zoom` (3 px tolerance). +fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { + let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / zoom, outline: app.ui.view.outline, path_only: false }; + vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt) +} + +/// Swatches and Fill/Stroke proxies dropped on art paint the object under the pointer (the active +/// proxy's fill or stroke); a chip of the dragged paint follows the pointer meanwhile. +fn paint_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) { + use crate::panels::swatches; + swatches::drag_preview(app, ui.ctx()); + let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return }; + // Not through a floating panel over the canvas. + if ui.ctx().layer_id_at(pos).is_some_and(|l| l != resp.layer_id) { + return; + } + let Some(mut params) = swatches::released_paint(app, resp) else { return }; + let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return }; + params["ids"] = json!([hit.leaf.0]); + params["focus"] = json!(false); + if let Err(e) = app.run(crate::panels::proxy_cmd(app, false), params) { + app.status(e); + } +} + fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(h) = app.hover_doc else { return }; if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") { return; } let Some(st) = app.session.active() else { return }; - let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / xf.zoom, outline: app.ui.view.outline, path_only: false }; - let Some(hit) = vectorcraft_doc::hit::hit_test(&st.doc, h, opt) else { return }; + let Some(hit) = hit_at(app, h, xf.zoom) else { return }; let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf }; if st.selection.contains(id) { return; @@ -1081,4 +1106,37 @@ mod tests { assert_eq!(app.view().unwrap().center, after.center); assert_eq!(app.session.active().unwrap().doc.art_bounds(), None); } + + #[test] + fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() { + use crate::panels::swatches::SwatchDrag; + use vectorcraft_color::{Color, Paint}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let release = |at: Pos2| { + vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ] + }; + let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); + let drag = SwatchDrag { grabbed: "Red".into(), names: vec!["Red".into()], groups: false }; + // Off the art nothing happens. + egui::DragAndDrop::set_payload(&ctx, drag.clone()); + frame(&mut app, &ctx, release(xf.to_screen(Point::new(300.0, 250.0)))); + assert_eq!(fill(&app), Paint::solid(Color::WHITE)); + egui::DragAndDrop::set_payload(&ctx, drag); + frame(&mut app, &ctx, release(xf.to_screen(Point::new(100.0, 100.0)))); + assert_eq!(fill(&app), Paint::solid(Color::from_hex("#ed1c24").unwrap()), "paint.setFill with the hit id"); + assert!(app.session.active().unwrap().selection.is_empty(), "the selection stays as it was"); + // A proxy's paint works the same. + egui::DragAndDrop::set_payload(&ctx, Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + frame(&mut app, &ctx, release(xf.to_screen(Point::new(60.0, 60.0)))); + assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + } } diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 07903388..1d10c87e 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -304,7 +304,8 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: } _ => { let cmd = super::proxy_cmd(app, behind); - live_run(app, "Color", cmd, json!({"color": color_json(&c), "focus": !behind}), phase); + // The panel's sliders work in the model picked there, even in a CMYK document. + live_run(app, "Color", cmd, json!({"color": color_json(&c), "focus": !behind, "keepModel": true}), phase); } } } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 585c4ba8..fee74ddb 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -25,22 +25,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut expanded: HashSet = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_else(|| doc.layers.iter().map(|l| l.id.0).collect()); let mut actions: Vec<(String, serde_json::Value)> = vec![]; // Search field ("Search All"). - let search_id = egui::Id::new("layers-search"); - let mut query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); - egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, t.input_border)) - .corner_radius(egui::CornerRadius::same(2)) - .inner_margin(egui::Margin::symmetric(8, 5)) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut query) - .frame(egui::Frame::NONE) - .hint_text(egui::RichText::new("Search All").italics()) - .desired_width(ui.available_width()), - ); - }); - ui.data_mut(|d| d.insert_temp(search_id, query.clone())); + crate::widgets::search_field(ui, egui::Id::new("layers-search"), "Search All"); ui.add_space(6.0); let h = ui.available_height() - 34.0; egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| { diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 5018fd29..465c7e7f 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -1,9 +1,14 @@ -//! Swatches panel: proxy, Recent Colors, thumbnail grid (15.5 pt tiles on a 17 pt pitch) or list, -//! colour groups as folders, None/Registration first, bottom bar and panel menu. +//! Swatches panel: proxy, Recent Colors, find field, thumbnail grid (15.5 pt tiles on a 17 pt pitch) +//! or list, colour groups as folders, None/Registration first, bottom bar and panel menu. +//! +//! Swatches drag to reorder, into and out of colour groups (`swatch.move`) and onto art (the active +//! proxy's paint command with the object's id); a Fill/Stroke proxy dropped on the panel becomes a +//! swatch (`swatch.new`). -use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use serde_json::json; -use vectorcraft_color::Paint; +use egui::{Color32, Rect, Response, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; +use vectorcraft_color::{Color, GradientKind, Paint}; +use vectorcraft_doc::Document; use super::{active_paint, pstate, set_pstate}; use crate::theme::Tokens; @@ -90,29 +95,38 @@ impl Entry { Entry::Swatch { name, .. } | Entry::Folder(name) => name, } } + fn is_folder(&self) -> bool { + matches!(self, Entry::Folder(_)) + } } const REGISTRATION: &str = "[Registration]"; -fn entries(app: &VectorcraftApp, kind: Kind) -> Vec { +/// The panel's rows for the kind filter and the find field's `query` (a case-insensitive name +/// match; a colour group whose name matches shows all its swatches). +fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec { let Some(st) = app.session.active() else { return vec![] }; let d = &st.doc; + let q = query.trim().to_lowercase(); + let found = |n: &str| q.is_empty() || n.to_lowercase().contains(&q); let mut out = vec![]; let sw = |s: &vectorcraft_color::Swatch| Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global: s.global, spot: s.spot }; - // None first, then Registration, then the rest (Illustrator's order). + // None first, then Registration, then the rest (the reference app's order). let (specials, rest): (Vec<_>, Vec<_>) = d.swatches.iter().partition(|s| s.paint.is_none()); - for s in specials.iter().filter(|s| kind.accepts(&s.paint, false)) { + for s in specials.iter().filter(|s| kind.accepts(&s.paint, false) && found(&s.name)) { out.push(sw(s)); } - if matches!(kind, Kind::All | Kind::Color) { + if matches!(kind, Kind::All | Kind::Color) && found(REGISTRATION) { out.push(Entry::Registration); } - for s in rest.iter().filter(|s| kind.accepts(&s.paint, false)) { + for s in rest.iter().filter(|s| kind.accepts(&s.paint, false) && found(&s.name)) { out.push(sw(s)); } for g in &d.swatch_groups { - let items: Vec<_> = g.swatches.iter().filter(|s| kind.accepts(&s.paint, true)).collect(); - if items.is_empty() && kind != Kind::Groups && kind != Kind::All { + let all = found(&g.name); + let items: Vec<_> = g.swatches.iter().filter(|s| kind.accepts(&s.paint, true) && (all || found(&s.name))).collect(); + // Empty groups show only among all swatches or groups, and when the find field names them. + if items.is_empty() && !(all && matches!(kind, Kind::Groups | Kind::All)) { continue; } out.push(Entry::Folder(g.name.clone())); @@ -121,15 +135,20 @@ fn entries(app: &VectorcraftApp, kind: Kind) -> Vec { out } +/// The paint command params that apply swatch (or Registration) `name`; `None` for colour groups +/// and unknown names. +fn swatch_params(d: &Document, name: &str) -> Option { + if name == REGISTRATION { + return Some(json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}})); + } + Some(if d.swatch(name)?.paint.is_none() { json!({"none": true}) } else { json!({"swatch": name}) }) +} + /// Apply a clicked swatch to the active proxy (Alt: the inactive one). fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) { - let params = match e { - Entry::Registration => json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}}), - Entry::Swatch { paint, .. } if paint.is_none() => json!({"none": true}), - Entry::Swatch { name, .. } => json!({"swatch": name}), - Entry::Folder(_) => return, - }; - super::apply_click(app, ui, params); + if let Some(params) = app.session.active().and_then(|st| swatch_params(&st.doc, e.name())) { + super::apply_click(app, ui, params); + } } /// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). @@ -187,6 +206,82 @@ fn draw_folder(ui: &Ui, r: Rect) { icons::paint(ui, "dc-folder", r.expand(1.0), t.icon); } +/// The ink colours of the CMYK glyph (cyan, magenta, yellow, black) and the RGB glyph's bars. +const CMYK_INKS: [Color32; 4] = [Color32::from_rgb(0, 174, 239), Color32::from_rgb(236, 0, 140), Color32::from_rgb(255, 242, 0), Color32::BLACK]; +const RGB_BARS: [Color32; 3] = [Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 200, 0), Color32::from_rgb(0, 0, 255)]; + +/// A swatch's kind in words, for list tooltips ("Global Process Color, CMYK"). +fn describe(paint: &Paint, global: bool, spot: bool) -> String { + match paint { + Paint::Solid { color, .. } => { + let kind = if spot { + "Spot Color" + } else if global { + "Global Process Color" + } else { + "Process Color" + }; + format!("{kind}, {}", color.model_name()) + } + Paint::Gradient(g) => format!("{} Gradient", g.gradient.kind.label()), + Paint::Pattern { .. } => "Pattern".into(), + Paint::None => "None".into(), + } +} + +/// The kind and colour-mode icons at the right of list row `r`: spot (a dot in a ring), global (a +/// square with a filled corner) or process colours (a square), gradients and patterns; then a +/// colour's model (CMYK as four ink quarters, RGB as three bars, Gray as a grey square). +fn list_icons(ui: &Ui, r: Rect, paint: &Paint, global: bool, spot: bool) { + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + let mode = Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(10.0, 10.0)); + let kind = mode.translate(vec2(-16.0, 0.0)); + let line = Stroke::new(1.0, t.icon); + match paint { + Paint::Solid { color, .. } => { + if spot { + p.circle_stroke(kind.center(), 4.5, line); + p.circle_filled(kind.center(), 1.8, t.icon); + } else { + p.rect_stroke(kind, 0.0, line, StrokeKind::Inside); + if global { + let c = kind.right_bottom(); + p.add(Shape::convex_polygon(vec![c, c - vec2(6.0, 0.0), c - vec2(0.0, 6.0)], t.icon, Stroke::NONE)); + } + } + match color { + Color::Cmyk { .. } => { + let q = mode.size() / 2.0; + for (i, ink) in CMYK_INKS.into_iter().enumerate() { + p.rect_filled(Rect::from_min_size(mode.min + vec2((i % 2) as f32 * q.x, (i / 2) as f32 * q.y), q), 0.0, ink); + } + } + Color::Rgb { .. } => { + let w = mode.width() / 3.0; + for (i, bar) in RGB_BARS.into_iter().enumerate() { + p.rect_filled(Rect::from_min_size(mode.min + vec2(i as f32 * w, 0.0), vec2(w, mode.height())), 0.0, bar); + } + } + Color::Gray { .. } => { + p.rect_filled(mode, 0.0, Color32::from_gray(128)); + } + } + p.rect_stroke(mode, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + } + Paint::Gradient(g) => { + let icon = match g.gradient.kind { + GradientKind::Linear => "dc-grad-linear", + GradientKind::Radial => "dc-grad-radial", + GradientKind::Freeform => "dc-grad-freeform", + }; + icons::paint(ui, icon, kind.expand(1.0), t.icon); + } + Paint::Pattern { .. } => icons::paint(ui, "grid-3x3", kind.expand(1.0), t.icon), + Paint::None => {} + } +} + /// The swatches and colour groups selected in the panel, in click order, without names that no /// longer exist (deleted, renamed or undone). fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec { @@ -196,6 +291,16 @@ fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec { names } +/// The names the panel highlights: `sel` plus the swatches of selected colour groups (clicking a +/// folder selects the whole group). +fn highlighted<'a>(d: &'a Document, sel: &'a [String]) -> Vec<&'a str> { + let mut out: Vec<&str> = sel.iter().map(String::as_str).collect(); + for g in d.swatch_groups.iter().filter(|g| sel.contains(&g.name)) { + out.extend(g.swatches.iter().map(|w| w.name.as_str())); + } + out +} + /// The selection after a click on `name`: Cmd/Ctrl toggles it, Shift extends from the last clicked /// name over `order` (the names as displayed), a plain click selects it alone. fn click_selection(ui: &Ui, mut sel: Vec, order: &[&str], name: &str, m: egui::Modifiers) -> Vec { @@ -224,6 +329,227 @@ fn click_selection(ui: &Ui, mut sel: Vec, order: &[&str], name: &str, m: sel } +/// What the Swatches panel drags: the swatch, None, Registration or colour group under the pointer +/// when the drag started (it paints art it's dropped on) and the names it moves: the panel +/// selection when the grabbed one is part of it (only colour groups, or only swatches, like the +/// grabbed one), in panel order. +#[derive(Clone, Debug)] +pub(crate) struct SwatchDrag { + pub grabbed: String, + pub names: Vec, + /// The names are colour groups. + pub groups: bool, +} + +/// A drag released on the panel. +enum Drop { + /// Swatches (or colour groups) dropped before or `after` row `target`, into folder `target`, or + /// at the end (`None`). + Move { names: Vec, target: Option, after: bool }, + /// A Fill/Stroke proxy's paint dropped on row `target` (or between rows) becomes a swatch. + New { paint: Paint, target: Option }, +} + +/// A drag payload of type `T` released on `resp`. (egui's release takes any payload, so the type is +/// checked first: a payload of another type stays for the next drop target.) +fn released(resp: &Response) -> Option> { + resp.dnd_hover_payload::()?; + resp.dnd_release_payload::() +} + +/// What the tiles or rows saw this frame. +#[derive(Default)] +struct TileEvents { + clicked: Option<(Entry, egui::Modifiers)>, + /// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing). + edit: Option, + drop: Option, + /// A drag is held over a tile or row. + over: bool, +} + +/// Clicks, double-clicks, tooltips and drag and drop of the tile or row `resp` of entry `e` +/// (`items`: the panel's rows, `sel`: its selection, `list`: rows stack downwards). A drag from it +/// starts a [`SwatchDrag`]; a drag held over it shows where it would land (before or after it by +/// the pointer's half, or into a folder) and a release there sets the drop. +fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) { + let name = e.name(); + if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() { + ev.edit = Some(name.to_string()); + } + if resp.drag_started() { + let with_sel = sel.iter().any(|s| s == name); + let names = items + .iter() + .filter(|x| x.is_folder() == e.is_folder() && (x.name() == name || (with_sel && sel.iter().any(|s| s == x.name())))) + .map(|x| x.name().to_string()) + .collect(); + egui::DragAndDrop::set_payload(ui.ctx(), SwatchDrag { grabbed: name.to_string(), names, groups: e.is_folder() }); + } + let moving = resp.dnd_hover_payload::(); + // Swatches dropped on one of themselves stay where they are. + if moving.as_ref().is_some_and(|d| d.names.iter().any(|n| n == name)) { + ev.over = true; + released::(&resp); + } else if moving.is_some() || resp.dnd_hover_payload::().is_some() { + ev.over = true; + let t = Tokens::get(ui.ctx()); + let r = resp.rect; + let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center()); + let after = if list { at.y > r.center().y } else { at.x > r.center().x }; + // A proxy's paint, or swatches over a folder, go into it; otherwise a bar marks the slot. + if moving.as_ref().is_none_or(|d| e.is_folder() && !d.groups) { + ui.painter().rect_stroke(r.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside); + } else if list { + let y = if after { r.bottom() } else { r.top() }; + ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent)); + } else { + let x = if after { r.right() + 0.75 } else { r.left() - 0.75 }; + ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent)); + } + let target = Some(name.to_string()); + if let Some(d) = moving.and_then(|_| released::(&resp)) { + ev.drop = Some(Drop::Move { names: d.names.clone(), target, after }); + } else if let Some(p) = released::(&resp) { + ev.drop = Some(Drop::New { paint: (*p).clone(), target }); + } + } + let resp = match e { + Entry::Folder(n) => resp.on_hover_text(format!("Color Group: {n}")), + Entry::Swatch { paint, global, spot, .. } if list => resp.on_hover_ui(|ui| { + ui.label(format!("{name} ({})", describe(paint, *global, *spot))); + }), + _ => resp.on_hover_text(name), + }; + if resp.clicked() { + ev.clicked = Some((e.clone(), ui.input(|i| i.modifiers))); + } +} + +/// Drops between and after the tiles (`zone`: the list's viewport) go to the end of the ungrouped +/// swatches (colour groups to the end of the groups); the list is outlined while one is held there. +fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) { + let held = zone.dnd_hover_payload::().is_some() || zone.dnd_hover_payload::().is_some(); + if held && !ev.over { + ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if let Some(d) = released::(zone) { + ev.drop = Some(Drop::Move { names: d.names.clone(), target: None, after: false }); + } else if let Some(p) = released::(zone) { + ev.drop = Some(Drop::New { paint: (*p).clone(), target: None }); + } +} + +/// The colour group `name` is, or the one swatch `name` belongs to. +fn group_of(d: &Document, name: &str) -> Option { + if d.swatch_groups.iter().any(|g| g.name == name) { + return Some(name.to_string()); + } + d.swatch_group_of(name).map(|g| d.swatch_groups[g].name.clone()) +} + +/// `swatch.move` params for dropping `names` before or `after` row `target`, into folder `target` +/// (swatches), or at the end (`target` None); `None` when nothing would move. Only solid colours go +/// into colour groups; None and Registration stay put. +fn move_params(d: &Document, names: &[String], target: Option<&str>, after: bool) -> Option { + if target.is_some_and(|t| names.iter().any(|n| n == t)) { + return None; + } + let group_index = |n: &str| d.swatch_groups.iter().position(|g| g.name == n); + if names.iter().all(|n| group_index(n).is_some()) { + let to = match target { + None => d.swatch_groups.len(), + // Over a group's folder or one of its swatches: next to that group; else before the first. + Some(t) => group_index(t).or_else(|| d.swatch_group_of(t)).map_or(0, |i| i + usize::from(after)), + }; + return Some(json!({"names": names, "to": to})); + } + let (group, to) = match target { + Some(t) if group_index(t).is_some() => (Some(t), None), + Some(REGISTRATION) => (None, Some(d.swatches.iter().position(|w| !w.paint.is_none()).unwrap_or(0))), + Some(t) => { + let (group, list) = match d.swatch_group_of(t) { + Some(g) => (Some(d.swatch_groups[g].name.as_str()), &d.swatch_groups[g].swatches), + None => (None, &d.swatches), + }; + (group, list.iter().position(|w| w.name == t).map(|i| i + usize::from(after))) + } + None => (None, None), + }; + let movable: Vec<&String> = + names.iter().filter(|n| d.swatch(n).is_some_and(|w| !w.paint.is_none() && (group.is_none() || w.paint.color().is_some()))).collect(); + (!movable.is_empty()).then(|| json!({"names": movable, "to": to, "group": group})) +} + +/// Run a drop on the panel: move the swatches or make a swatch of the proxy's paint (a colour +/// dropped on a folder or a grouped swatch joins that group). +fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { + let Some(d) = app.session.active().map(|st| &st.doc) else { return }; + let (cmd, params) = match drop { + Drop::Move { names, target, after } => match move_params(d, &names, target.as_deref(), after) { + Some(p) => ("swatch.move", p), + None => return, + }, + Drop::New { paint, target } => { + let mut p = super::paint_params(&paint); + if paint.color().is_some() + && let Some(g) = target.and_then(|t| group_of(d, &t)) + { + p["group"] = json!(g); + } + ("swatch.new", p) + } + }; + if let Err(e) = app.run(cmd, params) { + app.status(e); + } +} + +/// Paint command params for swatches or a Fill/Stroke proxy released on `resp`: the grabbed swatch +/// (`None` for a colour group), or the proxy's paint (a gradient fits the new object afresh). +pub(crate) fn released_paint(app: &VectorcraftApp, resp: &Response) -> Option { + if let Some(d) = released::(resp) { + return swatch_params(&app.session.active()?.doc, &d.grabbed); + } + let mut paint = (*released::(resp)?).clone(); + if let Paint::Gradient(g) = &mut paint { + g.geom = None; + } + Some(super::paint_params(&paint)) +} + +/// While swatches or a Fill/Stroke proxy are dragged, a chip of the grabbed paint follows the +/// pointer. +pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { + let (paint, registration) = if let Some(d) = egui::DragAndDrop::payload::(ctx) { + match app.session.active().and_then(|st| st.doc.swatch(&d.grabbed)) { + Some(w) => (w.paint.clone(), false), + None if d.grabbed == REGISTRATION => (Paint::None, true), + None => return, + } + } else if let Some(p) = egui::DragAndDrop::payload::(ctx) { + ((*p).clone(), false) + } else { + return; + }; + let Some(at) = ctx.pointer_hover_pos() else { return }; + let area = egui::Area::new(egui::Id::new("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0)); + area.interactable(false).show(ctx, |ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + if registration { + draw_registration(ui, r); + } else { + swatch_tile(ui, r, &paint, false, false); + pattern_thumb(app, ui, r.shrink(1.0), &paint); + } + }); +} + +/// The id of the find field's text (shown with the panel menu's Show Find Field). +fn find_id() -> egui::Id { + egui::Id::new("swatch-find") +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); if app.session.active().is_none() { @@ -247,7 +573,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { super::recent_colors_row(app, ui); widgets::divider(ui); widgets::subheader(ui, "Swatch Tiles"); - let items = entries(app, kind); + let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), "Find") } else { String::new() }; + let items = entries(app, kind, &query); let active = active_paint(app); let active_swatch = match &active { Paint::Solid { swatch: Some(n), .. } => Some(n.clone()), @@ -258,17 +585,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Without a selection the swatch of the active paint is highlighted. let fallback: Vec = if selected.is_empty() { active_swatch.into_iter().collect() } else { vec![] }; let sel = if selected.is_empty() { &fallback } else { &selected }; - let is_sel = |name: &str| sel.iter().any(|s| s == name); + let lit = app.session.active().map(|st| highlighted(&st.doc, sel)).unwrap_or_default(); + let is_sel = |name: &str| lit.contains(&name); let (tile, pitch) = view.tile(); - let mut clicked: Option<(Entry, egui::Modifiers)> = None; - // Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing). - let mut edit: Option = None; + let mut ev = TileEvents::default(); widgets::list_box(ui, |ui| { - egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(if view == View::LargeThumb { 200.0 } else { 150.0 }).show(ui, |ui| { + let max_height = if view == View::LargeThumb { 200.0 } else { 150.0 }; + let out = egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(max_height).show(ui, |ui| { ui.set_width(ui.available_width()); if view.is_list() { for e in &items { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), pitch), Sense::click()); + let (_, r) = ui.allocate_space(vec2(ui.available_width(), pitch)); + let resp = ui.interact(r, tile_id(e), Sense::click_and_drag()); let name = e.name(); if is_sel(name) { ui.painter().rect_filled(r, 0.0, t.row_selected); @@ -278,9 +606,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let chip = Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile)); match e { Entry::Registration => draw_registration(ui, chip), - Entry::Swatch { paint, .. } => { + Entry::Swatch { paint, global, spot, .. } => { swatch_tile(ui, chip, paint, false, false); pattern_thumb(app, ui, chip, paint); + list_icons(ui, r, paint, *global, *spot); } Entry::Folder(_) => draw_folder(ui, chip), } @@ -291,27 +620,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::FontId::proportional(12.0), t.text, ); - // Kind markers at the right: global (white corner) / spot (dot) / process. - if let Entry::Swatch { global, spot, paint, .. } = e { - let mk = Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(10.0, 10.0)); - if *spot { - ui.painter().circle_filled(mk.center(), 3.5, t.icon); - } else if matches!(paint, Paint::Gradient(_)) { - icons::paint(ui, "dc-grad-linear", mk, t.icon); - } else if *global { - ui.painter().add(egui::Shape::convex_polygon( - vec![mk.left_bottom(), mk.right_bottom(), mk.right_top()], - t.icon, - Stroke::NONE, - )); - } - } - if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() { - edit = Some(name.to_string()); - } - if resp.on_hover_text(name).clicked() { - clicked = Some((e.clone(), ui.input(|i| i.modifiers))); - } + tile_input(ui, resp, e, &items, sel, true, &mut ev); } } else { let w = ui.available_width(); @@ -320,7 +629,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut rows: Vec> = vec![vec![]]; for e in &items { // A group folder starts a new row. - if matches!(e, Entry::Folder(_)) && col > 0 { + if e.is_folder() && col > 0 { rows.push(vec![]); col = 0; } @@ -335,23 +644,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover()); for (c, e) in row.into_iter().enumerate() { let cell = Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); - let resp = ui.interact(cell, tile_id(e), Sense::click()); + let resp = ui.interact(cell, tile_id(e), Sense::click_and_drag()); let name = e.name(); match e { Entry::Registration => draw_registration(ui, cell), - Entry::Swatch { paint, global, .. } => { + Entry::Swatch { paint, global, spot, .. } => { swatch_tile(ui, cell, paint, is_sel(name), resp.hovered()); pattern_thumb(app, ui, cell.shrink(1.0), paint); - if resp.double_clicked() { - edit = Some(name.to_string()); - } + // Global colours get a white corner; spot colours a dot in it. if *global { - let k = cell.shrink(1.0); - ui.painter().add(egui::Shape::convex_polygon( - vec![k.right_bottom(), k.right_bottom() - vec2(5.0, 0.0), k.right_bottom() - vec2(0.0, 5.0)], - egui::Color32::WHITE, - Stroke::NONE, - )); + let k = cell.shrink(1.0).right_bottom(); + let corner = vec![k, k - vec2(5.0, 0.0), k - vec2(0.0, 5.0)]; + ui.painter().add(Shape::convex_polygon(corner, Color32::WHITE, Stroke::NONE)); + if *spot { + ui.painter().circle_filled(k - vec2(1.6, 1.6), 1.0, Color32::BLACK); + } } } Entry::Folder(_) => { @@ -361,22 +668,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } } - let resp = match e { - Entry::Folder(n) => resp.on_hover_text(format!("Color Group: {n}")), - _ => resp.on_hover_text(name), - }; - if resp.clicked() { - clicked = Some((e.clone(), ui.input(|i| i.modifiers))); - } + tile_input(ui, resp, e, &items, sel, false, &mut ev); } } } }); + let zone = ui.interact(out.inner_rect, ui.id().with("swatch-drop"), Sense::hover()); + zone_input(ui, &zone, &mut ev); }); - if let Some(name) = edit { + if let Some(name) = ev.edit { app.run("ui.swatchOptions", json!({"name": name})).ok(); } - let selected = match clicked { + if let Some(drop) = ev.drop { + apply_drop(app, drop); + } + let selected = match ev.clicked { Some((e, m)) => { let order: Vec<&str> = items.iter().map(Entry::name).collect(); let sel = click_selection(ui, selected, &order, e.name(), m); @@ -392,7 +698,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { bottom(app, ui, &selected); } -/// The interaction id of a tile in the thumbnail views (stable per swatch or group name). +/// The interaction id of a tile or list row (stable per swatch or group name). fn tile_id(e: &Entry) -> egui::Id { egui::Id::new(("swatch-tile", e.name())) } @@ -411,6 +717,18 @@ fn editable(app: &VectorcraftApp, sel: &[String]) -> Option { } } +/// The selected solid-colour swatches in selection order (Merge Swatches keeps the first). +fn mergeable(app: &VectorcraftApp, sel: &[String]) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + sel.iter().filter(|n| st.doc.swatch(n).is_some_and(|w| w.paint.color().is_some())).cloned().collect() +} + +/// The first selected colour group (Ungroup Color Group). +fn selected_group(app: &VectorcraftApp, sel: &[String]) -> Option { + let d = &app.session.active()?.doc; + sel.iter().find(|n| d.swatch_groups.iter().any(|g| g.name == **n)).cloned() +} + /// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click). fn delete(app: &mut VectorcraftApp, names: Vec, now: bool) { let params = json!({"names": names}); @@ -463,12 +781,7 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { /// The colour group the selection points at: the last selected group, or the group of the last /// selected swatch. fn target_group(app: &VectorcraftApp, sel: &[String]) -> Option { - let d = &app.session.active()?.doc; - let last = sel.last()?; - if d.swatch_groups.iter().any(|g| g.name == *last) { - return Some(last.clone()); - } - d.swatch_group_of(last).map(|g| d.swatch_groups[g].name.clone()) + group_of(&app.session.active()?.doc, sel.last()?) } /// New Swatch from the active paint (a colour goes into the selected colour group): opens the @@ -504,18 +817,36 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { { app.run("swatch.duplicate", json!({"name": n})).ok(); } - menu_item(ui, "Merge Swatches", false, false); + // The first selected colour is kept. + let merge = mergeable(app, &selected); + if menu_item(ui, "Merge Swatches", merge.len() > 1, false) { + app.run("swatch.merge", json!({ "names": merge })).ok(); + } if menu_item(ui, "Delete Swatch", !del.is_empty(), false) { delete(app, del, false); } - menu_item(ui, "Ungroup Color Group", false, false); - menu_item(ui, "Select All Unused", false, false); - menu_item(ui, "Add Used Colors", false, false); + let group = selected_group(app, &selected); + if menu_item(ui, "Ungroup Color Group", group.is_some(), false) + && let Some(g) = group + { + app.run("swatch.ungroup", json!({ "name": g })).ok(); + } + if menu_item(ui, "Select All Unused", true, false) + && let Ok(r) = app.run("swatch.unused", json!({})) + { + let names: Vec = r["names"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + set_pstate(ui.ctx(), "swatch-selected", names); + } + if menu_item(ui, "Add Used Colors", true, false) { + app.run("swatch.addUsedColors", json!({})).ok(); + } ui.separator(); if menu_item(ui, "Sort by Name", true, false) { app.run("swatch.sortByName", json!({})).ok(); } - menu_item(ui, "Sort by Kind", false, false); + if menu_item(ui, "Sort by Kind", true, false) { + app.run("swatch.sortByKind", json!({})).ok(); + } ui.separator(); for (v, label) in View::ALL { if menu_item(ui, label, true, v == view) { @@ -523,6 +854,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } ui.separator(); + let find: bool = pstate(ui.ctx(), "swatch-show-find"); + if menu_item(ui, "Show Find Field", true, find) { + set_pstate(ui.ctx(), "swatch-show-find", !find); + } + ui.separator(); if menu_item(ui, "Swatch Options…", opts.is_some(), false) && let Some(n) = opts { @@ -752,6 +1088,122 @@ mod tests { assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()) }); } + /// The centre of the tile or row of `name` as laid out by the last frame. + fn tile_center(ctx: &egui::Context, name: &str) -> Pos2 { + ctx.read_response(egui::Id::new(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center() + } + + /// Press at `from`, drag to `to` and release there, a frame per step from `time`. + fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, time: f64) { + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + frame(app, ctx, vec![Event::PointerMoved(from), button(from, true)], time, show); + frame(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], time + 0.1, show); + frame(app, ctx, vec![Event::PointerMoved(to)], time + 0.2, show); + frame(app, ctx, vec![Event::PointerMoved(to), button(to, false)], time + 0.3, show); + } + + fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec { + let d = &app.session.active().unwrap().doc; + let list = match group { + Some(g) => &d.swatch_groups.iter().find(|x| x.name == g).unwrap().swatches, + None => &d.swatches, + }; + list.iter().map(|w| w.name.clone()).collect() + } + + #[test] + fn find_field_filters_and_shift_extends_over_what_is_shown() { + let mut app = app(); + let ctx = context(); + set_pstate(&ctx, "swatch-show-find", true); + ctx.data_mut(|d| d.insert_temp(find_id(), "BRIGHT".to_string())); + frame(&mut app, &ctx, vec![], 0.0, show); + assert!(ctx.read_response(egui::Id::new(("swatch-tile", "Red"))).is_none(), "Red doesn't match"); + click_tile(&mut app, &ctx, "Bright Red", Modifiers::NONE, 1.0); + click_tile(&mut app, &ctx, "Bright Blue", Modifiers::SHIFT, 2.0); + assert_eq!(selected(&ctx), ["Bright Red", "Bright Yellow", "Bright Green", "Bright Blue"]); + // A group whose name matches shows all its swatches. + ctx.data_mut(|d| d.insert_temp(find_id(), "grays".to_string())); + frame(&mut app, &ctx, vec![], 3.0, show); + frame(&mut app, &ctx, vec![], 3.5, show); + assert!(ctx.read_response(egui::Id::new(("swatch-tile", "K=50"))).is_some()); + assert!(ctx.read_response(egui::Id::new(("swatch-tile", "Bright Red"))).is_none()); + } + + #[test] + fn list_view_rows_take_double_clicks_and_folder_clicks_select_the_group() { + let mut app = app(); + let ctx = context(); + set_pstate(&ctx, "swatch-view", View::SmallList); + double_click_tile(&mut app, &ctx, "Red"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND), "double-click in a list opens Swatch Options"); + let d = &app.session.active().unwrap().doc; + let sel = vec!["Brights".to_string()]; + assert!(highlighted(d, &sel).contains(&"Bright Violet"), "a selected folder highlights its swatches"); + assert_eq!(describe(&Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), true, false), "Global Process Color, CMYK"); + assert_eq!(describe(&Paint::solid(Color::gray(0.5)), true, true), "Spot Color, Grayscale"); + } + + #[test] + fn dragging_tiles_reorders_and_moves_into_groups_as_one_undo_step_each() { + let mut app = app(); + let ctx = context(); + frame(&mut app, &ctx, vec![], 0.0, show); + let undo = app.session.doc().unwrap().history.undo.len(); + // Onto the left half of Red: before it. + let (from, red) = (tile_center(&ctx, "Amber"), tile_center(&ctx, "Red")); + drag(&mut app, &ctx, from, red - vec2(4.0, 0.0), 1.0); + let n = names_of(&app, None); + assert_eq!(n[n.iter().position(|x| x == "Red").unwrap() - 1], "Amber"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // Onto a folder: into that group, at its end. (Two frames, so tile rects are current.) + frame(&mut app, &ctx, vec![], 2.0, show); + frame(&mut app, &ctx, vec![], 2.5, show); + drag(&mut app, &ctx, tile_center(&ctx, "Amber"), tile_center(&ctx, "Grays"), 3.0); + assert_eq!(names_of(&app, Some("Grays")).last().map(String::as_str), Some("Amber")); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2); + // A selection drags together, in panel order; a gradient can't join a group. + set_pstate(&ctx, "swatch-selected", vec!["Lime".to_string(), "Sunset".to_string(), "Orange".to_string()]); + frame(&mut app, &ctx, vec![], 4.0, show); + frame(&mut app, &ctx, vec![], 4.5, show); + drag(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Bright Red") - vec2(4.0, 0.0), 5.0); + assert_eq!(names_of(&app, Some("Brights"))[..3], ["Orange", "Lime", "Bright Red"]); + assert!(names_of(&app, None).contains(&"Sunset".to_string())); + // Dropped back on itself: nothing moves. + let (before, undo) = (names_of(&app, None), app.session.doc().unwrap().history.undo.len()); + frame(&mut app, &ctx, vec![], 6.0, show); + frame(&mut app, &ctx, vec![], 6.5, show); + let white = tile_center(&ctx, "White"); + drag(&mut app, &ctx, white, white + vec2(2.0, 0.0), 7.0); + assert_eq!((names_of(&app, None), app.session.doc().unwrap().history.undo.len()), (before, undo)); + } + + #[test] + fn a_proxy_dropped_on_the_panel_becomes_a_swatch() { + let mut app = app(); + let ctx = context(); + frame(&mut app, &ctx, vec![], 0.0, show); + let at = tile_center(&ctx, "Bright Red"); + egui::DragAndDrop::set_payload(&ctx, Paint::solid(Color::from_hex("#123456").unwrap())); + let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show); + assert_eq!( + names_of(&app, Some("Brights")).last().map(String::as_str), + Some("R=18 G=52 B=86"), + "dropped on a group's colour: into that group" + ); + assert!(egui::DragAndDrop::payload::(&ctx).is_none()); + } + + #[test] + fn menu_targets_follow_the_panel_selection() { + let app = app(); + let sel: Vec = ["Red", "Sunset", "Grays", "Orange", "Brights"].map(String::from).to_vec(); + assert_eq!(mergeable(&app, &sel), ["Red", "Orange"], "Merge Swatches takes the selected colours, first kept"); + assert_eq!(selected_group(&app, &sel).as_deref(), Some("Grays")); + assert_eq!(selected_group(&app, &sel[..2]), None); + } + #[test] fn kind_filter() { let solid = Paint::solid(Color::BLACK); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fc8dfa7c..a6d84496 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -255,8 +255,14 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool let s = size * 0.62; let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s)); let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s)); - let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click()); - let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click()); + let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click_and_drag()); + let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click_and_drag()); + // A proxy drags its paint (onto the Swatches panel or art), unless it shows "?". + for (resp, paint, mixed) in [(&fill_resp, fill, mixed.0), (&stroke_resp, stroke, mixed.1)] { + if resp.drag_started() && !mixed { + egui::DragAndDrop::set_payload(ui.ctx(), paint.clone()); + } + } let question = |ui: &Ui, r: Rect, color: Color32| { ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(r.height() * 0.8), color); }; @@ -857,6 +863,29 @@ pub fn swatch_tile(ui: &Ui, rect: Rect, paint: &Paint, selected: bool, hovered: } } +/// A recessed search field with an italic `hint` (Layers' Search All, Swatches' Find), keeping its +/// text in egui memory under `id`. Returns the text. +pub fn search_field(ui: &mut Ui, id: egui::Id, hint: &str) -> String { + let t = Tokens::get(ui.ctx()); + let mut query: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, t.input_border)) + .corner_radius(egui::CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(8, 5)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut query) + .id(id.with("edit")) + .frame(egui::Frame::NONE) + .hint_text(egui::RichText::new(hint).italics()) + .desired_width(ui.available_width()), + ); + }); + ui.data_mut(|d| d.insert_temp(id, query.clone())); + query +} + /// A bordered list box (Swatches tiles, Appearance rows, Artboards list). pub fn list_box(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { let t = Tokens::get(ui.ctx()); diff --git a/docs/mcp.md b/docs/mcp.md index 3c05bb08..9f546593 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -94,6 +94,16 @@ swatches and colour groups in one undo step (art using a deleted global swatch k `swatch.new {color?, mode?, spot?, global?, group?}` saves a colour (into a colour group with `group`), gradient or pattern; `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected art's colours (by default as global swatches the art links to). +`swatch.move {names, to?, group?}` reorders swatches or moves them into or out of a colour group (as dragging +them in the Swatches panel does); give colour group names instead to reorder the groups. Dropping a swatch on art +in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `ids: [the object under the pointer]`. +The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`, `swatch.unused` (a query: the +names Select All Unused selects), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and +`swatch.sortByKind`. +Document colour mode: `file.new {colorMode: "cmyk"}` starts a CMYK document with CMYK default swatches, and RGB +colours applied to it (`paint.setFill`/`setStroke` colours and gradient stops, `swatch.new`) are stored as CMYK; +Gray stays Gray, and `keepModel: true` keeps a colour as given. RGB documents keep colours as given. Harmonies, Edit +Colors blends, inversions and Recolor Artwork keep each colour's model (a blend between models takes the document's). ## Resources