From a272d49ea7e9096f3ade0b56044cebf726c4f549 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 17:28:28 -0700 Subject: [PATCH 1/2] Select: Same commands for paint, appearance and shape select compound paths Select > Same > Fill Color, Stroke Color, Stroke Weight, Fill & Stroke, Opacity, Blending Mode, Appearance and Shape skipped containers, so they never selected a compound path, even when the compound path was the object selected first. With a member path of a compound path selected first, these commands and Same Graphic Style and Appearance Attribute compared the other objects with the member, which has no paint or style of its own. Graphic Style then reported that the selection had no graphic style, and Appearance Attribute that the object had no fill, stroke or effect. The eight commands now match a compound path on its own paint and attributes, as select.color does, and skip its member paths. When a member path is selected first, all ten commands compare the other objects with its compound path. In isolation mode inside a compound path, the member paths are matched as before. --- crates/engine/src/cmd/select.rs | 35 ++++++++++++++++++++--------- crates/engine/src/tests_menucmds.rs | 34 ++++++++++++++++++++++++++++ crates/engine/src/tests_styles.rs | 30 +++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 8a7d220e3..6d8d7c4a5 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -8,6 +8,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::wand::{fill_of, stroke_of}; use super::*; +use crate::DocState; pub fn specs() -> Vec { vec![ @@ -663,10 +664,27 @@ fn paint_reported(d: &Document, n: &Node) -> bool { compound(n) || (!n.is_container() && !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound)) } +/// The object Select > Same compares the others with, and whether isolation mode is inside a +/// compound path. The object is the first selected one, or its compound path when it is a member +/// of one; in isolation mode inside a compound path it is the member path itself. +fn same_reference<'a>(st: &'a DocState, cmd: &str) -> Result<(&'a Node, bool)> { + let compound = |n: &Node| matches!(n.kind, NodeKind::Compound { .. }); + let members = st.isolation.and_then(|i| st.doc.ancestry(i)).is_some_and(|a| a.iter().any(|i| st.doc.node(*i).is_some_and(compound))); + let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?; + let refn = match st.doc.parent_of(first.id).and_then(|p| st.doc.node(p)) { + Some(p) if !members && compound(p) => p, + _ => first, + }; + Ok((refn, members)) +} + +/// Select > Same: the objects `eq` matches with the object [`same_reference`] returns, among those +/// [`paint_reported`] accepts. A compound path matches as a whole. In isolation mode inside a +/// compound path, its member paths are matched instead. fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result { - let st = s.doc()?; - let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?; - select_where(s, cmd, &json!({}), |_, n| !n.is_container() && eq(n, &refn)) + let (refn, members) = same_reference(s.doc()?, cmd)?; + let refn = refn.clone(); + select_where(s, cmd, &json!({}), |d, n| (if members { !n.is_container() } else { paint_reported(d, n) }) && eq(n, &refn)) } /// Select the objects [`walk_selectable`] visits that `f` accepts (not looking inside accepted @@ -690,13 +708,8 @@ fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &No fn same_graphic_style(s: &mut Session, _: &Value) -> Result { let cmd = "select.same.graphicStyle"; let st = s.doc()?; - let id = st - .selection - .objects - .first() - .and_then(|id| super::style::linked_style(&st.doc, st.doc.node(*id)?)) - .map(|g| g.id) - .ok_or_else(|| bad(cmd, "the selection has no graphic style"))?; + let (refn, _) = same_reference(st, cmd)?; + let id = super::style::linked_style(&st.doc, refn).map(|g| g.id).ok_or_else(|| bad(cmd, "the selection has no graphic style"))?; select_where(s, cmd, &json!({}), |d, n| n.extras().graphic_style == Some(id) && super::style::linked_style(d, n).is_some()) } @@ -709,7 +722,7 @@ enum Attribute { fn same_attribute(s: &mut Session, p: &Value) -> Result { let cmd = "select.same.appearanceAttribute"; let st = s.doc()?; - let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?; + let (refn, _) = same_reference(st, cmd)?; let ap = &refn.appearance; let item = p.get("item").and_then(Value::as_u64).map(index).or(s.appearance_item()); let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) { diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index c0fb6de45..8e57771b0 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -1303,6 +1303,40 @@ fn select_same_paint_reads_type_characters() { assert_eq!(same(&mut s, t1, "select.same.fillAndStroke"), vec![t1, r]); } +/// Select > Same matches a compound path on its own paint, as a whole: never its member paths. +/// When a member path is selected first, the other objects are compared with its compound path. +#[test] +fn select_same_matches_compound_paths_on_their_own_paint() { + let mut s = session(); + let [a, b] = [0.0, 20.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0)); + let outer = rect(&mut s, 100.0, 0.0, 50.0, 50.0); + let inner = rect(&mut s, 110.0, 10.0, 20.0, 20.0); + let r = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + for id in [a, b, outer, inner] { + fill(&mut s, id, "#000000"); + } + fill(&mut s, r, "#ff0000"); + sel(&mut s, &[outer, inner]); + let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + let same = |s: &mut Session, from: NodeId, cmd: &str| { + sel(s, &[from]); + s.execute(cmd, &json!({})).unwrap(); + let mut v = selected(s); + v.sort(); + v + }; + for from in [a, c, outer] { + assert_eq!(same(&mut s, from, "select.same.fillColor"), vec![a, b, c], "from {from:?}"); + } + assert_eq!(same(&mut s, inner, "select.same.shapeType"), vec![c]); + fill(&mut s, c, "#ff0000"); + assert_eq!(same(&mut s, a, "select.same.fillColor"), vec![a, b]); + assert_eq!(same(&mut s, r, "select.same.fillColor"), vec![r, c]); + // Isolated in the compound path, its member paths are the objects to match. + s.execute("object.isolate", &json!({"id": c.0})).unwrap(); + assert_eq!(same(&mut s, outer, "select.same.shapeType"), vec![outer, inner]); +} + /// Select by Color needs no object to start from: it matches fills and strokes as /// `document.inspect` reports them, `#rrggbb`. #[test] diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests_styles.rs index 1b464d564..2a8a379c8 100644 --- a/crates/engine/src/tests_styles.rs +++ b/crates/engine/src/tests_styles.rs @@ -256,6 +256,36 @@ fn select_same_appearance_attribute() { assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); } +/// With a member path of a compound path selected first, Select > Same > Graphic Style and +/// Appearance Attribute compare the other objects with the compound path, except in isolation mode +/// inside it. +#[test] +fn select_same_style_and_attribute_from_a_compound_path_member() { + let mut s = session(); + let a = rect(&mut s, 10.0); + set_fill(&mut s, a, "#ff0000"); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + run(&mut s, "graphicStyle.new", json!({"name": "Rough"})); + run(&mut s, "graphicStyle.apply", json!({"name": "Rough"})); + let outer = rect(&mut s, 100.0); + let inner = rect(&mut s, 120.0); + run(&mut s, "select.set", json!({"ids": [outer.0, inner.0]})); + let c = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap()); + run(&mut s, "graphicStyle.apply", json!({"name": "Rough"})); + rect(&mut s, 300.0); + for cmd in ["select.same.graphicStyle", "select.same.appearanceAttribute"] { + for from in [c, outer] { + run(&mut s, "select.set", json!({"ids": [from.0]})); + run(&mut s, cmd, json!({})); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, c], "{cmd} from {from:?}"); + } + } + // Isolated in the compound path, its member paths are compared, and they carry no style. + run(&mut s, "object.isolate", json!({"id": c.0})); + run(&mut s, "select.set", json!({"ids": [outer.0]})); + assert!(s.execute("select.same.graphicStyle", &json!({})).is_err()); +} + #[test] fn document_info_lists_style_names() { let mut s = session(); From 500c84e2c77ad668ada3d7fc107223046ac3ae18 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 23:05:12 -0700 Subject: [PATCH 2/2] Select: Same command descriptions state the compound-path rule The params descriptions of Same Fill Color, Stroke Color, Fill & Stroke, Graphic Style and Appearance Attribute say that a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path. --- crates/engine/src/cmd/select.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 6d8d7c4a5..efea2f176 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -104,7 +104,7 @@ pub fn specs() -> Vec { "Fill Color", ["Select", "Same"], None, - "{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent)", + "{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent); a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path", has_selection, |s, _| { let tint = s.prefs.select_same_tint_percent; @@ -116,7 +116,7 @@ pub fn specs() -> Vec { "Stroke Color", ["Select", "Same"], None, - "{} the objects stroked with the first selected object's stroke colour (tints as select.same.fillColor)", + "{} the objects stroked with the first selected object's stroke colour (tints and compound paths as select.same.fillColor)", has_selection, |s, _| { let tint = s.prefs.select_same_tint_percent; @@ -133,7 +133,7 @@ pub fn specs() -> Vec { "Fill & Stroke", ["Select", "Same"], None, - "{} the objects with the first selected object's fill and stroke colours (tints as select.same.fillColor)", + "{} the objects with the first selected object's fill and stroke colours (tints and compound paths as select.same.fillColor)", has_selection, |s, _| { let tint = s.prefs.select_same_tint_percent; @@ -180,7 +180,7 @@ pub fn specs() -> Vec { "Graphic Style", ["Select", "Same"], None, - "{} the objects linked to the graphic style the first selected object is linked to", + "{} the objects linked to the graphic style the first selected object is linked to (compound paths as select.same.fillColor)", has_selection, same_graphic_style ), @@ -189,7 +189,7 @@ pub fn specs() -> Vec { "Appearance Attribute", ["Select", "Same"], None, - "{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill", + "{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill (compound paths as select.same.fillColor)", has_selection, same_attribute ),