Edit Colors: no undo step when a recolor changes nothing

Invert Colors, the Convert to commands, Saturate, Adjust Color Balance,
the Blend commands and Recolor Artwork recorded an undo step even when
they changed no color, for example Invert Colors with fills and strokes
off, Saturate at 0 or a second Blend Front to Back. The empty step also
cleared the redo steps. paint.invert and paint.complement on whole
objects, Apply as Filter (addon.color.filter) and Apply Nearest Swatch
run the same pass and did the same.

These commands now run on a copy of the document and record the step
only when something changed. Convert to Indexed Color uses the same
helper. Recolor Artwork also compares the documents when it counted no
recolored paint, so rewriting a color group's swatches still records
its step.
This commit is contained in:
dotCooCoo
2026-11-13 15:29:04 -08:00
parent 4558196e16
commit 9594d39bd5
7 changed files with 108 additions and 42 deletions
+2 -2
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply as Filter",
["Object", "Plug-ins", "Color Effects"],
None,
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}",
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}",
has_selection,
filter
),
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
has_selection,
nearest::apply
),
+48 -30
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Invert Colors",
["Edit", "Edit Colors"],
None,
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
has_selection,
|s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert)
),
@@ -104,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Front to Back",
["Edit", "Edit Colors"],
None,
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md) → {changed}",
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md); no undo step when no fill changes → {changed}",
has_selection,
|s, _| blend(s, BlendOrder::Stack)
),
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Horizontally",
["Edit", "Edit Colors"],
None,
"{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost → {changed}",
"{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost; no undo step when no fill changes → {changed}",
has_selection,
|s, _| blend(s, BlendOrder::Horizontal)
),
@@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Vertically",
["Edit", "Edit Colors"],
None,
"{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost → {changed}",
"{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost; no undo step when no fill changes → {changed}",
has_selection,
|s, _| blend(s, BlendOrder::Vertical)
),
@@ -457,8 +457,8 @@ pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str,
run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p, cmd)?, f, links))
}
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`;
/// errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`,
/// or none when the pass changes nothing; errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the
/// selection left out ([`left_out`]) that the pass would have changed.
fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result<Value> {
let (ids, left) = match ids_param(p, "ids", cmd)? {
@@ -471,20 +471,47 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -
}
};
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let (n, not_changed) = s.edit(label, |d, _| {
let (n, not_changed) = edit_if_changed(s, label, |_, d| {
let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden));
let n = keeping_series_paint(d, &ids, |d| {
let n = pass.run(d, &ids);
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
Ok(n)
})?;
Ok((n, not_changed))
Ok(((n, not_changed), n > 0))
})?;
let mut out = json!({ "changed": n });
not_changed.report(&mut out);
Ok(out)
}
/// [`Session::edit`] for an Edit Colors pass, which may change nothing: `f` runs on a copy of the
/// document (it gets the document as it is too) and returns its result with whether it changed the
/// copy. Only a changed copy becomes the document, as one undo step named `label`; otherwise the
/// undo and redo steps and the saved state stay as they were.
pub(crate) fn edit_if_changed<T>(s: &mut Session, label: &str, f: impl FnOnce(&Document, &mut Document) -> Result<(T, bool)>) -> Result<T> {
if s.script_running() {
return Err(EngineError::Busy);
}
// A typing session ends here as `s.edit` would end it, before the copy is taken, so that the
// copy holds what was typed.
if !s.previewing && super::typing_in_progress(s) {
super::settle_interaction(s)?;
}
let before = s.doc()?.doc.clone();
let mut doc = (*before).clone();
let (v, changed) = f(&before, &mut doc)?;
// The document is shared with `before` no longer, so `s.edit` does not copy it again.
drop(before);
if changed {
s.edit(label, |d, _| {
*d = doc;
Ok(())
})?;
}
Ok(v)
}
/// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed
/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn
/// again.
@@ -597,11 +624,6 @@ fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result<Targets> {
fn to_indexed(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.colors.toIndexed";
let indexing = Indexing::of(s, p)?;
// A typing session ends here as `s.edit` would end it, before the copy is taken, so that the
// copy holds what was typed.
if !s.previewing && super::typing_in_progress(s) {
super::settle_interaction(s)?;
}
let remake = |b: &ImageBlob| indexing.convert(b);
let images = Scope { fill: false, stroke: false, images: true, patterns: false };
let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) };
@@ -609,22 +631,18 @@ fn to_indexed(s: &mut Session, p: &Value) -> Result<Value> {
// indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key.
let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![]));
let (ids, left) = pass_targets(s, p, C)?;
let st = s.doc()?;
let colorized_ok = super::paint::check_colorized(&st.doc).is_ok();
let not_changed = NotChanged::of(
Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden),
);
let mut doc = (*st.doc).clone();
let n = pass.run(&mut doc, &ids);
if let Some((w, h)) = indexing.too_large.get() {
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS)));
}
if n > 0 {
s.edit("Convert to Indexed Color", |d, _| {
*d = doc;
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))
})?;
}
let (n, not_changed) = edit_if_changed(s, "Convert to Indexed Color", |now, doc| {
let colorized_ok = super::paint::check_colorized(now).is_ok();
let not_changed = NotChanged::of(
Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(now, &left).into_iter().map(|(_, hidden)| hidden),
);
let n = pass.run(doc, &ids);
if let Some((w, h)) = indexing.too_large.get() {
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS)));
}
super::paint::check_colorized_kept(colorized_ok, doc).map_err(|e| bad(C, e))?;
Ok(((n, not_changed), n > 0))
})?;
let mut out = json!({ "changed": n });
not_changed.report(&mut out);
Ok(out)
@@ -923,7 +941,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?;
Ok(changed)
};
let changed = s.edit(label, |d, _| keeping_series_paint(d, &ids, run))?;
let changed = edit_if_changed(s, label, |_, d| keeping_series_paint(d, &ids, run).map(|n| (n, n > 0)))?;
Ok(json!({ "changed": changed }))
}
+2 -2
View File
@@ -88,7 +88,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Invert",
[],
None,
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md) → {changed}",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md); no undo step when no color changes → {changed}",
has_doc,
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
),
@@ -97,7 +97,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Complement",
[],
None,
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default → {changed}",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default; no undo step when no color changes → {changed}",
has_doc,
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
),
+7 -4
View File
@@ -15,7 +15,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Point, Rect};
use super::colorcmds::{NotChanged, Recolor, Scope, keeping_series_paint, left_out};
use super::colorcmds::{NotChanged, Recolor, Scope, edit_if_changed, keeping_series_paint, left_out};
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param};
pub fn specs() -> Vec<CommandSpec> {
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
has_doc,
apply
),
@@ -1661,7 +1661,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
};
let rename = text_param(p, "rename", C)?;
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let (changed, edit, added, converted, not_changed) = s.edit(label, |d, _| {
let (changed, edit, added, converted, not_changed) = edit_if_changed(s, label, |now, d| {
// The rows' library swatches join the document (one it has with that name and colour is
// used as it is).
let mut added = vec![];
@@ -1716,7 +1716,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let not_changed = NotChanged::of(
before.iter().filter(|(id, colors, _)| d.node(*id).is_some_and(|now| shown_colors(d, now) == *colors)).map(|(_, _, hidden)| *hidden),
);
Ok((changed, edit, added, map.converted.get(), not_changed))
// Rewriting the group's swatches and relinking its art are not counted in `changed`, so a
// pass that counts nothing compares the documents.
let any = changed > 0 || !added.is_empty() || *d != *now;
Ok(((changed, edit, added, map.converted.get(), not_changed), any))
})?;
let mut out = json!({ "changed": changed, "swatches": added });
not_changed.report(&mut out);
+42
View File
@@ -109,6 +109,48 @@ fn saturate_keeps_a_color_it_does_not_change() {
assert_eq!(paint(&s, b), cmyk);
}
/// An Edit Colors pass, a Blend or Recolor Artwork that changes nothing records no undo step, and the
/// step that redo would bring back stays; one that changes a color still records one.
#[test]
fn edit_colors_that_change_nothing_record_no_undo_step() {
let mut s = session();
let gray = rect(&mut s, json!("#808080"));
let a = rect(&mut s, json!("#ff0000"));
let b = rect(&mut s, json!("#808080"));
let c = rect(&mut s, json!("#0000ff"));
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
s.execute("edit.colors.blendFrontToBack", &json!({})).unwrap();
// A step for redo to bring back.
s.execute("edit.colors.invert", &json!({"ids": [a.0]})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let history = |s: &Session| {
let h = &s.doc().unwrap().history;
(h.undo.len(), h.redo.len())
};
let before = history(&s);
assert_eq!(before.1, 1, "one step to redo");
let doc = s.doc().unwrap().doc.clone();
let cases = [
("edit.colors.invert", json!({"fill": false, "stroke": false})),
("edit.colors.saturate", json!({"intensity": 0})),
("edit.colors.toRGB", json!({})),
("edit.colors.adjustBalance", json!({"mode": "rgb", "r": 0})),
("edit.colors.blendFrontToBack", json!({})),
("recolor.apply", json!({"map": {"#ff0000": "#ff0000"}})),
// The complement of a gray is the same gray.
("paint.complement", json!({"ids": [gray.0], "stroke": false})),
("addon.color.filter", json!({"effect": "addon.color.invert", "fill": false, "stroke": false})),
];
for (id, p) in cases {
let v = s.execute(id, &p).unwrap();
assert_eq!(v["changed"], 0, "{id}");
assert_eq!(history(&s), before, "{id}: no undo step, and redo is kept");
assert_eq!(s.doc().unwrap().doc, doc, "{id}: the document is unchanged");
}
s.execute("edit.colors.invert", &json!({})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0), "a pass that changes colors is one undo step");
}
/// Adjust Color Balance refuses a channel that is not a number, which it would otherwise read as 0.
#[test]
fn adjust_balance_refuses_a_channel_that_is_not_a_number() {
+2 -2
View File
@@ -1783,7 +1783,7 @@ mod tests {
/// The Color Theme Picker's picks (the `themePicks` field) give the rows that aren't excluded
/// the palette of the art picked, in row order, repeating it when the rows outnumber its
/// colors, and the art previews them. Picks that touch no art leave the rows as they are, and
/// OK keeps the recolor as one undo step.
/// OK with rows that give the art its own colors back records no undo step.
#[test]
fn theme_picks_give_the_rows_the_palette_of_the_art_picked() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
@@ -1822,7 +1822,7 @@ mod tests {
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "nothing is committed before OK");
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!((fill_hex(&app, red), fill_hex(&app, blue)), ("#ff0000".into(), "#0000ff".into()), "the excluded row kept red");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "the art is unchanged: no undo step");
}
const RED: &str = "rgb 255 0 0";
+5 -2
View File
@@ -3878,6 +3878,9 @@ For `edit.colors.*`, `fill: false` leaves colorized image fills unchanged. `reco
`edit.colors.toGrayscale` and `edit.colors.adjustBalance` with `mode: "gray"` and `convert: true` store an embedded image
with one gray channel, so a PDF writes it in gray.
`includePatterns: false` leaves patterns out. The Blend commands also grade gradient meshes, which keep their shading.
The `edit.colors.*` commands, including the Blend commands, and `recolor.apply`, `paint.invert`, `paint.complement`,
`addon.color.filter` and `addon.color.applyNearestSwatch` record no undo step when they change nothing, for example
`edit.colors.invert` with `fill: false` and `stroke: false`, or a Blend whose objects already have the graded fills.
`edit.colors.saturate` needs `intensity`, a number from -100 to 100 (a number outside the range counts as its nearest
end). Without one, or with `null`, it answers `` missing number `intensity` ``; with a value that is not a number, such as
`"40"`, it answers `` `intensity` must be a number, not "40" ``. Either way it changes nothing.
@@ -10985,7 +10988,7 @@ the other colour adjustments (Effect › Color Adjustments); each colour keeps i
`addon.color.filter {effect, params?, fill?, stroke?, includeImages?, includePatterns?, ids?}` applies any colour
adjustment, the add-on's or one of Effect › Color Adjustments', to the colours themselves instead of as a live effect:
fills, strokes, type, gradient meshes, embedded images and pattern tiles, as `edit.colors.invert` reaches them, as one
undo step. Missing `params` take the effect's dialog defaults; the objects' own effects stay as they are.
undo step (none when nothing changes). Missing `params` take the effect's dialog defaults; the objects' own effects stay as they are.
```json
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"addon.color.exposure","params":{"exposure":1.5}}}}
@@ -11152,7 +11155,7 @@ spot, deltaE}]}]}` (`values` as a default swatch name reads them: `C=10 M=20 Y=3
`addon.color.applyNearestSwatch {library?, fill?, stroke?, ids?}` (Object › Plug-ins › Color Effects › Find Nearest
Swatch…, whose dialog lists each colour's three closest swatches with their difference; Apply runs it) gives each solid
colour and gradient stop of the selected objects' fills and strokes (and gradient meshes) the colour of its nearest
swatch, as one undo step. A global or spot swatch's colour links to it. The library's swatches the colours take are
swatch, as one undo step (none when nothing changes). A global or spot swatch's colour links to it. The library's swatches the colours take are
added to the document's first, as `swatch.library.add` adds them, so a spot colour from a colour book stays spot;
images and patterns keep their colours. → `{changed, swatches: [the document swatches the colours took], added}`