mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 15:57:49 +00:00
New Color Group looked up and removed each named swatch with a scan of every swatch list, so making a group from artwork linked to many global swatches took time quadratic in their number. Delete Swatch, Move and Merge did the same, and checked each paint's link against the name list. take_swatches now finds every named swatch in one pass and removes them together, in the order and with the same choice of swatch as removing them one at a time. The commands look names up in one name map and check links against sets. With 16,000 named swatches in a release build, New Color Group went from 175.9 ms to 16.3 ms; 4,000 went from 11.6 ms to 3.6 ms.
664 lines
33 KiB
Rust
664 lines
33 KiB
Rust
//! 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::{Color, Paint};
|
|
|
|
use crate::*;
|
|
|
|
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"));
|
|
}
|
|
|
|
/// A swatch or color group moved to place 2^32 goes last. A 32-bit build read the place as 0 and
|
|
/// moved it to the front.
|
|
#[test]
|
|
fn move_past_u32_goes_last() {
|
|
let mut s = session();
|
|
run(&mut s, "swatch.move", json!({"name": "White", "to": 1u64 << 32}));
|
|
assert_eq!(names(&s, None).last().map(String::as_str), Some("White"));
|
|
run(&mut s, "swatch.move", json!({"name": "Grays", "to": 1u64 << 32}));
|
|
assert_eq!(doc(&s).swatch_groups.last().map(|g| g.name.as_str()), Some("Grays"));
|
|
}
|
|
|
|
#[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()), tint: 1.0 }
|
|
);
|
|
assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global);
|
|
}
|
|
|
|
/// Give `id` the fill `p` exactly as written (a command would round or convert some colors).
|
|
fn set_fill(s: &mut Session, id: NodeId, p: Paint) {
|
|
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.set_fill(p);
|
|
}
|
|
|
|
/// Rectangles filled with repeated colors, near-equal colors that get the same name, a color and
|
|
/// its twin with a -0.0 component, a color whose name a swatch already has, and colors two
|
|
/// swatches already have (one of them through -0.0). Returns the rectangles in paint order.
|
|
fn used_colors_doc(s: &mut Session) -> Vec<NodeId> {
|
|
run(s, "swatch.new", json!({"name": "Gray K=33", "color": "#00ff00"}));
|
|
run(s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"}));
|
|
let red = doc(s).swatch("Red").unwrap().paint.color().unwrap();
|
|
let colors = [
|
|
Color::rgb(0.2, 0.4, 0.6),
|
|
Color::rgb(0.2, 0.4, 0.6),
|
|
Color::rgb(0.2, 0.4, 0.6001),
|
|
Color::rgb(0.0, 0.4, 0.6),
|
|
Color::rgb(-0.0, 0.4, 0.6),
|
|
Color::gray(0.33),
|
|
Color::gray(0.3301),
|
|
red,
|
|
Color::rgb(-0.0, 128.0 / 255.0, 1.0),
|
|
Color::rgb(0.2, 0.4, 0.6001),
|
|
];
|
|
colors
|
|
.into_iter()
|
|
.map(|c| {
|
|
let id = rect_filled(s, json!({"color": "#000000"}));
|
|
set_fill(s, id, Paint::solid(c));
|
|
id
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
const USED_NAMES: [&str; 5] = ["R=51 G=102 B=153", "R=51 G=102 B=153 2", "R=0 G=102 B=153", "Gray K=33 2", "Gray K=33 3"];
|
|
|
|
#[test]
|
|
fn add_used_colors_adds_each_new_color_once_with_free_names() {
|
|
let mut s = session();
|
|
used_colors_doc(&mut s);
|
|
let before = doc(&s).clone();
|
|
let undo = undo_len(&s);
|
|
let r = run(&mut s, "swatch.addUsedColors", json!({}));
|
|
assert_eq!(r, json!({"added": USED_NAMES, "linked": 0}));
|
|
assert_eq!(names(&s, None)[names(&s, None).len() - 5..], USED_NAMES);
|
|
let color = |n: &str| doc(&s).swatch(n).unwrap().paint.color().unwrap();
|
|
assert_eq!(color("R=51 G=102 B=153 2"), Color::rgb(0.2, 0.4, 0.6001));
|
|
assert_eq!(color("Gray K=33 3"), Color::gray(0.3301));
|
|
assert_eq!(undo_len(&s), undo + 1);
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(*doc(&s), before);
|
|
}
|
|
|
|
#[test]
|
|
fn add_used_colors_global_links_every_paint_of_a_new_color() {
|
|
let mut s = session();
|
|
let ids = used_colors_doc(&mut s);
|
|
let r = run(&mut s, "swatch.addUsedColors", json!({"global": true}));
|
|
assert_eq!(r, json!({"added": USED_NAMES, "linked": 8}));
|
|
let link = |s: &Session, id: NodeId| match fill(s, id) {
|
|
Paint::Solid { swatch, .. } => swatch,
|
|
p => panic!("{p:?}"),
|
|
};
|
|
// The index in USED_NAMES of the swatch each rectangle links to.
|
|
let want = [Some(0), Some(0), Some(1), Some(2), Some(2), Some(3), Some(4), None, None, Some(1)];
|
|
let got: Vec<Option<String>> = ids.iter().map(|id| link(&s, *id)).collect();
|
|
assert_eq!(got, want.map(|i: Option<usize>| i.map(|i| USED_NAMES[i].to_string())));
|
|
assert!(USED_NAMES.iter().all(|n| doc(&s).swatch(n).unwrap().global));
|
|
}
|
|
|
|
/// A paint linked to Registration keeps its link when a new global swatch has the same color.
|
|
#[test]
|
|
fn add_used_colors_global_leaves_registration_links_alone() {
|
|
let mut s = session();
|
|
let reg_paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 };
|
|
let registration = rect_filled(&mut s, json!({"color": "#000000"}));
|
|
set_fill(&mut s, registration, reg_paint.clone());
|
|
let plain = rect_filled(&mut s, json!({"color": "#000000"}));
|
|
set_fill(&mut s, plain, Paint::solid(Color::cmyk(1.0, 1.0, 1.0, 1.0)));
|
|
let r = run(&mut s, "swatch.addUsedColors", json!({"global": true}));
|
|
assert_eq!(r["linked"], json!(1), "{r}");
|
|
let name = r["added"][0].as_str().unwrap().to_string();
|
|
assert_eq!(fill(&s, registration), reg_paint);
|
|
assert_eq!(fill(&s, plain), Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(name), tint: 1.0 });
|
|
}
|
|
|
|
#[test]
|
|
fn unused_matches_links_tints_colors_gradients_and_patterns_exactly() {
|
|
let mut s = session();
|
|
run(&mut s, "swatch.edit", json!({"name": "Teal", "global": true}));
|
|
run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 40}));
|
|
run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 70}));
|
|
run(&mut s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"}));
|
|
let teal = doc(&s).swatch("Teal").unwrap().paint.color().unwrap();
|
|
let tinted = rect_filled(&mut s, json!({"color": "#000000"}));
|
|
set_fill(&mut s, tinted, Paint::Solid { color: teal.tinted(0.4), swatch: Some("Teal".into()), tint: 0.4 });
|
|
let zero = rect_filled(&mut s, json!({"color": "#000000"}));
|
|
set_fill(&mut s, zero, Paint::solid(Color::rgb(-0.0, 128.0 / 255.0, 1.0)));
|
|
// Sunset's gradient without a link to it, beside gradients with Sunset's stop colors and a
|
|
// different midpoint or opacity; Ocean's with one stop moved, and Ocean's with another kind,
|
|
// method or dither.
|
|
let gradient = |s: &Session, n: &str| match &doc(s).swatch(n).unwrap().paint {
|
|
Paint::Gradient(g) => g.gradient.clone(),
|
|
p => panic!("{p:?}"),
|
|
};
|
|
let sunset = gradient(&s, "Sunset");
|
|
let (mut midpoint, mut opacity) = (sunset.clone(), sunset.clone());
|
|
midpoint.stops[0].midpoint = 0.3;
|
|
opacity.stops[1].opacity = 0.5;
|
|
let ocean = gradient(&s, "Ocean");
|
|
let (mut moved, mut radial, mut perceptual, mut dither) = (ocean.clone(), ocean.clone(), ocean.clone(), ocean);
|
|
moved.stops[0].offset += 0.25;
|
|
radial.kind = vectorcraft_color::GradientKind::Radial;
|
|
perceptual.method = vectorcraft_color::GradientMethod::Perceptual;
|
|
dither.dither = true;
|
|
for g in [midpoint, sunset.clone(), opacity, sunset, moved, radial, perceptual, dither] {
|
|
let id = rect_filled(&mut s, json!({"color": "#000000"}));
|
|
set_fill(&mut s, id, Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(g))));
|
|
}
|
|
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", "Teal 40%", "Zeroed", "Sunset", "Dots"] {
|
|
assert!(!names.contains(&used), "{used} is used: {names:?}");
|
|
}
|
|
for unused in ["Teal 70%", "Ocean", "Orange", "Red"] {
|
|
assert!(names.contains(&unused), "{unused} is unused: {names:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn new_swatches_from_colors_are_numbered_after_names_already_taken() {
|
|
let mut s = session();
|
|
run(&mut s, "swatch.new", json!({"name": "R=255 G=0 B=0 2", "color": "#0000ff"}));
|
|
run(&mut s, "swatch.newGroup", json!({"name": "Mine"}));
|
|
let before = doc(&s).clone();
|
|
let undo = undo_len(&s);
|
|
// Near-equal colors with the same name, and a Lab color and its twin with a -0.0 component.
|
|
let colors = json!([
|
|
"#ff0000",
|
|
"#ff0000",
|
|
"#ff0000",
|
|
"#00ff00",
|
|
"#00ff00",
|
|
[0.2, 0.4, 0.6],
|
|
[0.2, 0.4, 0.6001],
|
|
{"l": 50, "a": 0.0, "b": 0},
|
|
{"l": 50, "a": -0.0, "b": 0}
|
|
]);
|
|
let r = run(&mut s, "swatch.new", json!({ "colors": colors }));
|
|
let want = [
|
|
"R=255 G=0 B=0",
|
|
"R=255 G=0 B=0 3",
|
|
"R=255 G=0 B=0 4",
|
|
"R=0 G=255 B=0",
|
|
"R=0 G=255 B=0 2",
|
|
"R=51 G=102 B=153",
|
|
"R=51 G=102 B=153 2",
|
|
"L=50 a=0 b=0",
|
|
"L=50 a=0 b=0 2",
|
|
];
|
|
assert_eq!(r, json!({"name": want[0], "names": want}));
|
|
assert_eq!(names(&s, None)[names(&s, None).len() - want.len()..], want);
|
|
assert!(matches!(doc(&s).swatch("L=50 a=0 b=0 2").unwrap().paint.color(), Some(Color::Lab { a, .. }) if a.is_sign_negative()));
|
|
assert_eq!(undo_len(&s), undo + 1);
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(*doc(&s), before);
|
|
let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", "#ff0000", "#ff0000"]}));
|
|
assert_eq!(r["names"], json!(["R=255 G=0 B=0", "R=255 G=0 B=0 3", "R=255 G=0 B=0 4"]));
|
|
let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", "#ff0000"], "group": "Mine"}));
|
|
assert_eq!(r["names"], json!(["R=255 G=0 B=0 5", "R=255 G=0 B=0 6"]));
|
|
assert_eq!(names(&s, Some("Mine")), ["R=255 G=0 B=0 5", "R=255 G=0 B=0 6"]);
|
|
}
|
|
|
|
#[test]
|
|
fn new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches() {
|
|
let mut s = session();
|
|
run(&mut s, "swatch.new", json!({"name": "R=255 G=0 B=0 2", "color": "#0000ff"}));
|
|
let r = run(
|
|
&mut s,
|
|
"swatch.newGroup",
|
|
json!({"name": "R=255 G=0 B=0", "swatches": ["R=255 G=0 B=0 2"], "colors": ["#ff0000", "#ff0000", "#00ff00", "#ff0000"]}),
|
|
);
|
|
let want = ["R=255 G=0 B=0 2", "R=255 G=0 B=0 3", "R=255 G=0 B=0 4", "R=0 G=255 B=0", "R=255 G=0 B=0 5"];
|
|
assert_eq!(r, json!({"name": "R=255 G=0 B=0", "swatches": want, "linked": 0}));
|
|
}
|
|
|
|
/// New Color Group from artwork lists each color once in paint order: a repeat and a twin with a
|
|
/// -0.0 component are the same color, near-equal colors with the same name are numbered after the
|
|
/// names already taken, and a paint linked to Registration brings no color. One undo step.
|
|
#[test]
|
|
fn new_color_group_from_artwork_lists_each_color_once_with_free_names() {
|
|
let mut s = session();
|
|
run(&mut s, "swatch.new", json!({"name": "R=0 G=102 B=153", "color": "#00ff00"}));
|
|
let colors = [
|
|
Color::rgb(0.2, 0.4, 0.6),
|
|
Color::rgb(0.0, 0.4, 0.6),
|
|
Color::rgb(0.2, 0.4, 0.6001),
|
|
Color::rgb(-0.0, 0.4, 0.6),
|
|
Color::rgb(0.2, 0.4, 0.6),
|
|
Color::lab(50.0, 0.0, 0.0),
|
|
Color::lab(50.0, -0.0, 0.0),
|
|
Color::lab(50.0, 0.0001, 0.0),
|
|
];
|
|
let mut ids: Vec<u64> = vec![];
|
|
for c in colors {
|
|
let id = rect_filled(&mut s, json!({"color": "#000000"}));
|
|
set_fill(&mut s, id, Paint::solid(c));
|
|
ids.push(id.0);
|
|
}
|
|
let registration = rect_filled(&mut s, json!({"color": "#000000"}));
|
|
let reg_paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 };
|
|
set_fill(&mut s, registration, reg_paint);
|
|
ids.push(registration.0);
|
|
run(&mut s, "select.set", json!({ "ids": ids }));
|
|
let before = doc(&s).clone();
|
|
let undo = undo_len(&s);
|
|
let r = run(&mut s, "swatch.newGroup", json!({"name": "R=51 G=102 B=153 2", "fromArtwork": true}));
|
|
let want = ["R=51 G=102 B=153", "R=0 G=102 B=153 2", "R=51 G=102 B=153 3", "L=50 a=0 b=0", "L=50 a=0 b=0 2"];
|
|
assert_eq!(r, json!({"name": "R=51 G=102 B=153 2", "swatches": want, "linked": 8}));
|
|
assert_eq!(names(&s, Some("R=51 G=102 B=153 2")), want);
|
|
assert_eq!(undo_len(&s), undo + 1);
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(*doc(&s), before);
|
|
}
|
|
|
|
/// New Color Group takes the named solid swatches in the order given. A name listed twice takes
|
|
/// the next swatch of that name, found ungrouped first and then in each colour group, and the
|
|
/// lists the swatches leave keep their order. Gradients and names no swatch has take nothing.
|
|
#[test]
|
|
fn new_color_group_takes_named_swatches_in_order_from_every_list() {
|
|
let mut s = session();
|
|
let twin = vectorcraft_color::Swatch { name: "Red".into(), paint: Paint::solid(Color::rgb(0.5, 0.0, 0.0)), global: false, spot: false };
|
|
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc)
|
|
.swatch_groups
|
|
.iter_mut()
|
|
.find(|g| g.name == "Brights")
|
|
.unwrap()
|
|
.swatches
|
|
.push(twin.clone());
|
|
let red = doc(&s).swatch("Red").cloned().unwrap();
|
|
let mut ungrouped = names(&s, None);
|
|
let mut brights = names(&s, Some("Brights"));
|
|
let r = run(&mut s, "swatch.newGroup", json!({"name": "Mine", "swatches": ["Red", "Orange", "Sunset", "Red", "Missing", "Bright Red"]}));
|
|
assert_eq!(r["swatches"], json!(["Red", "Orange", "Red", "Bright Red"]));
|
|
let group = &doc(&s).swatch_groups.iter().find(|g| g.name == "Mine").unwrap().swatches;
|
|
assert_eq!(group[0], red);
|
|
assert_eq!(group[2], twin);
|
|
ungrouped.retain(|n| n != "Red" && n != "Orange");
|
|
brights.retain(|n| n != "Red" && n != "Bright Red");
|
|
assert_eq!(names(&s, None), ungrouped);
|
|
assert_eq!(names(&s, Some("Brights")), brights);
|
|
assert!(doc(&s).swatch("Sunset").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn edit_color_group_numbers_extra_colors_after_the_names_it_keeps() {
|
|
let mut s = session();
|
|
run(&mut s, "swatch.newGroup", json!({"name": "Mine", "colors": ["#ff0000", "#00ff00", "#0000ff"]}));
|
|
let before = doc(&s).clone();
|
|
let undo = undo_len(&s);
|
|
// Near-equal colors with the same name, and a Lab color and its twin with a -0.0 component.
|
|
let colors = json!([
|
|
"#0000ff",
|
|
"#ff0000",
|
|
"#ff0000",
|
|
"#ff0000",
|
|
"#ff0000",
|
|
"#00ff00",
|
|
[0.2, 0.4, 0.6],
|
|
[0.2, 0.4, 0.6001],
|
|
{"l": 50, "a": 0.0, "b": 0},
|
|
{"l": 50, "a": -0.0, "b": 0}
|
|
]);
|
|
let r = run(&mut s, "swatch.editGroup", json!({"group": "Mine", "colors": colors, "rename": "R=255 G=0 B=0 2"}));
|
|
let want = [
|
|
"R=255 G=0 B=0",
|
|
"R=0 G=255 B=0",
|
|
"R=0 G=0 B=255",
|
|
"R=255 G=0 B=0 3",
|
|
"R=255 G=0 B=0 4",
|
|
"R=0 G=255 B=0 2",
|
|
"R=51 G=102 B=153",
|
|
"R=51 G=102 B=153 2",
|
|
"L=50 a=0 b=0",
|
|
"L=50 a=0 b=0 2",
|
|
];
|
|
assert_eq!(r, json!({"name": "R=255 G=0 B=0 2", "swatches": want, "relinked": 0}));
|
|
assert_eq!(names(&s, Some("R=255 G=0 B=0 2")), want);
|
|
assert_eq!(undo_len(&s), undo + 1);
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(*doc(&s), before);
|
|
}
|
|
|
|
#[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()), tint: 1.0 });
|
|
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)));
|
|
// RGB colors separate through the color settings (Generic CMYK: red is C0 M97 Y100 K0).
|
|
let cms = vectorcraft_color::cms::active();
|
|
let managed = |c: Color| cms.convert(&c, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent);
|
|
let a = rect_filled(&mut s, json!({"color": "#ff0000"}));
|
|
assert_eq!(fill(&s, a), Paint::solid(managed(Color::rgb(1.0, 0.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"].as_str().unwrap().to_string();
|
|
assert_eq!(doc(&s).swatch(&name).and_then(|w| w.paint.color()), Some(managed(Color::rgb(0.0, 1.0, 0.0))));
|
|
assert_eq!(name, "C=65 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));
|
|
}
|
|
|
|
/// The other swatch commands store RGB colors in a CMYK document as `swatch.new {color}` does:
|
|
/// `swatch.new {colors}`, `swatch.newGroup {colors}`, `swatch.editGroup {colors}` and
|
|
/// `swatch.edit {color}`. A color a swatch already has, and a color given with `mode`, keep their
|
|
/// model.
|
|
#[test]
|
|
fn a_cmyk_document_stores_rgb_colors_given_to_the_swatch_commands_as_cmyk() {
|
|
use vectorcraft_color::Color;
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"}));
|
|
let cms = vectorcraft_color::cms::active();
|
|
let managed = |c: Color| cms.convert(&c, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent);
|
|
let (red, green) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 1.0, 0.0));
|
|
let color = |s: &Session, n: &Value| doc(s).swatch(n.as_str().unwrap()).and_then(|w| w.paint.color());
|
|
let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", {"c": 0, "m": 0, "y": 100, "k": 0}]}));
|
|
assert_eq!((color(&s, &r["names"][0]), color(&s, &r["names"][1])), (Some(managed(red)), Some(Color::cmyk(0.0, 0.0, 1.0, 0.0))));
|
|
let r = run(&mut s, "swatch.newGroup", json!({"name": "G", "colors": ["#00ff00"]}));
|
|
assert_eq!(color(&s, &r["swatches"][0]), Some(managed(green)));
|
|
let kept = run(&mut s, "swatch.newGroup", json!({"name": "K", "colors": ["#00ff00"], "keepModel": true}));
|
|
assert_eq!(color(&s, &kept["swatches"][0]), Some(green), "keepModel keeps RGB");
|
|
let r = run(&mut s, "swatch.editGroup", json!({"group": "G", "colors": ["#ff0000", "#00ff00"]}));
|
|
assert_eq!((color(&s, &r["swatches"][0]), color(&s, &r["swatches"][1])), (Some(managed(red)), Some(managed(green))));
|
|
let name = run(&mut s, "swatch.new", json!({"color": "#000000", "keepModel": true}))["name"].clone();
|
|
assert_eq!(color(&s, &name), Some(Color::BLACK));
|
|
run(&mut s, "swatch.edit", json!({"name": name, "color": "#ff0000"}));
|
|
assert_eq!(color(&s, &name), Some(managed(red)));
|
|
// An RGB swatch sent back unchanged stays RGB.
|
|
let rgb = run(&mut s, "swatch.new", json!({"color": "#00ff00", "keepModel": true}))["name"].clone();
|
|
run(&mut s, "swatch.edit", json!({"name": rgb, "color": "#00ff00", "global": true}));
|
|
assert_eq!(color(&s, &rgb), Some(green));
|
|
run(&mut s, "swatch.newGroup", json!({"name": "H", "swatches": [rgb]}));
|
|
let r = run(&mut s, "swatch.editGroup", json!({"group": "H", "colors": ["#00ff00", "#ff0000"]}));
|
|
assert_eq!((color(&s, &r["swatches"][0]), color(&s, &r["swatches"][1])), (Some(green), Some(managed(red))));
|
|
// With `mode`, the color is converted once, into that mode.
|
|
let r = run(&mut s, "swatch.new", json!({"color": "#00ff00", "mode": "rgb"}));
|
|
assert_eq!(color(&s, &r["name"]), Some(green));
|
|
let r = run(&mut s, "swatch.new", json!({"colors": ["#00ff00"], "mode": "rgb"}));
|
|
assert_eq!(color(&s, &r["names"][0]), Some(green));
|
|
}
|
|
|
|
/// In a CMYK document `swatch.new` (with `color`, `colors` or `mode`), `swatch.newGroup`,
|
|
/// `swatch.edit` (its `keepModel` and its `paint`'s) and `swatch.editGroup` refuse a `keepModel` given
|
|
/// as text or as another value that is not true or false, and the swatches and the undo history stay
|
|
/// as they were. Null is read as `keepModel` left out.
|
|
#[test]
|
|
fn the_swatch_commands_refuse_a_keep_model_given_as_text() {
|
|
use vectorcraft_color::Color;
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"}));
|
|
let cms = vectorcraft_color::cms::active();
|
|
let managed = |c: Color| cms.convert(&c, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent);
|
|
let name = run(&mut s, "swatch.new", json!({"color": "#000000", "keepModel": true}))["name"].clone();
|
|
run(&mut s, "swatch.newGroup", json!({"name": "G", "colors": ["#00ff00"]}));
|
|
let before = (doc(&s).clone(), undo_len(&s));
|
|
let calls = [
|
|
("swatch.new", json!({"color": "#ff0000", "keepModel": "true"})),
|
|
("swatch.new", json!({"colors": ["#ff0000"], "keepModel": 1})),
|
|
("swatch.new", json!({"color": "#ff0000", "mode": "rgb", "keepModel": "false"})),
|
|
("swatch.newGroup", json!({"name": "K", "colors": ["#00ff00"], "keepModel": "true"})),
|
|
("swatch.edit", json!({"name": name, "color": "#ff0000", "keepModel": [true]})),
|
|
("swatch.edit", json!({"name": name, "paint": {"color": "#ff0000", "keepModel": "true"}, "mode": "cmyk"})),
|
|
("swatch.editGroup", json!({"group": "G", "colors": ["#ff0000"], "keepModel": "true"})),
|
|
];
|
|
for (cmd, p) in &calls {
|
|
let r = s.execute(cmd, p);
|
|
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`keepModel` must be true or false"), "{cmd} {p}: {r:?}");
|
|
assert_eq!((doc(&s).clone(), undo_len(&s)), before, "{cmd} {p}");
|
|
}
|
|
let r = run(&mut s, "swatch.new", json!({"color": "#ff0000", "keepModel": null}));
|
|
assert_eq!(doc(&s).swatch(r["name"].as_str().unwrap()).and_then(|w| w.paint.color()), Some(managed(Color::rgb(1.0, 0.0, 0.0))));
|
|
}
|
|
|
|
/// The swatch commands refuse an on/off option given as text or as another value that is not true
|
|
/// or false, and `swatch.move` a `to` that is not a number, naming the key; the swatches and the
|
|
/// undo history stay as they were. True, false, numbers and null still work.
|
|
#[test]
|
|
fn the_swatch_commands_refuse_a_boolean_or_number_given_as_text() {
|
|
use crate::tests::{not_a_number, not_a_switch, not_refused};
|
|
let mut s = session();
|
|
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}));
|
|
run(&mut s, "paint.setFill", json!({"color": "#3366cc"}));
|
|
let name = run(&mut s, "swatch.new", json!({"color": "#000000"}))["name"].clone();
|
|
let mut calls: Vec<_> = [
|
|
("swatch.new", json!({"color": "#ff0000", "spot": "true"}), "spot"),
|
|
("swatch.new", json!({"color": "#ff0000", "global": 1}), "global"),
|
|
("swatch.delete", json!({"name": name, "unlink": "false"}), "unlink"),
|
|
("swatch.newGroup", json!({"name": "K", "colors": ["#00ff00"], "includeTints": "true"}), "includeTints"),
|
|
("swatch.newGroup", json!({"name": "K", "fromArtwork": "true"}), "fromArtwork"),
|
|
("swatch.newGroup", json!({"name": "K", "fromArtwork": true, "toGlobal": "false"}), "toGlobal"),
|
|
("swatch.edit", json!({"name": name, "spot": "true"}), "spot"),
|
|
("swatch.edit", json!({"name": name, "global": [true]}), "global"),
|
|
("swatch.addUsedColors", json!({"global": "true"}), "global"),
|
|
("swatch.addUsedColors", json!({"selection": 1}), "selection"),
|
|
]
|
|
.into_iter()
|
|
.map(|(cmd, p, key)| not_a_switch(cmd, p, key))
|
|
.collect();
|
|
calls.push(not_a_number("swatch.move", json!({"name": name, "to": "0"}), "to"));
|
|
let wrong = not_refused(&mut s, &calls);
|
|
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
|
run(&mut s, "swatch.edit", json!({"name": name, "global": true, "spot": null}));
|
|
run(&mut s, "swatch.move", json!({"name": name, "to": 0}));
|
|
assert!(doc(&s).swatch(name.as_str().unwrap()).is_some_and(|w| w.global && !w.spot));
|
|
}
|
|
|
|
#[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 { .. })));
|
|
}
|