diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 8a7d220e3..efea2f176 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![ @@ -103,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; @@ -115,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; @@ -132,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; @@ -179,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 ), @@ -188,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 ), @@ -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();