Merge wp/A2

# Conflicts:
#	crates/engine/src/lib.rs
This commit is contained in:
bflatastic 2026-10-04 23:20:10 +07:00
commit 818980a789
18 changed files with 1327 additions and 167 deletions

View File

@ -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<Color> {
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<Color> {
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 { .. })));
}
}

View File

@ -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));

View File

@ -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<Swatch>, Vec<SwatchGroup>) {
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<Swatch>, Vec<SwatchGroup>) {
/// gradients. (The composition is ours; the reference app's layout grammar — None first, then
/// specials — is kept.)
fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
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::<Vec<_>>(), rgb.iter().map(|w| &w.name).collect::<Vec<_>>());
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)));
}
}

View File

@ -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(),

View File

@ -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 {

View File

@ -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<Value> {
}
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<Value> {
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);
}

View File

@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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);

View File

@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<vectorcraft_color::cms::Model> {
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<Option<Paint>> {
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<Option<Paint>> {
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)
}

View File

@ -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<CommandSpec> {
"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<Value> {
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;
}
});

View File

@ -59,6 +59,60 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
Ok(json!({ "name": name }))
}
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
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<Vec<String>> {
let mut names: Vec<String> = 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<Value> {
}
}
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<Value> {
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<String> = 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<String> = 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<Value> {
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<Value> {
}
let members: Vec<String> = 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<String> = 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<T>(list: &[T], to: Option<usize>, moving: &[String], name: impl Fn(&T) -> &str) -> Option<String> {
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<T>(list: &mut Vec<T>, items: Vec<T>, 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<Value> {
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<usize>) -> &mut Vec<Swatch> {
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<Swatch> = 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<Value> {
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<Value> {
})?;
ok()
}
fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
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<NodeId> = 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::<Vec<_>>(), linked))
})?;
Ok(json!({"added": added, "linked": linked}))
}
fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
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<Value> {
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<String>| {
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<Value> {
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<String> = 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<Value> {
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()
}

View File

@ -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;

View File

@ -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<String> {
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<u8> = 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::<vectorcraft_doc::Document>(&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 { .. })));
}

View File

@ -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<vectorcraft_doc::hit::Hit> {
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)));
}
}

View File

@ -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);
}
}
}

View File

@ -25,22 +25,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let mut expanded: HashSet<u64> = 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| {

View File

@ -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<Entry> {
/// 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<Entry> {
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<Entry> {
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<Value> {
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<String> {
@ -196,6 +291,16 @@ fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec<String> {
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<String>, order: &[&str], name: &str, m: egui::Modifiers) -> Vec<String> {
@ -224,6 +329,227 @@ fn click_selection(ui: &Ui, mut sel: Vec<String>, 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<String>,
/// 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<String>, target: Option<String>, after: bool },
/// A Fill/Stroke proxy's paint dropped on row `target` (or between rows) becomes a swatch.
New { paint: Paint, target: Option<String> },
}
/// 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<T: std::any::Any + Send + Sync>(resp: &Response) -> Option<std::sync::Arc<T>> {
resp.dnd_hover_payload::<T>()?;
resp.dnd_release_payload::<T>()
}
/// 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<String>,
drop: Option<Drop>,
/// 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::<SwatchDrag>();
// 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::<SwatchDrag>(&resp);
} else if moving.is_some() || resp.dnd_hover_payload::<Paint>().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::<SwatchDrag>(&resp)) {
ev.drop = Some(Drop::Move { names: d.names.clone(), target, after });
} else if let Some(p) = released::<Paint>(&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::<SwatchDrag>().is_some() || zone.dnd_hover_payload::<Paint>().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::<SwatchDrag>(zone) {
ev.drop = Some(Drop::Move { names: d.names.clone(), target: None, after: false });
} else if let Some(p) = released::<Paint>(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<String> {
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<Value> {
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<Value> {
if let Some(d) = released::<SwatchDrag>(resp) {
return swatch_params(&app.session.active()?.doc, &d.grabbed);
}
let mut paint = (*released::<Paint>(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::<SwatchDrag>(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::<Paint>(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<String> = 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<String> = 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<&Entry>> = 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<String> {
}
}
/// The selected solid-colour swatches in selection order (Merge Swatches keeps the first).
fn mergeable(app: &VectorcraftApp, sel: &[String]) -> Vec<String> {
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<String> {
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<String>, 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<String> {
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<String> = 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<String> {
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::<Paint>(&ctx).is_none());
}
#[test]
fn menu_targets_follow_the_panel_selection() {
let app = app();
let sel: Vec<String> = ["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);

View File

@ -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<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
let t = Tokens::get(ui.ctx());

View File

@ -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