From 8a0b7969455972f0af377e9de2c16a1afd99df17 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 23:05:23 -0700 Subject: [PATCH 1/6] Recolor: report the locked and hidden art a recolor of everything left out When the selection is exactly what select.all takes, recolor.apply and edit.colors.invert, toCMYK, toGrayscale, toRGB, saturate and adjustBalance now count the objects select.all left out whose colors they would have changed: locked or hidden objects, the objects of locked or hidden layers and sublayers, and objects on locked artboards (counted as locked). In isolation mode the count covers the isolated group's members. An object both hidden and locked counts as hidden. Template layers' art is not counted. Locked and hidden members of a selected group are recolored as before and are not counted. With any other selection, or with ids, both counts are 0. Lock and visibility work as before. The reply carries notChanged: {locked, hidden}, and a warning when a count is above 0, which the app shows in the status bar, for example "Recolor completed; 3 locked objects and 1 hidden object were left unchanged". A trial pass over the left-out objects finds which ones would change. In recolor.apply, an object that the group rewrite then changes is not counted. The message has rows in the 20 catalogs that translate messages. docs/mcp.md describes the counts. --- crates/engine/src/cmd/colorcmds.rs | 126 +++++++++++++++++++++++--- crates/engine/src/cmd/recolor.rs | 23 ++++- crates/engine/src/tests/editcolors.rs | 44 +++++++++ crates/engine/src/tests/recolor.rs | 72 +++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 7 ++ crates/ui-egui/src/i18n/de.tsv | 7 ++ crates/ui-egui/src/i18n/el.tsv | 7 ++ crates/ui-egui/src/i18n/es.tsv | 7 ++ crates/ui-egui/src/i18n/fi.tsv | 7 ++ crates/ui-egui/src/i18n/fr.tsv | 7 ++ crates/ui-egui/src/i18n/hu.tsv | 7 ++ crates/ui-egui/src/i18n/it.tsv | 7 ++ crates/ui-egui/src/i18n/ja.tsv | 7 ++ crates/ui-egui/src/i18n/ka.tsv | 7 ++ crates/ui-egui/src/i18n/ko.tsv | 7 ++ crates/ui-egui/src/i18n/pl.tsv | 7 ++ crates/ui-egui/src/i18n/pt-br.tsv | 7 ++ crates/ui-egui/src/i18n/ru.tsv | 7 ++ crates/ui-egui/src/i18n/sv.tsv | 7 ++ crates/ui-egui/src/i18n/tests.rs | 21 +++++ crates/ui-egui/src/i18n/tr.tsv | 7 ++ crates/ui-egui/src/i18n/uk.tsv | 7 ++ crates/ui-egui/src/i18n/vi.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 7 ++ docs/mcp.md | 9 ++ 26 files changed, 419 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 49b729c8a..f068b41b5 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "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), embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}", + "{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), embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {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, "Invert Colors", &invert) ), @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Convert to CMYK", ["Edit", "Edit Colors"], None, - "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}", has_selection, |s, p| recolor(s, p, "Convert to CMYK", &to_cmyk) ), @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Convert to Grayscale", ["Edit", "Edit Colors"], None, - "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}", has_selection, |s, p| recolor(s, p, "Convert to Grayscale", &to_gray) ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Convert to RGB", ["Edit", "Edit Colors"], None, - "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}", has_selection, |s, p| { recolor(s, p, "Convert to RGB", &|c| { @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Saturate…", ["Edit", "Edit Colors"], None, - "{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}", + "{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed, notChanged, warning?} (as edit.colors.invert)", has_selection, saturate ), @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "Adjust Color Balance…", ["Edit", "Edit Colors"], None, - "{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}", + "{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed, notChanged, warning?} (as edit.colors.invert)", has_selection, adjust_balance ), @@ -213,6 +213,17 @@ impl<'a> Recolor<'a> { Self { links: Some(links), ..Self::new(scope, f) } } + /// A pass that maps colors as this one does, with nothing recolored yet. + fn again(&self) -> Self { + Self { links: self.links, ..Self::new(self.scope, self.f) } + } + + /// The entries of `objects` (ids, each with a flag) whose object this pass would change. The + /// document stays as it is. + pub(crate) fn would_change(mut self, d: &Document, objects: &[(NodeId, T)]) -> Vec<(NodeId, T)> { + objects.iter().copied().filter(|(id, _)| d.node(*id).is_some_and(|n| self.subtree(d, n).is_some())).collect() + } + /// Recolour the objects `ids` and everything inside them (each once), add the new images and /// pattern swatches, and return the number of paints, meshes and images changed. pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize { @@ -335,14 +346,105 @@ pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) run_pass(s, p, label, Recolor::new(Scope::of(p), f)) } -/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step. +/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step. 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, label: &str, pass: Recolor) -> Result { - let ids = match ids_param(p, "ids") { - Some(v) => v, - None => selected_roots(s)?, + let (ids, left) = match ids_param(p, "ids") { + Some(v) => (v, vec![]), + None => { + let roots = selected_roots(s)?; + let st = s.doc()?; + let left = left_out(&st.doc, st.isolation, &roots); + (roots, left) + } }; - let n = s.edit(label, |d, _| Ok(pass.run(d, &ids)))?; - Ok(json!({ "changed": n })) + let (n, not_changed) = s.edit(label, |d, _| { + let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden)); + Ok((pass.run(d, &ids), not_changed)) + })?; + let mut out = json!({ "changed": n }); + not_changed.report(&mut out); + Ok(out) +} + +/// The objects a recolor of `roots` leaves out of the art the user meant to recolor, each with +/// whether it is hidden (otherwise it is locked). When `roots` are exactly the objects Select All +/// takes (in isolation mode, those of the isolated group), these are the objects beside them that +/// Select All skips: hidden or locked objects, the objects of hidden or locked layers and +/// sublayers, and, outside isolation mode, objects on locked artboards (counted as locked). The art +/// of template layers and the objects inside a group are not listed. With any other `roots`, none. +pub(crate) fn left_out(d: &Document, iso: Option, roots: &[NodeId]) -> Vec<(NodeId, bool)> { + fn visit(n: &Node, hidden: bool, locked: bool, all: &HashSet, board_locked: &dyn Fn(&Node) -> bool, out: &mut Vec<(NodeId, bool)>) { + for c in n.children().into_iter().flatten() { + if c.is_template() { + continue; + } + let (hidden, locked) = (hidden || !c.visible, locked || c.locked); + if c.is_layer() { + visit(c, hidden, locked, all, board_locked, out); + } else if !all.contains(&c.id) && (hidden || locked || board_locked(c)) { + out.push((c.id, hidden)); + } + } + } + let all: HashSet = super::select::selectable(d, iso).into_iter().collect(); + let picked: HashSet = roots.iter().copied().collect(); + if picked.is_empty() || picked != all { + return vec![]; + } + let mut out = vec![]; + match iso.and_then(|i| d.node(i)) { + Some(c) => visit(c, false, false, &all, &|_| false, &mut out), + None => { + let boards = d.locked_artboards(); + let board_locked = |n: &Node| !boards.is_empty() && boards.holds(n); + for l in d.layers.iter().filter(|l| !l.is_template()) { + visit(l, !l.visible, l.locked, &all, &board_locked, &mut out); + } + } + } + out +} + +/// How many locked and hidden objects a recolor left out ([`left_out`]) although it would have +/// changed their colors. An object both hidden and locked counts as hidden. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct NotChanged { + pub locked: usize, + pub hidden: usize, +} + +impl NotChanged { + /// The counts of objects given by whether each is hidden. + pub(crate) fn of(hidden: impl IntoIterator) -> Self { + hidden.into_iter().fold(Self::default(), |n, h| if h { Self { hidden: n.hidden + 1, ..n } } else { Self { locked: n.locked + 1, ..n } }) + } + + /// Add `notChanged` to reply `out`, and a `warning`, which the app shows in its status bar, + /// when any object is counted. + pub(crate) fn report(self, out: &mut Value) { + out["notChanged"] = json!({ "locked": self.locked, "hidden": self.hidden }); + if let Some(w) = self.warning() { + out["warning"] = json!(w); + } + } + + fn warning(self) -> Option { + let part = |n: usize, kind: &str| match n { + 0 => None, + 1 => Some(format!("1 {kind} object")), + n => Some(format!("{n} {kind} objects")), + }; + match (part(self.locked, "locked"), part(self.hidden, "hidden")) { + (Some(locked), Some(hidden)) => Some(format!("Recolor completed; {locked} and {hidden} were left unchanged")), + (Some(objects), None) | (None, Some(objects)) if self.locked + self.hidden == 1 => { + Some(format!("Recolor completed; {objects} was left unchanged")) + } + (Some(objects), None) | (None, Some(objects)) => Some(format!("Recolor completed; {objects} were left unchanged")), + (None, None) => None, + } + } } fn saturate(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 8a4fbb09d..4a29afd4b 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -12,7 +12,7 @@ use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::swatches::{node_colors, node_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; -use super::colorcmds::{Recolor, Scope}; +use super::colorcmds::{NotChanged, Recolor, Scope, left_out}; use super::{CommandSpec, Result, Session, always, bad, bool_or, checked_ids_param, cmd, color_value, has_doc, index, str_param}; pub fn specs() -> Vec { @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "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, exclude?: false (the row's colours are kept)}] | {\"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} 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 → {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)}", + "{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, exclude?: false (the row's colours are kept)}] | {\"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} 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 → {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 ), @@ -419,6 +419,10 @@ fn apply(s: &mut Session, p: &Value) -> Result { let row_swatches: Vec> = rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect(); let ids = s.doc()?.selection.objects.clone(); + let left = { + let st = s.doc()?; + left_out(&st.doc, st.isolation, &super::edit::roots_of(&st.doc, ids.clone())) + }; let group = str_param(p, "group"); if ids.is_empty() && group.is_none() { return Err(bad(C, "select artwork to recolour, or give a colour `group`")); @@ -460,7 +464,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { None => rows.iter().filter_map(Row::new_color).collect(), }; let rename = str_param(p, "rename"); - let (changed, edit, added, converted) = s.edit(label, |d, _| { + let (changed, edit, added, converted, not_changed) = s.edit(label, |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![]; @@ -497,11 +501,22 @@ fn apply(s: &mut Session, p: &Value) -> Result { let map = ColorMap::new(&rows, method, own_model, &links, kept); let f = |c: Color| map.map(c); let lf = |c: &mut Color, link: &mut Option, tint: &mut f32| map.map_link(c, link, tint); + // The objects left out whose colors the pass would change, as they are now. The trial + // pass does not count towards the note on color models. + let before: Vec<(Node, bool)> = Recolor::with_links(scope, &f, &lf) + .would_change(d, &left) + .into_iter() + .filter_map(|(id, hidden)| Some((d.node(id)?.clone(), hidden))) + .collect(); + map.converted.set(false); let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; - Ok((changed, edit, added, map.converted.get())) + // Rewriting the group's swatches can change art linked to them, left out or not. + let not_changed = NotChanged::of(before.iter().filter(|(n, _)| d.node(n.id) == Some(n)).map(|(_, hidden)| *hidden)); + Ok((changed, edit, added, map.converted.get(), not_changed)) })?; let mut out = json!({ "changed": changed, "swatches": added }); + not_changed.report(&mut out); if let Some(e) = edit { e.defaults(s); out["relinked"] = json!(e.relinked()); diff --git a/crates/engine/src/tests/editcolors.rs b/crates/engine/src/tests/editcolors.rs index 511431c3c..39152576c 100644 --- a/crates/engine/src/tests/editcolors.rs +++ b/crates/engine/src/tests/editcolors.rs @@ -355,3 +355,47 @@ fn recolor_writes_a_new_pattern_swatch() { s.execute("edit.colors.invert", &json!({})).unwrap(); assert_eq!((fill_pattern(&s, r), tile_fill(&s, "Dots 2")), ("Dots 2".to_string(), "#00ffff".to_string())); } + +#[test] +fn edit_colors_of_everything_count_the_hidden_objects_left_out() { + let mut s = session(); + let shown = rect(&mut s, json!("#ff0000")); + let hidden = rect(&mut s, json!("#ff0000")); + // A hidden object with no paint would not change. + let unpainted = rect(&mut s, json!("#ff0000")); + s.execute("paint.setFill", &json!({"ids": [unpainted.0], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [unpainted.0], "none": true})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [hidden.0, unpainted.0], "visible": false})).unwrap(); + // A locked member of a selected group is recolored with it. + let member = rect(&mut s, json!("#ff0000")); + let locked_member = rect(&mut s, json!("#ff0000")); + s.execute("select.set", &json!({"ids": [member.0, locked_member.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [locked_member.0], "locked": true})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let r = s.execute("edit.colors.invert", &json!({})).unwrap(); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 1}), "{r}"); + assert_eq!(r["warning"], "Recolor completed; 1 hidden object was left unchanged"); + assert_eq!((fill_of(&s, shown).to_hex(), fill_of(&s, hidden).to_hex()), ("#00ffff".to_string(), "#ff0000".to_string())); + assert_eq!(fill_of(&s, locked_member).to_hex(), "#00ffff"); + // Objects given by `ids` are the whole scope. + let r = s.execute("edit.colors.toGrayscale", &json!({"ids": [shown.0]})).unwrap(); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); +} + +#[test] +fn edit_colors_in_isolation_mode_count_the_locked_members_left_out() { + let mut s = session(); + let a = rect(&mut s, json!("#ff0000")); + let b = rect(&mut s, json!("#ff0000")); + let c = rect(&mut s, json!("#ff0000")); + s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap(); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("layer.setProps", &json!({"ids": [b.0, c.0], "locked": true})).unwrap(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let r = s.execute("edit.colors.saturate", &json!({"intensity": -50})).unwrap(); + assert_eq!(r["notChanged"], json!({"locked": 2, "hidden": 0}), "{r}"); + assert_eq!(r["warning"], "Recolor completed; 2 locked objects were left unchanged"); +} diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index fb9959ded..b9b4377e1 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -572,6 +572,78 @@ fn model_param_must_be_a_string_when_given() { assert_eq!(fill(&s, art).to_hex(), "#1a1a1a", "nothing changed"); } +/// Sets the Layers panel's lock and visibility of `ids` (objects or layers). +fn set_props(s: &mut Session, ids: &[NodeId], props: Value) { + let mut p = props; + p["ids"] = json!(ids.iter().map(|i| i.0).collect::>()); + run(s, "layer.setProps", p); +} + +#[test] +fn recolor_of_everything_counts_the_locked_and_hidden_objects_it_left_out() { + let mut s = session(); + let open = rect(&mut s, json!("#ff0000")); + let locked = rect(&mut s, json!("#ff0000")); + let hidden = rect(&mut s, json!("#ff0000")); + let both = rect(&mut s, json!("#ff0000")); + // A locked object the map leaves as it is was not left out of anything. + let other = rect(&mut s, json!("#00ff00")); + set_props(&mut s, &[locked, other], json!({"locked": true})); + set_props(&mut s, &[hidden], json!({"visible": false})); + set_props(&mut s, &[both], json!({"visible": false, "locked": true})); + // The objects of a locked layer count as locked; a template layer's art is not counted. + let layer = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap()); + let on_locked_layer = rect(&mut s, json!("#ff0000")); + set_props(&mut s, &[layer], json!({"locked": true})); + let template = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap()); + rect(&mut s, json!("#ff0000")); + set_props(&mut s, &[template], json!({"template": true})); + select_all(&mut s); + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}})); + assert_eq!(r["notChanged"], json!({"locked": 2, "hidden": 2}), "{r}"); + assert_eq!(r["warning"], "Recolor completed; 2 locked objects and 2 hidden objects were left unchanged"); + assert_eq!(fill(&s, open).to_hex(), "#0000ff"); + for id in [locked, hidden, both, on_locked_layer] { + assert_eq!(fill(&s, id).to_hex(), "#ff0000", "locked and hidden art keeps its colors"); + } +} + +#[test] +fn locked_and_hidden_members_of_a_selected_group_are_recolored_and_not_counted() { + let mut s = session(); + let ids = [rect(&mut s, json!("#ff0000")), rect(&mut s, json!("#ff0000")), rect(&mut s, json!("#ff0000"))]; + select_all(&mut s); + run(&mut s, "object.group", json!({})); + set_props(&mut s, &ids[1..2], json!({"locked": true})); + set_props(&mut s, &ids[2..3], json!({"visible": false})); + let node = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(node(&s, ids[1]).locked && !node(&s, ids[2]).visible); + select_all(&mut s); + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}})); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + assert!(ids.iter().all(|id| fill(&s, *id).to_hex() == "#0000ff")); +} + +#[test] +fn recolor_of_part_of_the_art_counts_nothing_left_out() { + let mut s = session(); + let picked = rect(&mut s, json!("#ff0000")); + let locked = rect(&mut s, json!("#ff0000")); + rect(&mut s, json!("#ff0000")); + set_props(&mut s, &[locked], json!({"locked": true})); + run(&mut s, "select.set", json!({"ids": [picked.0]})); + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}})); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + // Everything selected, with nothing locked or hidden. + run(&mut s, "object.unlockAll", json!({})); + select_all(&mut s); + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}})); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); +} + #[test] fn document_model_converts_the_colours_of_a_group_too() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 668f75051..d0b0e390a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4565,6 +4565,13 @@ imageTrace Outline Obrys @msg select an image vyberte obraz @msg select an object in perspective vyberte objekt v perspektivě @msg select artwork (or a colour group) to recolor vyberte kresbu (nebo skupinu barev) k přebarvení +@msg Recolor completed; {locked} and {hidden} were left unchanged Přebarvení dokončeno; beze změny: {locked} a {hidden} +@msg Recolor completed; {objects} were left unchanged Přebarvení dokončeno; beze změny: {objects} +@msg Recolor completed; {objects} was left unchanged Přebarvení dokončeno; beze změny: {objects} +@msg 1 locked object zamčený objekt (1) +@msg {n} locked objects zamčené objekty ({n}) +@msg 1 hidden object skrytý objekt (1) +@msg {n} hidden objects skryté objekty ({n}) @msg select at least two objects vyberte alespoň dva objekty @msg exit isolation mode first nejprve ukončete izolovaný režim @msg select objects, not layers vyberte objekty, ne vrstvy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4b2f24c45..9331b28bc 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4184,6 +4184,13 @@ theme Appearance Erscheinungsbild @msg select an image wählen Sie ein Bild aus @msg select an object in perspective wählen Sie ein Objekt in der Perspektive aus @msg select artwork (or a colour group) to recolor wählen Sie eine Grafik (oder eine Farbgruppe) zum Neufärben aus +@msg Recolor completed; {locked} and {hidden} were left unchanged Neufärben abgeschlossen; unverändert: {locked} und {hidden} +@msg Recolor completed; {objects} were left unchanged Neufärben abgeschlossen; unverändert: {objects} +@msg Recolor completed; {objects} was left unchanged Neufärben abgeschlossen; unverändert: {objects} +@msg 1 locked object 1 gesperrtes Objekt +@msg {n} locked objects {n} gesperrte Objekte +@msg 1 hidden object 1 ausgeblendetes Objekt +@msg {n} hidden objects {n} ausgeblendete Objekte @msg select at least two objects wählen Sie mindestens zwei Objekte aus @msg exit isolation mode first beenden Sie zuerst den Isolationsmodus @msg select objects, not layers wählen Sie Objekte aus, keine Ebenen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0dcaa0931..e6d55a0c0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4221,6 +4221,13 @@ theme Appearance Εμφάνιση @msg select an image επιλέξτε εικόνα @msg select an object in perspective επιλέξτε αντικείμενο σε προοπτική @msg select artwork (or a colour group) to recolor επιλέξτε σχέδιο (ή ομάδα χρωμάτων) για επαναχρωματισμό +@msg Recolor completed; {locked} and {hidden} were left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {locked} και {hidden} +@msg Recolor completed; {objects} were left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {objects} +@msg Recolor completed; {objects} was left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {objects} +@msg 1 locked object 1 κλειδωμένο αντικείμενο +@msg {n} locked objects {n} κλειδωμένα αντικείμενα +@msg 1 hidden object 1 κρυφό αντικείμενο +@msg {n} hidden objects {n} κρυφά αντικείμενα @msg select at least two objects επιλέξτε τουλάχιστον δύο αντικείμενα @msg exit isolation mode first βγείτε πρώτα από τη λειτουργία απομόνωσης @msg select objects, not layers επιλέξτε αντικείμενα, όχι επίπεδα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cfe0019e7..3b21c68d2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4263,6 +4263,13 @@ axis Both Ambos @msg select an image selecciona una imagen @msg select an object in perspective selecciona un objeto en perspectiva @msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear +@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloreado completado; sin cambios: {locked} y {hidden} +@msg Recolor completed; {objects} were left unchanged Recoloreado completado; sin cambios: {objects} +@msg Recolor completed; {objects} was left unchanged Recoloreado completado; sin cambios: {objects} +@msg 1 locked object 1 objeto bloqueado +@msg {n} locked objects {n} objetos bloqueados +@msg 1 hidden object 1 objeto oculto +@msg {n} hidden objects {n} objetos ocultos @msg select at least two objects selecciona al menos dos objetos @msg exit isolation mode first sal primero del modo de aislamiento @msg select objects, not layers selecciona objetos, no capas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 353e6ace4..9fc70a1a3 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4472,6 +4472,13 @@ imageTrace Outline Ääriviiva @msg select an image valitse kuva @msg select an object in perspective valitse perspektiivissä oleva objekti @msg select artwork (or a colour group) to recolor valitse uudelleenväritettävä kuvitus (tai väriryhmä) +@msg Recolor completed; {locked} and {hidden} were left unchanged Uudelleenväritys valmis; muuttamatta jäi: {locked} ja {hidden} +@msg Recolor completed; {objects} were left unchanged Uudelleenväritys valmis; muuttamatta jäi: {objects} +@msg Recolor completed; {objects} was left unchanged Uudelleenväritys valmis; muuttamatta jäi: {objects} +@msg 1 locked object 1 lukittu objekti +@msg {n} locked objects {n} lukittua objektia +@msg 1 hidden object 1 piilotettu objekti +@msg {n} hidden objects {n} piilotettua objektia @msg select at least two objects valitse vähintään kaksi objektia @msg exit isolation mode first poistu ensin eristystilasta @msg select objects, not layers valitse objekteja, ei tasoja diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ef43c205e..7de87ccce 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4260,6 +4260,13 @@ axis Both Les deux @msg select an image sélectionnez une image @msg select an object in perspective sélectionnez un objet en perspective @msg select artwork (or a colour group) to recolor sélectionnez l’illustration (ou un groupe de couleurs) dont redéfinir les couleurs +@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloration terminée ; sans changement : {locked} et {hidden} +@msg Recolor completed; {objects} were left unchanged Recoloration terminée ; sans changement : {objects} +@msg Recolor completed; {objects} was left unchanged Recoloration terminée ; sans changement : {objects} +@msg 1 locked object 1 objet verrouillé +@msg {n} locked objects {n} objets verrouillés +@msg 1 hidden object 1 objet masqué +@msg {n} hidden objects {n} objets masqués @msg select at least two objects sélectionnez au moins deux objets @msg exit isolation mode first quittez d’abord le mode Isolation @msg select objects, not layers sélectionnez des objets, pas des calques diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9fd8704e2..7a56b0bd0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4973,6 +4973,13 @@ imageTrace Outline Körvonal @msg select an Image Trace object jelölj ki egy képvektorizálási objektumot @msg select an object in perspective jelölj ki egy perspektivikus objektumot @msg select artwork (or a colour group) to recolor jelölj ki átszínezendő grafikát (vagy színcsoportot) +@msg Recolor completed; {locked} and {hidden} were left unchanged Átszínezés kész; változatlan maradt: {locked} és {hidden} +@msg Recolor completed; {objects} were left unchanged Átszínezés kész; változatlan maradt: {objects} +@msg Recolor completed; {objects} was left unchanged Átszínezés kész; változatlan maradt: {objects} +@msg 1 locked object 1 zárolt objektum +@msg {n} locked objects {n} zárolt objektum +@msg 1 hidden object 1 rejtett objektum +@msg {n} hidden objects {n} rejtett objektum @msg select at least two objects jelölj ki legalább két objektumot @msg exit isolation mode first előbb lépj ki az elkülönítési módból @msg select objects, not layers objektumokat jelölj ki, rétegeket ne diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3e2c30079..39bbc0e9a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4303,6 +4303,13 @@ axis Both Entrambi @msg select an image seleziona un'immagine @msg select an object in perspective seleziona un oggetto in prospettiva @msg select artwork (or a colour group) to recolor seleziona la grafica (o un gruppo colori) da ricolorare +@msg Recolor completed; {locked} and {hidden} were left unchanged Ricolorazione completata; senza modifiche: {locked} e {hidden} +@msg Recolor completed; {objects} were left unchanged Ricolorazione completata; senza modifiche: {objects} +@msg Recolor completed; {objects} was left unchanged Ricolorazione completata; senza modifiche: {objects} +@msg 1 locked object 1 oggetto bloccato +@msg {n} locked objects {n} oggetti bloccati +@msg 1 hidden object 1 oggetto nascosto +@msg {n} hidden objects {n} oggetti nascosti @msg select at least two objects seleziona almeno due oggetti @msg exit isolation mode first esci prima dalla modalità isolamento @msg select objects, not layers seleziona oggetti, non livelli diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4166e0eb5..3b2aebb0f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4417,6 +4417,13 @@ swatch Bright Violet ビビッドバイオレット @msg print settings are not an object プリント設定がオブジェクト形式ではありません @msg no colour group `{g}` カラーグループ `{g}` が見つかりません @msg select artwork (or a colour group) to recolor 再配色するアートワーク (またはカラーグループ) を選択してください +@msg Recolor completed; {locked} and {hidden} were left unchanged 再配色が完了しました。変更されなかったもの: {locked}、{hidden} +@msg Recolor completed; {objects} were left unchanged 再配色が完了しました。変更されなかったもの: {objects} +@msg Recolor completed; {objects} was left unchanged 再配色が完了しました。変更されなかったもの: {objects} +@msg 1 locked object ロックされたオブジェクト 1 個 +@msg {n} locked objects ロックされたオブジェクト {n} 個 +@msg 1 hidden object 非表示のオブジェクト 1 個 +@msg {n} hidden objects 非表示のオブジェクト {n} 個 @msg Downloaded {_1} file(s) {_1} 個のファイルをダウンロードしました @msg Preview in Browser needs the desktop app (the web downloads files instead) 「ブラウザーでプレビュー」にはデスクトップアプリが必要です (Web 版では代わりにファイルをダウンロードします) @msg no file system here この環境にはファイルシステムがありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index dd782c070..04adf9cd5 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4026,6 +4026,13 @@ font-technology Variable ცვლადი @msg select an image მონიშნეთ გამოსახულება @msg select an object in perspective მონიშნეთ ობიექტი პერსპექტივაში @msg select artwork (or a colour group) to recolor მონიშნეთ ხელახლა შესაფერადებელი ნახატი (ან ფერთა ჯგუფი) +@msg Recolor completed; {locked} and {hidden} were left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {locked} და {hidden} +@msg Recolor completed; {objects} were left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {objects} +@msg Recolor completed; {objects} was left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {objects} +@msg 1 locked object 1 ჩაკეტილი ობიექტი +@msg {n} locked objects {n} ჩაკეტილი ობიექტი +@msg 1 hidden object 1 დამალული ობიექტი +@msg {n} hidden objects {n} დამალული ობიექტი @msg select at least two objects მონიშნეთ ორი ობიექტი მაინც @msg exit isolation mode first ჯერ გამოდით იზოლაციის რეჟიმიდან @msg select objects, not layers მონიშნეთ ობიექტები და არა შრეები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4659fc3c3..5cbcfba02 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4349,6 +4349,13 @@ theme Appearance 외관 @msg print settings are not an object 인쇄 설정이 개체가 아닙니다 @msg no colour group `{g}` 색상 그룹 `{g}`이(가) 없습니다 @msg select artwork (or a colour group) to recolor 리컬러할 아트웍(또는 색상 그룹)을 선택하세요 +@msg Recolor completed; {locked} and {hidden} were left unchanged 색상 재지정이 완료되었습니다. 변경되지 않음: {locked}, {hidden} +@msg Recolor completed; {objects} were left unchanged 색상 재지정이 완료되었습니다. 변경되지 않음: {objects} +@msg Recolor completed; {objects} was left unchanged 색상 재지정이 완료되었습니다. 변경되지 않음: {objects} +@msg 1 locked object 잠긴 개체 1개 +@msg {n} locked objects 잠긴 개체 {n}개 +@msg 1 hidden object 숨겨진 개체 1개 +@msg {n} hidden objects 숨겨진 개체 {n}개 @msg Downloaded {_1} file(s) 파일 {_1}개를 다운로드했습니다 @msg Preview in Browser needs the desktop app (the web downloads files instead) 브라우저에서 미리 보기는 데스크톱 앱이 필요합니다(웹에서는 파일을 다운로드합니다) @msg no file system here 여기에는 파일 시스템이 없습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1a458e992..f048ca3e7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4392,6 +4392,13 @@ imageTrace Outline Kontur @msg select an image zaznacz obraz @msg select an object in perspective zaznacz obiekt w perspektywie @msg select artwork (or a colour group) to recolor zaznacz kompozycję (lub grupę kolorów), aby zmienić kolory +@msg Recolor completed; {locked} and {hidden} were left unchanged Zmiana kolorów zakończona; bez zmian: {locked} i {hidden} +@msg Recolor completed; {objects} were left unchanged Zmiana kolorów zakończona; bez zmian: {objects} +@msg Recolor completed; {objects} was left unchanged Zmiana kolorów zakończona; bez zmian: {objects} +@msg 1 locked object zablokowany obiekt (1) +@msg {n} locked objects zablokowane obiekty ({n}) +@msg 1 hidden object ukryty obiekt (1) +@msg {n} hidden objects ukryte obiekty ({n}) @msg select at least two objects zaznacz co najmniej dwa obiekty @msg exit isolation mode first najpierw wyjdź z trybu izolacji @msg select objects, not layers zaznacz obiekty, nie warstwy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a3561648f..5598a10ac 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3835,6 +3835,13 @@ axis Both Ambos @msg select an image selecione uma imagem @msg select an object in perspective selecione um objeto em perspectiva @msg select artwork (or a colour group) to recolor selecione uma arte-final (ou um grupo de cores) para recolorir +@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloração concluída; sem alteração: {locked} e {hidden} +@msg Recolor completed; {objects} were left unchanged Recoloração concluída; sem alteração: {objects} +@msg Recolor completed; {objects} was left unchanged Recoloração concluída; sem alteração: {objects} +@msg 1 locked object 1 objeto bloqueado +@msg {n} locked objects {n} objetos bloqueados +@msg 1 hidden object 1 objeto oculto +@msg {n} hidden objects {n} objetos ocultos @msg select at least two objects selecione pelo menos dois objetos @msg exit isolation mode first saia do modo de isolamento primeiro @msg select objects, not layers selecione objetos, não camadas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5ad5ae6fe..d012e5962 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4299,6 +4299,13 @@ axis Both Оба @msg select an image выделите изображение @msg select an object in perspective выделите объект в перспективе @msg select artwork (or a colour group) to recolor выделите рисунок (или цветовую группу) для перекрашивания +@msg Recolor completed; {locked} and {hidden} were left unchanged Перекрашивание завершено; без изменений: {locked} и {hidden} +@msg Recolor completed; {objects} were left unchanged Перекрашивание завершено; без изменений: {objects} +@msg Recolor completed; {objects} was left unchanged Перекрашивание завершено; без изменений: {objects} +@msg 1 locked object заблокированный объект (1) +@msg {n} locked objects заблокированные объекты ({n}) +@msg 1 hidden object скрытый объект (1) +@msg {n} hidden objects скрытые объекты ({n}) @msg select at least two objects выделите как минимум два объекта @msg exit isolation mode first сначала выйдите из режима изоляции @msg select objects, not layers выделите объекты, а не слои diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 355b52783..c05e05557 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4172,6 +4172,13 @@ axis Both Båda @msg select an image markera en bild @msg select an object in perspective markera ett objekt i perspektiv @msg select artwork (or a colour group) to recolor markera grafik (eller en färggrupp) att färga om +@msg Recolor completed; {locked} and {hidden} were left unchanged Omfärgning klar; utan ändring: {locked} och {hidden} +@msg Recolor completed; {objects} were left unchanged Omfärgning klar; utan ändring: {objects} +@msg Recolor completed; {objects} was left unchanged Omfärgning klar; utan ändring: {objects} +@msg 1 locked object 1 låst objekt +@msg {n} locked objects {n} låsta objekt +@msg 1 hidden object 1 dolt objekt +@msg {n} hidden objects {n} dolda objekt @msg select at least two objects markera minst två objekt @msg exit isolation mode first avsluta isoleringsläget först @msg select objects, not layers markera objekt, inte lager diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index a92562e7c..fb3e168b5 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1431,6 +1431,27 @@ fn messages_translate_by_template_and_their_parts() { assert_eq!(message(Lang::EN, nested), nested); } +/// The note `recolor.apply` and the Edit Colors commands give when they leave out locked or hidden +/// objects is translated whole, its counts included, in every complete-message language. +#[test] +fn recolor_notes_on_objects_left_unchanged_translate_with_their_counts() { + let notes = [ + ("Recolor completed; 2 locked objects and 1 hidden object were left unchanged", ["2", "1"]), + ("Recolor completed; 1 locked object and 3 hidden objects were left unchanged", ["1", "3"]), + ("Recolor completed; 1 locked object was left unchanged", ["1", "1"]), + ("Recolor completed; 5 hidden objects were left unchanged", ["5", "5"]), + ]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for (note, counts) in notes { + let shown = message(lang, note); + let english = ["completed", "locked", "hidden", "object", "unchanged"]; + assert!(english.iter().all(|w| !shown.contains(w)), "{code}: {shown}"); + assert!(counts.iter().all(|n| shown.contains(n)), "{code}: {shown}"); + } + } +} + #[test] fn package_status_message_templates_parse() { let c = Catalog::parse(concat!( diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 50c3e5f4c..7bc0eaf6d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4028,6 +4028,13 @@ axis Both Her İkisi @msg select an image bir resim seç @msg select an object in perspective perspektifte bir nesne seç @msg select artwork (or a colour group) to recolor yeniden renklendirilecek resmi (veya bir renk grubunu) seçin +@msg Recolor completed; {locked} and {hidden} were left unchanged Yeniden renklendirme tamamlandı; değiştirilmeden kalan: {locked} ve {hidden} +@msg Recolor completed; {objects} were left unchanged Yeniden renklendirme tamamlandı; değiştirilmeden kalan: {objects} +@msg Recolor completed; {objects} was left unchanged Yeniden renklendirme tamamlandı; değiştirilmeden kalan: {objects} +@msg 1 locked object 1 kilitli nesne +@msg {n} locked objects {n} kilitli nesne +@msg 1 hidden object 1 gizli nesne +@msg {n} hidden objects {n} gizli nesne @msg select at least two objects en az iki nesne seç @msg exit isolation mode first önce izolasyon modundan çık @msg select objects, not layers katmanları değil nesneleri seçin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ea16da598..aaeab9a06 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4287,6 +4287,13 @@ axis Both Обидві @msg select an image виділіть зображення @msg select an object in perspective виділіть об’єкт у перспективі @msg select artwork (or a colour group) to recolor виділіть малюнок (або колірну групу) для перефарбовування +@msg Recolor completed; {locked} and {hidden} were left unchanged Перефарбовування завершено; без змін: {locked} і {hidden} +@msg Recolor completed; {objects} were left unchanged Перефарбовування завершено; без змін: {objects} +@msg Recolor completed; {objects} was left unchanged Перефарбовування завершено; без змін: {objects} +@msg 1 locked object заблокований об’єкт (1) +@msg {n} locked objects заблоковані об’єкти ({n}) +@msg 1 hidden object прихований об’єкт (1) +@msg {n} hidden objects приховані об’єкти ({n}) @msg select at least two objects виділіть як мінімум два об’єкти @msg exit isolation mode first спочатку вийдіть з режиму ізоляції @msg select objects, not layers виділіть об’єкти, а не шари diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 10b8ea281..af6e5a15f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4625,6 +4625,13 @@ theme Appearance Giao diện @msg select an Image Trace object hãy chọn đối tượng vector hóa ảnh @msg select an object in perspective hãy chọn đối tượng trong phối cảnh @msg select artwork (or a colour group) to recolor hãy chọn tác phẩm (hoặc nhóm màu) để tô lại +@msg Recolor completed; {locked} and {hidden} were left unchanged Đã tô màu lại xong; không thay đổi: {locked} và {hidden} +@msg Recolor completed; {objects} were left unchanged Đã tô màu lại xong; không thay đổi: {objects} +@msg Recolor completed; {objects} was left unchanged Đã tô màu lại xong; không thay đổi: {objects} +@msg 1 locked object 1 đối tượng bị khóa +@msg {n} locked objects {n} đối tượng bị khóa +@msg 1 hidden object 1 đối tượng bị ẩn +@msg {n} hidden objects {n} đối tượng bị ẩn @msg select at least two objects hãy chọn ít nhất hai đối tượng @msg exit isolation mode first hãy thoát chế độ cô lập trước @msg select objects, not layers hãy chọn đối tượng, không phải lớp diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ac80bc510..10594947d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4145,6 +4145,13 @@ imageTrace Outline 轮廓 @msg print settings are not an object 打印设置不是对象 @msg no colour group `{g}` 没有颜色组 `{g}` @msg select artwork (or a colour group) to recolor 请选择要重新着色的图稿 (或颜色组) +@msg Recolor completed; {locked} and {hidden} were left unchanged 重新着色已完成;未更改:{locked}和{hidden} +@msg Recolor completed; {objects} were left unchanged 重新着色已完成;未更改:{objects} +@msg Recolor completed; {objects} was left unchanged 重新着色已完成;未更改:{objects} +@msg 1 locked object 1 个锁定的对象 +@msg {n} locked objects {n} 个锁定的对象 +@msg 1 hidden object 1 个隐藏的对象 +@msg {n} hidden objects {n} 个隐藏的对象 @msg Downloaded {_1} file(s) 已下载 {_1} 个文件 @msg Preview in Browser needs the desktop app (the web downloads files instead) “在浏览器中预览”需要桌面应用 (网页版会改为下载文件) @msg no file system here 此处没有文件系统 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f96254830..b75691a86 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2102,6 +2102,13 @@ file-filter Templates 範本 @msg print settings are not an object 列印設定不是物件 @msg no colour group `{g}` 沒有顏色群組 `{g}` @msg select artwork (or a colour group) to recolor 請選取要重新上色的圖稿 (或顏色群組) +@msg Recolor completed; {locked} and {hidden} were left unchanged 重新上色已完成;未變更:{locked}和{hidden} +@msg Recolor completed; {objects} were left unchanged 重新上色已完成;未變更:{objects} +@msg Recolor completed; {objects} was left unchanged 重新上色已完成;未變更:{objects} +@msg 1 locked object 1 個鎖定的物件 +@msg {n} locked objects {n} 個鎖定的物件 +@msg 1 hidden object 1 個隱藏的物件 +@msg {n} hidden objects {n} 個隱藏的物件 @msg Downloaded {_1} file(s) 已下載 {_1} 個檔案 @msg Preview in Browser needs the desktop app (the web downloads files instead) 「在瀏覽器中預覽」需要桌面應用程式 (網頁版會改為下載檔案) @msg no file system here 此處沒有檔案系統 diff --git a/docs/mcp.md b/docs/mcp.md index 6c33e8dc3..d7caff448 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1754,6 +1754,15 @@ patterns out. The Blend commands also grade gradient meshes, which keep their sh `intensity`, a number from -100 to 100 (a number outside the range counts as its nearest end). Without one, or with a value that is not a number, it answers `` missing number `intensity` `` and changes nothing. +The reply of `recolor.apply` and of every command above except the Blend commands has `notChanged: {locked, hidden}`. +When the selection is exactly what `select.all` takes (in isolation mode, what it takes inside the isolated group), it +counts the objects `select.all` left out whose colors the command would have changed: locked or hidden objects, the +objects of locked or hidden layers and sublayers, and objects on locked artboards (counted as locked). An object that +is both hidden and locked counts as hidden. Template layers' art is not counted. Locked and hidden objects inside a +selected group are recolored and not counted. With any other selection, or with `ids`, both counts are 0. When a count +is above 0 the reply also has a `warning`, which the app shows in the status bar, for example +`Recolor completed; 3 locked objects and 1 hidden object were left unchanged`. + ```json {"name":"run_command","arguments":{"command":"recolor.colors","params":{}}} {"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":{"#ff0000":"#0055ff"},"includeImages":false}}} From e11e7a39d9d7b512fe97ee843f3c3da0bb741d46 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:02:03 -0700 Subject: [PATCH 2/6] Recolor: count left-out art by the colors it shows A recolor of everything counted a locked or hidden object as left unchanged when the pass would only have linked it, or the tiles of a pattern it is filled with, to a swatch of the same color. With `group`, it also counted one as changed when the group's rewrite only removed its link to a dropped swatch, and as unchanged when the rewrite recolored a pattern it is filled with. The trial pass now leaves out changes to swatch links alone, patterns' tiles included, and the count compares the colors the object shows, its patterns' tiles included, before the recolor and after the rewrite. --- crates/engine/src/cmd/colorcmds.rs | 48 ++++++++++++++++-- crates/engine/src/cmd/recolor.rs | 37 +++++++++++--- crates/engine/src/tests/recolor.rs | 79 ++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index f068b41b5..3b0c3f788 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -218,10 +218,29 @@ impl<'a> Recolor<'a> { Self { links: self.links, ..Self::new(self.scope, self.f) } } - /// The entries of `objects` (ids, each with a flag) whose object this pass would change. The - /// document stays as it is. + /// The entries of `objects` (ids, each with a flag) whose object this pass would change, other + /// than by swatch links alone, its patterns' tiles included. The document stays as it is. pub(crate) fn would_change(mut self, d: &Document, objects: &[(NodeId, T)]) -> Vec<(NodeId, T)> { - objects.iter().copied().filter(|(id, _)| d.node(*id).is_some_and(|n| self.subtree(d, n).is_some())).collect() + let tried: Vec<(&Node, Node, T)> = + objects.iter().filter_map(|&(id, flag)| d.node(id).and_then(|n| Some((n, self.subtree(d, n)?, flag)))).collect(); + let same = self.link_only_copies(d); + tried.into_iter().filter_map(|(n, m, flag)| (without_links(m, &same) != without_links(n.clone(), &same)).then_some((n.id, flag))).collect() + } + + /// The patterns this pass has copied whose copy differs from the original only by swatch + /// links: the copy's name → the original's. + fn link_only_copies(&self, d: &Document) -> HashMap { + let none = HashMap::new(); + let art = |v: &[Arc]| v.iter().map(|n| without_links((**n).clone(), &none)).collect::>(); + let copies: HashMap<&str, &PatternDef> = self.new_patterns.iter().map(|p| (p.name.as_str(), p)).collect(); + self.patterns + .iter() + .filter_map(|(old, new)| { + let new = new.as_ref()?; + let copy = copies.get(new.as_str())?; + (art(&d.pattern(old)?.art) == art(©.art)).then(|| (new.clone(), old.clone())) + }) + .collect() } /// Recolour the objects `ids` and everything inside them (each once), add the new images and @@ -324,6 +343,29 @@ impl<'a> Recolor<'a> { } } +/// `n` with the swatch links of its paints and its children's removed, each color kept as it +/// shows, and each pattern named in `same` (a copy → its original) by its original's name. +fn without_links(mut n: Node, same: &HashMap) -> Node { + for p in vectorcraft_doc::swatches::node_paints_mut(&mut n) { + map_links(p, &|_, link, _| link.take().is_some()); + match p { + Paint::Gradient(g) => g.swatch = None, + Paint::Pattern { pattern, .. } => { + if let Some(original) = same.get(pattern.as_str()) { + *pattern = original.clone(); + } + } + _ => {} + } + } + if let Some(children) = n.children_mut() { + for c in children.iter_mut() { + *c = Arc::new(without_links((**c).clone(), same)); + } + } + n +} + /// What a pass over document `d` does to each colour the shared visitor meets. struct Visit<'p, 'a> { pass: &'p mut Recolor<'a>, diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 4a29afd4b..f5fdeb122 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -116,6 +116,27 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet>) -> used.into_iter().map(|(_, u)| u).collect() } +/// The colors `n` shows, in paint order ([`node_colors`]), then the colors of the tiles of the +/// patterns it uses as fills or strokes. +fn shown_colors(d: &Document, n: &Node) -> Vec { + let mut colors = vec![]; + node_colors(n, &mut |c, _| colors.push(*c)); + let mut patterns: Vec<&str> = vec![]; + n.walk(&mut |m| { + for p in node_paints(m) { + if let Paint::Pattern { pattern, .. } = p + && !patterns.contains(&pattern.as_str()) + { + patterns.push(pattern); + } + } + }); + for def in patterns.iter().filter_map(|p| d.pattern(p)) { + def.art.iter().for_each(|a| node_colors(a, &mut |c, _| colors.push(*c))); + } + colors +} + /// The objects whose colors `recolor.colors` and `recolor.reduce` read, for [`used_colors`]: `ids` /// (each checked) or else the selection, with everything they hold; with nothing selected, the /// objects Select All takes and the objects inside them, leaving out every hidden or locked object @@ -501,18 +522,22 @@ fn apply(s: &mut Session, p: &Value) -> Result { let map = ColorMap::new(&rows, method, own_model, &links, kept); let f = |c: Color| map.map(c); let lf = |c: &mut Color, link: &mut Option, tint: &mut f32| map.map_link(c, link, tint); - // The objects left out whose colors the pass would change, as they are now. The trial - // pass does not count towards the note on color models. - let before: Vec<(Node, bool)> = Recolor::with_links(scope, &f, &lf) + // The objects left out whose colors the pass would change, with the colors they show now. + // The trial pass does not count towards the note on color models. + let before: Vec<(NodeId, Vec, bool)> = Recolor::with_links(scope, &f, &lf) .would_change(d, &left) .into_iter() - .filter_map(|(id, hidden)| Some((d.node(id)?.clone(), hidden))) + .filter_map(|(id, hidden)| Some((id, shown_colors(d, d.node(id)?), hidden))) .collect(); map.converted.set(false); let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; - // Rewriting the group's swatches can change art linked to them, left out or not. - let not_changed = NotChanged::of(before.iter().filter(|(n, _)| d.node(n.id) == Some(n)).map(|(_, hidden)| *hidden)); + // Rewriting the group's swatches can change the colors of art linked to them, left out or + // not, and of the patterns it uses. Art that only loses its link keeps its colors and is + // counted. + 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)) })?; let mut out = json!({ "changed": changed, "swatches": added }); diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index b9b4377e1..8e9c8da46 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -608,6 +608,85 @@ fn recolor_of_everything_counts_the_locked_and_hidden_objects_it_left_out() { } } +#[test] +fn recolor_of_everything_counts_locked_art_that_only_loses_its_group_swatch_link() { + let mut s = session(); + run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]})); + let names: Vec = + s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect(); + for name in &names { + run(&mut s, "swatch.edit", json!({"name": name, "global": true})); + } + let open = rect(&mut s, json!("#000000")); + let locked = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [open.0, locked.0], "swatch": names[1]})); + set_props(&mut s, &[locked], json!({"locked": true})); + select_all(&mut s); + // The group is rewritten with one color, so its second swatch goes and the locked art only + // loses its link to it. + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#123456"]})); + assert_eq!(fill(&s, open).to_hex(), "#00ff00"); + assert_eq!(fill(&s, locked).to_hex(), "#ff0000", "the locked art keeps its color"); + assert_eq!(r["notChanged"], json!({"locked": 1, "hidden": 0}), "{r}"); +} + +#[test] +fn recolor_of_everything_does_not_count_locked_art_whose_pattern_the_group_rewrite_recolors() { + let mut s = session(); + run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]})); + let names: Vec = + s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect(); + for name in &names { + run(&mut s, "swatch.edit", json!({"name": name, "global": true})); + } + // "Dots": a tile linked to the group's second swatch. + let tile = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [tile.0], "swatch": names[1]})); + run(&mut s, "select.set", json!({"ids": [tile.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let tile_hex = |s: &Session| s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].appearance.fill_paint().color().unwrap().to_hex(); + assert_eq!(tile_hex(&s), "#ff0000"); + let locked = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [locked.0], "swatch": "Dots"})); + set_props(&mut s, &[locked], json!({"locked": true})); + let open = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [open.0], "swatch": names[1]})); + select_all(&mut s); + // The group's second swatch is rewritten with the row's new color, and the tile follows it. + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#2878f0", "#00ff00"]})); + assert_eq!(tile_hex(&s), "#00ff00", "{r}"); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); +} + +#[test] +fn recolor_of_everything_does_not_count_locked_art_that_would_only_take_a_swatch_link() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Red", "color": "#ff0000", "global": true})); + // "Dots": an unlinked red tile. + let tile = rect(&mut s, json!("#ff0000")); + run(&mut s, "select.set", json!({"ids": [tile.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let open = rect(&mut s, json!("#ff0000")); + let locked = rect(&mut s, json!("#ff0000")); + let dots = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [dots.0], "swatch": "Dots"})); + set_props(&mut s, &[locked, dots], json!({"locked": true})); + select_all(&mut s); + // The row keeps the color and snaps it to the document's global swatch of the same color, so + // the open art only takes the link. + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#ff0000"}, "limitTo": "document"})); + let link = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() { + vectorcraft_color::Paint::Solid { swatch, .. } => swatch, + p => panic!("{p:?}"), + }; + assert!(link(&s, open).is_some(), "{r}"); + assert_eq!(fill(&s, locked).to_hex(), "#ff0000"); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); +} + #[test] fn locked_and_hidden_members_of_a_selected_group_are_recolored_and_not_counted() { let mut s = session(); From 5a342685de8a2cdce0e0da982dcd8e259f68ee4c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 12:49:10 -0700 Subject: [PATCH 3/6] i18n: Catalan rows for the recolor notes on objects left unchanged Catalan joined the complete-message languages on main. The test now looks for the English words as whole words, so the Catalan "objecte" is not read as "object". --- crates/ui-egui/src/i18n/ca.tsv | 7 +++++++ crates/ui-egui/src/i18n/tests.rs | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0eb9ab2c4..b81f917c9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5326,6 +5326,13 @@ @msg select an object in perspective selecciona un objecte en perspectiva @msg select an Objects on Path group selecciona un grup d’objectes sobre un traçat @msg select artwork (or a colour group) to recolor selecciona la il·lustració (o un grup de colors) per recolorejar +@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloració completada; sense canvis: {locked} i {hidden} +@msg Recolor completed; {objects} were left unchanged Recoloració completada; sense canvis: {objects} +@msg Recolor completed; {objects} was left unchanged Recoloració completada; sense canvis: {objects} +@msg 1 locked object 1 objecte bloquejat +@msg {n} locked objects {n} objectes bloquejats +@msg 1 hidden object 1 objecte ocult +@msg {n} hidden objects {n} objectes ocults @msg select at least two objects selecciona almenys dos objectes @msg select objects, not layers selecciona objectes, no capes @msg select one object selecciona un objecte diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index f3beb1cc6..78c9eee25 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1495,8 +1495,9 @@ fn recolor_notes_on_objects_left_unchanged_translate_with_their_counts() { let lang = Lang::from_code(code).expect("registered"); for (note, counts) in notes { let shown = message(lang, note); - let english = ["completed", "locked", "hidden", "object", "unchanged"]; - assert!(english.iter().all(|w| !shown.contains(w)), "{code}: {shown}"); + // Whole words, so that a translation such as Catalan's "objecte" is not read as English. + let english = ["completed", "locked", "hidden", "object", "objects", "unchanged"]; + assert!(shown.split(|c: char| !c.is_alphabetic()).all(|w| !english.contains(&w)), "{code}: {shown}"); assert!(counts.iter().all(|n| shown.contains(n)), "{code}: {shown}"); } } From a90ae1e9a975087dea5471240c54f57c1fbf94cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 12:49:10 -0700 Subject: [PATCH 4/6] Recolor: count left-out art by the colors of nested pattern tiles too A left-out object whose pattern's tile uses another pattern showed the inner tile's colors without them being compared, so a group rewrite that recolored the inner tile still counted the object as left unchanged. The comparison now follows the patterns tiles use, each once. --- crates/engine/src/cmd/recolor.rs | 27 ++++++++++++++++-------- crates/engine/src/tests/recolor.rs | 33 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 0d07e2a2c..a3ce988bb 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -119,24 +119,35 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet>) -> } /// The colors `n` shows, in paint order ([`node_colors`]), then the colors of the tiles of the -/// patterns it uses as fills or strokes. -fn shown_colors(d: &Document, n: &Node) -> Vec { +/// patterns it uses as fills or strokes, and of the patterns those tiles use (each pattern once). +fn shown_colors<'a>(d: &'a Document, n: &'a Node) -> Vec { let mut colors = vec![]; node_colors(n, &mut |c, _| colors.push(*c)); let mut patterns: Vec<&str> = vec![]; + pattern_names(n, &mut patterns); + let mut next = 0; + while let Some(&name) = patterns.get(next) { + next += 1; + for a in d.pattern(name).map(|def| def.art.as_slice()).unwrap_or_default() { + node_colors(a, &mut |c, _| colors.push(*c)); + pattern_names(a, &mut patterns); + } + } + colors +} + +/// Adds the names of the patterns `n` and the objects inside it use as fills or strokes to `names`, +/// leaving out names already there. +fn pattern_names<'a>(n: &'a Node, names: &mut Vec<&'a str>) { n.walk(&mut |m| { for p in node_paints(m) { if let Paint::Pattern { pattern, .. } = p - && !patterns.contains(&pattern.as_str()) + && !names.contains(&pattern.as_str()) { - patterns.push(pattern); + names.push(pattern); } } }); - for def in patterns.iter().filter_map(|p| d.pattern(p)) { - def.art.iter().for_each(|a| node_colors(a, &mut |c, _| colors.push(*c))); - } - colors } /// The objects whose colors `recolor.colors` and `recolor.reduce` read, for [`used_colors`]: `ids` diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 17a30417e..72f8f2f64 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -659,6 +659,39 @@ fn recolor_of_everything_does_not_count_locked_art_whose_pattern_the_group_rewri assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); } +#[test] +fn recolor_of_everything_does_not_count_locked_art_whose_nested_pattern_the_group_rewrite_recolors() { + let mut s = session(); + run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]})); + let names: Vec = + s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect(); + for name in &names { + run(&mut s, "swatch.edit", json!({"name": name, "global": true})); + } + // "Dots": a tile linked to the group's second swatch; "Rings": a tile filled with "Dots". + let tile = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [tile.0], "swatch": names[1]})); + run(&mut s, "select.set", json!({"ids": [tile.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let outer = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [outer.0], "swatch": "Dots"})); + run(&mut s, "select.set", json!({"ids": [outer.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Rings", "width": 40, "height": 40})); + run(&mut s, "object.pattern.done", json!({})); + let tile_hex = |s: &Session| s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].appearance.fill_paint().color().unwrap().to_hex(); + let locked = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [locked.0], "swatch": "Rings"})); + set_props(&mut s, &[locked], json!({"locked": true})); + let open = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [open.0], "swatch": names[1]})); + select_all(&mut s); + // The rewrite recolors the inner tile, which the locked art shows through "Rings". + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#2878f0", "#00ff00"]})); + assert_eq!(tile_hex(&s), "#00ff00", "{r}"); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); +} + #[test] fn recolor_of_everything_does_not_count_locked_art_that_would_only_take_a_swatch_link() { let mut s = session(); From 8da340a7b9e2c39c1a1f4fbf0b41b06281e5f03f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 12:59:21 -0700 Subject: [PATCH 5/6] Recolor: a nested pattern copy that only takes swatch links is not a color change When a tile's inner pattern only took swatch links, the outer pattern's copy still named the inner copy, so the trial pass counted a locked object filled with the outer pattern as left unchanged. Copies are now compared with the link-only copies they use mapped back to their originals, repeated until no more copies qualify. --- crates/engine/src/cmd/colorcmds.rs | 26 +++++++++++++++++++------- crates/engine/src/tests/recolor.rs | 25 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 3b0c3f788..a7e42899a 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -228,19 +228,31 @@ impl<'a> Recolor<'a> { } /// The patterns this pass has copied whose copy differs from the original only by swatch - /// links: the copy's name → the original's. + /// links: the copy's name → the original's. A copy whose tile uses other copies counts when + /// those copies do. fn link_only_copies(&self, d: &Document) -> HashMap { - let none = HashMap::new(); - let art = |v: &[Arc]| v.iter().map(|n| without_links((**n).clone(), &none)).collect::>(); + let art = |v: &[Arc], same: &HashMap| v.iter().map(|n| without_links((**n).clone(), same)).collect::>(); let copies: HashMap<&str, &PatternDef> = self.new_patterns.iter().map(|p| (p.name.as_str(), p)).collect(); - self.patterns + let pairs: Vec<(&String, &String, &PatternDef, &PatternDef)> = self + .patterns .iter() .filter_map(|(old, new)| { let new = new.as_ref()?; - let copy = copies.get(new.as_str())?; - (art(&d.pattern(old)?.art) == art(©.art)).then(|| (new.clone(), old.clone())) + Some((new, old, d.pattern(old)?, *copies.get(new.as_str())?)) }) - .collect() + .collect(); + let mut same = HashMap::new(); + loop { + let found: Vec<(String, String)> = pairs + .iter() + .filter(|(new, _, original, copy)| !same.contains_key(*new) && art(&original.art, &same) == art(©.art, &same)) + .map(|(new, old, _, _)| ((*new).clone(), (*old).clone())) + .collect(); + if found.is_empty() { + return same; + } + same.extend(found); + } } /// Recolour the objects `ids` and everything inside them (each once), add the new images and diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 72f8f2f64..b4b22d151 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -720,6 +720,31 @@ fn recolor_of_everything_does_not_count_locked_art_that_would_only_take_a_swatch assert!(r.get("warning").is_none(), "{r}"); } +#[test] +fn recolor_of_everything_does_not_count_locked_art_whose_nested_pattern_would_only_take_a_swatch_link() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Red", "color": "#ff0000", "global": true})); + // "Dots": an unlinked red tile; "Rings": a tile filled with "Dots". + let tile = rect(&mut s, json!("#ff0000")); + run(&mut s, "select.set", json!({"ids": [tile.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let outer = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [outer.0], "swatch": "Dots"})); + run(&mut s, "select.set", json!({"ids": [outer.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Rings", "width": 40, "height": 40})); + run(&mut s, "object.pattern.done", json!({})); + rect(&mut s, json!("#ff0000")); + let rings = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [rings.0], "swatch": "Rings"})); + set_props(&mut s, &[rings], json!({"locked": true})); + select_all(&mut s); + // The inner tile would only take the link to the document's red swatch. + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#ff0000"}, "limitTo": "document"})); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); +} + #[test] fn locked_and_hidden_members_of_a_selected_group_are_recolored_and_not_counted() { let mut s = session(); From bdbf6cc57533cf0f00bf9b68863721dfca12b888 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 06:00:04 -0700 Subject: [PATCH 6/6] i18n: no-break spaces before ; and : in the French recolor notes --- crates/ui-egui/src/i18n/fr.tsv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1c6fd14a6..57705bdc0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4306,9 +4306,9 @@ axis Both Les deux @msg select an image sélectionnez une image @msg select an object in perspective sélectionnez un objet en perspective @msg select artwork (or a colour group) to recolor sélectionnez l’illustration (ou un groupe de couleurs) dont redéfinir les couleurs -@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloration terminée ; sans changement : {locked} et {hidden} -@msg Recolor completed; {objects} were left unchanged Recoloration terminée ; sans changement : {objects} -@msg Recolor completed; {objects} was left unchanged Recoloration terminée ; sans changement : {objects} +@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloration terminée ; sans changement : {locked} et {hidden} +@msg Recolor completed; {objects} were left unchanged Recoloration terminée ; sans changement : {objects} +@msg Recolor completed; {objects} was left unchanged Recoloration terminée ; sans changement : {objects} @msg 1 locked object 1 objet verrouillé @msg {n} locked objects {n} objets verrouillés @msg 1 hidden object 1 objet masqué