Swatches: global swatch edits also update graph designs and the art of brushes

Squashed from #4489 onto main.
This commit is contained in:
dotCooCoo
2026-10-29 14:36:04 +07:00
committed by bflatastic
parent b9f9099d91
commit bb869dd9df
6 changed files with 375 additions and 13 deletions
+40 -4
View File
@@ -108,9 +108,10 @@ impl Document {
Some(list.remove(i))
}
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
/// strokes and text runs — in the art (opacity masks included), symbol definitions, pattern
/// tiles, graphic styles and the swatches themselves (tint swatches, gradient swatches' stops).
/// Visit every color that can link to a swatch (solid paints and gradient stops of fills,
/// strokes and text runs) in the art (opacity masks included), symbol definitions, graph
/// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles
/// and the swatches themselves (tint swatches, gradient swatches' stops).
/// `f(colour, link, tint)` may change them and returns true when it did
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
/// copied. Returns the number of changed paints.
@@ -119,7 +120,10 @@ impl Document {
for s in &mut self.symbols {
n += map_tree(&mut s.art, f);
}
for p in &mut self.patterns {
for g in &mut self.graph_designs {
n += map_tree(&mut g.art, f);
}
for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) {
n += map_trees(&mut p.art, f);
}
for gs in &mut self.graphic_styles {
@@ -307,6 +311,19 @@ pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
}
}
/// [`Document::map_solid_paints`] for art a document keeps outside its trees, such as the art of a
/// brush: `f` applied to the linkable colors of `n` and its subtree (opacity masks included). Only
/// the nodes holding a changed paint are copied. Returns the number of changed paints.
pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize {
match map_node(n, f) {
Some((new, count)) => {
*n = new;
count
}
None => 0,
}
}
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
match map_node(n, f) {
Some((new, count)) => {
@@ -400,6 +417,25 @@ mod tests {
assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0]));
}
#[test]
fn mapping_reaches_graph_designs_and_the_tile_that_pattern_cancel_puts_back() {
let (mut d, a, _) = doc();
let art = d.node_arc(a).cloned().unwrap();
d.graph_designs.push(crate::GraphDesign { name: "Bar".into(), art: art.clone() });
d.pattern_edit =
Some(crate::PatternEdit { pattern: "Tile".into(), layer: NodeId(0), original: Some(crate::PatternDef::new("Tile", vec![art])) });
let mut loose = d.node(a).unwrap().clone();
let blue = Color::rgb(0.0, 0.0, 1.0);
let mut to_blue = |c: &mut Color, s: &mut Option<String>, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue;
assert_eq!(d.map_solid_paints(&mut to_blue), 3, "the layer's rectangle, the design and the Cancel tile");
assert_eq!(d.graph_designs[0].art.appearance.fill_paint(), linked(blue, "Brand"));
let tile = &d.pattern_edit.as_ref().unwrap().original.as_ref().unwrap().art[0];
assert_eq!(tile.appearance.fill_paint(), linked(blue, "Brand"));
assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 1);
assert_eq!(loose.appearance.fill_paint(), linked(blue, "Brand"));
assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 0, "nothing left to change");
}
#[test]
fn mapping_within_ids_visits_each_paint_once() {
let (mut d, a, b) = doc();
+130 -6
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent};
use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_doc::swatches::{LinkFn, color_name, node_colors};
use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors};
use vectorcraft_doc::{Document, NodeId};
use super::paint::paint_from;
@@ -257,7 +257,7 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
for n in &names {
d.remove_swatch(n);
}
Ok(d.map_solid_paints(&mut unlink))
Ok(map_swatch_links(d, &mut unlink))
})?;
map_default_paints(s, &mut unlink);
Ok(json!({"deleted": names, "unlinked": unlinked}))
@@ -529,6 +529,130 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
}
}
/// Apply a swatch-link rewrite to the document ([`Document::map_solid_paints`]) and to the art of
/// the brushes in its library, which is stored again only when a brush's paint changed. A Hue Shift
/// key that was the color of a recolored paint takes the paint's new color. Returns the number of
/// changed paints. Stored entries are stored again as they were read, except that a brush whose
/// paint changed is merged into its entry ([`merge_stored`]), so fields the brush definition does
/// not read are kept at every level.
pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize {
use vectorcraft_brush::{Brush, BrushKind, Colorization};
let mut n = d.map_solid_paints(f);
// Each stored entry, its brush when it parses, the brush as written before the edit (only
// when its paint changed), and whether its paint changed.
let mut lib: Vec<(Value, Option<Brush>, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) {
Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(),
None => {
vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect()
}
};
let mut in_brushes = 0;
for (_, b, base, changed) in lib.iter_mut() {
let Some(b) = b else { continue };
let before_edit = serde_json::to_value(&*b).unwrap_or_default();
let key = match b.kind.colorization() {
Colorization::HueShift { key } => Some(key),
_ => None,
};
let before = key.map(|_| art_paint_colors(b.art()));
let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, f)).sum::<usize>();
*changed = in_brush > 0;
if *changed {
*base = before_edit;
}
in_brushes += in_brush;
let (Some(key), Some(before)) = (key, before) else { continue };
// Colors are compared by hex value, as `art_colors` (Brush Options' key colors) lists them.
let same = |a: &Color, b: &Color| a.to_hex() == b.to_hex();
let after = art_paint_colors(b.art());
// The key moves when the art's fills and strokes (not opacity masks) had the key color and
// none has it now, to the first new color of a paint that had it.
let moved = before.iter().any(|c| same(c, &key)) && !after.iter().any(|c| same(c, &key));
let new_key = before.iter().zip(&after).find(|(o, _)| same(o, &key)).map(|(_, n)| *n);
if let Some(key) = new_key.filter(|_| moved) {
let c = match &mut b.kind {
BrushKind::Art(a) => &mut a.colorization,
BrushKind::Scatter(s) => &mut s.colorization,
BrushKind::Pattern(p) => &mut p.colorization,
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => continue,
};
*c = Colorization::HueShift { key };
}
}
if in_brushes > 0 {
let lib = lib
.into_iter()
.map(|(raw, b, base, changed)| match b.filter(|_| changed) {
Some(b) => merge_stored(raw, &base, serde_json::to_value(b).unwrap_or_default()),
None => raw,
})
.collect();
d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(lib));
n += in_brushes;
}
n
}
/// `new` (a definition as written after an edit) with the parts of `raw` (its stored entry) that
/// `base` (the definition as read from `raw` and written before the edit) does not hold. An object
/// keeps the fields of `raw` that `base` leaves out, and drops the fields that `base` holds and
/// `new` leaves out. An array takes the length of `new`, each element merged with the element at
/// the same index in `raw`.
fn merge_stored(raw: Value, base: &Value, new: Value) -> Value {
match (raw, new) {
(Value::Object(mut raw), Value::Object(new)) => {
let mut out = serde_json::Map::new();
for (k, v) in new {
let v = match raw.remove(&k) {
Some(r) => merge_stored(r, base.get(k.as_str()).unwrap_or(&Value::Null), v),
None => v,
};
out.insert(k, v);
}
out.extend(raw.into_iter().filter(|(k, _)| base.get(k.as_str()).is_none()));
Value::Object(out)
}
(Value::Array(raw), Value::Array(new)) => {
let mut raw = raw.into_iter();
let merged = new.into_iter().enumerate().map(|(i, v)| match raw.next() {
Some(r) => merge_stored(r, base.get(i).unwrap_or(&Value::Null), v),
None => v,
});
Value::Array(merged.collect())
}
(_, new) => new,
}
}
/// The solid colors of the fills and strokes under `nodes` (type runs' fills and strokes included,
/// opacity masks left out), in tree order and with repeats, the paints [`vectorcraft_brush::art_colors`]
/// counts.
fn art_paint_colors(nodes: Vec<&vectorcraft_doc::Node>) -> Vec<Color> {
fn walk(n: &vectorcraft_doc::Node, out: &mut Vec<Color>) {
for it in &n.appearance.items {
let (visible, paint) = match it {
vectorcraft_doc::AppearanceItem::Fill(f) => (f.visible, &f.paint),
vectorcraft_doc::AppearanceItem::Stroke(s) => (s.visible, &s.paint),
};
out.extend(paint.color().filter(|_| visible));
}
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
for r in &t.runs {
let stroke = (r.style.stroke_width > 0.0).then_some(&r.style.stroke);
out.extend([Some(&r.style.fill), stroke].into_iter().flatten().filter_map(Paint::color));
}
}
for c in n.children().into_iter().flatten() {
walk(c, out);
}
}
let mut out = vec![];
for n in nodes {
walk(n, &mut out);
}
out
}
fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.edit";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string();
@@ -592,7 +716,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(d.map_solid_paints(&mut |c, l, t| relink.apply(c, l, t)))
Ok(map_swatch_links(d, &mut |c, l, t| relink.apply(c, l, t)))
})?;
relink.defaults(s);
Ok(json!({"name": to, "relinked": relinked}))
@@ -747,7 +871,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
for n in merged {
d.remove_swatch(n);
}
Ok(d.map_solid_paints(&mut relink))
Ok(map_swatch_links(d, &mut relink))
})?;
map_default_paints(s, &mut relink);
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
@@ -803,7 +927,7 @@ fn swatch_spot_options(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|w| w.spot && matches!(w.paint, Paint::Solid { color: Color::Lab { .. }, swatch: None, .. }))
.filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?)))
.collect();
let relinked = d.map_solid_paints(&mut follow_bases(&bases));
let relinked = map_swatch_links(d, &mut follow_bases(&bases));
Ok((relinked, bases))
})?;
map_default_paints(s, &mut follow_bases(&bases));
@@ -880,7 +1004,7 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren
let g = &mut d.swatch_groups[gi];
(g.name, g.swatches) = (name.clone(), swatches);
let names = g.swatches.iter().map(|w| w.name.clone()).collect();
let relinked = if relinks.is_empty() { 0 } else { d.map_solid_paints(&mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) };
let relinked = if relinks.is_empty() { 0 } else { map_swatch_links(d, &mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) };
Ok(GroupEdit { name, swatches: names, relinks, relinked })
}
+2 -2
View File
@@ -17,7 +17,7 @@ use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatc
use super::fileio::{create_dir, extension, read_file, write_file, write_new_file};
use super::menucmds::squash;
use super::package::free_name;
use super::swatch::{map_default_paints, str_list, swatch_json};
use super::swatch::{map_default_paints, map_swatch_links, str_list, swatch_json};
use super::{Color, CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
pub fn specs() -> Vec<CommandSpec> {
@@ -644,7 +644,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
(d.swatches, d.swatch_groups) = (defaults.into(), groups.into());
added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone()));
live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone()));
d.map_solid_paints(&mut |_, l, _| unlink_dead(&live, l));
map_swatch_links(d, &mut |_, l, _| unlink_dead(&live, l));
return Ok(added);
}
for (i, w) in defaults.into_iter().enumerate() {
+200
View File
@@ -528,3 +528,203 @@ fn global_swatch_edits_reach_opacity_mask_art() {
run(&mut s, "edit.undo", json!({}));
assert_eq!(mask_fill(&s).color(), Some(Color::rgb(1.0, 1.0, 1.0)));
}
/// The fills of graph design "Bar" and of the art of brush "Holder", as `place: color, link`.
fn fills_outside_the_layers(s: &Session) -> Vec<String> {
let d = doc(s);
let mut out = vec![];
let mut tree = |place: &str, n: &vectorcraft_doc::Node| {
n.walk(&mut |m| {
if let Paint::Solid { color, swatch, .. } = m.appearance.fill_paint() {
out.push(format!("{place}: {}, {}", color.to_hex(), swatch.as_deref().unwrap_or("-")));
}
})
};
tree("design", &d.graph_designs.iter().find(|g| g.name == "Bar").unwrap().art);
vectorcraft_brush::find(d, "Holder").unwrap().art().into_iter().for_each(|n| tree("brush art", n));
out
}
/// A global swatch edit that recolors a Hue Shift brush's key color in its art moves the key to the
/// new color, so strokes still paint that art with the stroke color.
#[test]
fn global_swatch_edits_move_a_hue_shift_key() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
let id = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id.0]}));
run(&mut s, "brush.new", json!({"type": "art", "name": "Key", "ids": [id.0], "params": {"colorization": {"method": "hueShift"}}}));
let key = |s: &mut Session| run(s, "brush.get", json!({"name": "Key"}))["colorization"]["key"].clone();
let (red, blue) = (json!(Color::rgb(1.0, 0.0, 0.0)), json!(Color::rgb(0.0, 0.0, 1.0)));
assert_eq!(key(&mut s), red);
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
assert_eq!(key(&mut s), blue);
run(&mut s, "edit.undo", json!({}));
assert_eq!(key(&mut s), red);
// A paint in the key color that only an opacity mask uses leaves the key where it is.
run(&mut s, "swatch.new", json!({"name": "MaskTone", "color": "#ff0000", "global": true}));
let obj = rect(&mut s);
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [obj.0]}));
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [obj.0]}));
let m = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "MaskTone", "ids": [m.0]}));
run(&mut s, "select.set", json!({"ids": [obj.0, m.0]}));
run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false}));
run(&mut s, "brush.new", json!({"type": "art", "name": "Masked", "ids": [obj.0], "params": {"colorization": {"method": "hueShift"}}}));
let masked = |s: &mut Session| run(s, "brush.get", json!({"name": "Masked"}))["colorization"]["key"].clone();
assert_eq!(masked(&mut s), red);
run(&mut s, "swatch.edit", json!({"name": "MaskTone", "color": "#0000ff"}));
assert_eq!(masked(&mut s), red);
// With the art and its mask on one swatch, the key takes the art's new color, not the mask's.
run(&mut s, "swatch.new", json!({"name": "Both", "color": "#ffffff", "global": true}));
let obj = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Both", "ids": [obj.0]}));
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [obj.0]}));
let m = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Both", "tint": 50, "ids": [m.0]}));
run(&mut s, "select.set", json!({"ids": [obj.0, m.0]}));
run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false}));
run(&mut s, "brush.new", json!({"type": "art", "name": "Both", "ids": [obj.0], "params": {"colorization": {"method": "hueShift"}}}));
run(&mut s, "swatch.edit", json!({"name": "Both", "color": "#0000ff"}));
assert_eq!(run(&mut s, "brush.get", json!({"name": "Both"}))["colorization"]["key"], blue);
}
/// A global swatch edit that recolors brush art keeps the stored library entries that do not parse
/// as brushes.
#[test]
fn global_swatch_edits_keep_unreadable_brush_entries() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
let id = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
run(&mut s, "brush.new", json!({"type": "art", "name": "Holder", "ids": [id.0]}));
let junk = json!({"name": "Unreadable", "kind": 7});
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(Value::Array(entries)) = doc.unknown.get_mut(vectorcraft_brush::DOC_KEY) else { panic!("no stored brushes") };
entries.push(junk.clone());
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY];
assert!(stored.as_array().is_some_and(|a| a.contains(&junk)), "{stored}");
assert!(vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").is_some());
}
/// A global swatch edit that recolors brush art keeps the fields of stored brush entries that the
/// brush definition does not read, both on the brush it recolors and on the brushes it leaves alone,
/// and inside the art too. A swatch delete that unlinks the art drops the link and keeps those fields.
#[test]
fn global_swatch_edits_keep_unread_brush_fields() {
// Adds `"extra": 8` to every object under `v` that links a swatch.
fn tag_links(v: &mut Value) {
match v {
Value::Object(o) => {
if o.contains_key("swatch") {
o.insert("extra".into(), json!(8));
}
o.values_mut().for_each(tag_links);
}
Value::Array(a) => a.iter_mut().for_each(tag_links),
_ => {}
}
}
// The objects under `v` that carry `"extra": 8`.
fn tagged(v: &Value) -> Vec<&Value> {
match v {
Value::Object(o) => o.get("extra").filter(|e| **e == json!(8)).map(|_| v).into_iter().chain(o.values().flat_map(tagged)).collect(),
Value::Array(a) => a.iter().flat_map(tagged).collect(),
_ => vec![],
}
}
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
let id = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
run(&mut s, "brush.new", json!({"type": "art", "name": "Holder", "ids": [id.0]}));
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(Value::Array(entries)) = doc.unknown.get_mut(vectorcraft_brush::DOC_KEY) else { panic!("no stored brushes") };
for e in entries.iter_mut() {
if let Some(o) = e.as_object_mut() {
o.insert("extra".into(), json!(7));
}
tag_links(e);
}
let links = tagged(&doc.unknown[vectorcraft_brush::DOC_KEY]).len();
assert!(links > 0, "{}", doc.unknown[vectorcraft_brush::DOC_KEY]);
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY];
let entries = stored.as_array().map(Vec::as_slice).unwrap_or_default();
assert!(entries.len() > 1, "{stored}");
assert!(entries.iter().all(|e| e["extra"] == json!(7)), "{stored}");
assert_eq!(tagged(stored).len(), links, "{stored}");
let holder = vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").unwrap();
let fill = holder.art().first().map(|n| n.appearance.fill_paint().color().map(|c| c.to_hex()));
assert_eq!(fill, Some(Some("#0000ff".to_string())));
run(&mut s, "swatch.delete", json!({"name": "Tone"}));
let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY];
let unlinked = tagged(stored);
assert_eq!(unlinked.len(), links, "{stored}");
assert!(unlinked.iter().all(|o| o.get("swatch").is_none()), "{stored}");
let holder = vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").unwrap();
let fill = holder.art().first().map(|n| n.appearance.fill_paint().color().map(|c| c.to_hex()));
assert_eq!(fill, Some(Some("#0000ff".to_string())));
}
/// A global swatch edit, rename, delete and Default Swatches with replace change the art of graph
/// designs and brushes: the art follows the swatch's color and name, or keeps its color unlinked,
/// each in one undo step.
#[test]
fn global_swatch_edits_reach_graph_designs_and_brush_art() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
for (cmd, p) in [("graph.design", json!({"new": "Bar"})), ("brush.new", json!({"type": "art", "name": "Holder"}))] {
let id = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, cmd, p);
run(&mut s, "edit.clear", json!({"ids": [id.0]}));
}
let both = |fill: &str| vec![format!("design: {fill}"), format!("brush art: {fill}")];
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Tone"));
let undo = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
assert_eq!(r["relinked"], 2, "{r}");
assert_eq!(fills_outside_the_layers(&s), both("#0000ff, Tone"));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Tone"));
run(&mut s, "swatch.edit", json!({"name": "Tone", "newName": "Hue"}));
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Hue"));
// Default Swatches with replace drops the links to the swatches it removes.
run(&mut s, "swatch.resetDefaults", json!({"replace": true}));
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, -"));
run(&mut s, "edit.undo", json!({}));
let r = run(&mut s, "swatch.delete", json!({"name": "Hue"}));
assert_eq!(r["unlinked"], 2, "{r}");
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, -"));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 2);
}
/// A global swatch edited while a pattern is being edited also changes the tile that pattern
/// editing's Cancel puts back.
#[test]
fn global_swatch_edits_reach_the_tile_that_pattern_cancel_puts_back() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
let id = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let pattern = run(&mut s, "object.pattern.make", json!({"edit": false}))["name"].as_str().unwrap().to_string();
run(&mut s, "object.pattern.edit", json!({"name": pattern}));
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
run(&mut s, "object.pattern.cancel", json!({}));
let mut fills = vec![];
for n in &doc(&s).pattern(&pattern).unwrap().art {
n.walk(&mut |m| fills.push(m.appearance.fill_paint()));
}
assert_eq!(fills, [Paint::Solid { color: Color::rgb(0.0, 0.0, 1.0), swatch: Some("Tone".into()), tint: 1.0 }]);
}