From d9328c7668757c7921eebe9c72c189fb83e4e63f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 15:31:19 +0200 Subject: [PATCH] Select > Same: type matches on its characters' fill and stroke (fixes #1737) --- crates/engine/src/cmd/select.rs | 17 +++++++++---- crates/engine/src/cmd/wand.rs | 6 +++-- crates/engine/src/tests_menucmds.rs | 37 +++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 575c8cfa..c456e9a5 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use super::wand::{fill_of, stroke_of}; use super::*; pub fn specs() -> Vec { @@ -97,7 +98,7 @@ pub fn specs() -> Vec { has_selection, |s, _| { let tint = s.prefs.select_same_tint_percent; - same(s, "select.same.fillColor", |a, b| same_paint(&a.appearance.fill_paint(), &b.appearance.fill_paint(), tint)) + same(s, "select.same.fillColor", |a, b| same_paint(&fill_of(a), &fill_of(b), tint)) } ), cmd!( @@ -109,13 +110,13 @@ pub fn specs() -> Vec { has_selection, |s, _| { let tint = s.prefs.select_same_tint_percent; - same(s, "select.same.strokeColor", |a, b| same_paint(&a.appearance.stroke_paint(), &b.appearance.stroke_paint(), tint)) + same(s, "select.same.strokeColor", |a, b| same_paint(&stroke_of(a).0, &stroke_of(b).0, tint)) } ), cmd!("select.same.strokeWeight", "Stroke Weight", ["Select", "Same"], None, "{}", has_selection, |s, _| same( s, "select.same.strokeWeight", - |a, b| (a.appearance.stroke_width() - b.appearance.stroke_width()).abs() < 1e-9 + |a, b| (stroke_weight(a) - stroke_weight(b)).abs() < 1e-9 )), cmd!( "select.same.fillAndStroke", @@ -127,8 +128,7 @@ pub fn specs() -> Vec { |s, _| { let tint = s.prefs.select_same_tint_percent; same(s, "select.same.fillAndStroke", |a, b| { - let (x, y) = (&a.appearance, &b.appearance); - same_paint(&x.fill_paint(), &y.fill_paint(), tint) && same_paint(&x.stroke_paint(), &y.stroke_paint(), tint) + same_paint(&fill_of(a), &fill_of(b), tint) && same_paint(&stroke_of(a).0, &stroke_of(b).0, tint) }) } ), @@ -514,6 +514,13 @@ fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool { } } +/// Select › Same › Stroke Weight: an unpainted stroke weighs nothing, as +/// [`vectorcraft_doc::Appearance::stroke_width`] has it. +fn stroke_weight(n: &Node) -> f64 { + let (paint, width) = stroke_of(n); + if paint.is_none() { 0.0 } else { width } +} + 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"))?; diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index fd843800..a2d7613a 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -86,14 +86,16 @@ fn set(s: &mut Session, p: &Value) -> Result { Ok(json!(s.menu.wand)) } -fn fill_of(n: &Node) -> Paint { +/// The fill an object shows: type's comes from its characters (the first run speaks for all). +pub(crate) fn fill_of(n: &Node) -> Paint { match &n.kind { NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_default(), _ => n.appearance.fill_paint(), } } -fn stroke_of(n: &Node) -> (Paint, f64) { +/// The stroke paint and weight an object shows, type's from its first run as [`fill_of`]. +pub(crate) fn stroke_of(n: &Node) -> (Paint, f64) { match &n.kind { NodeKind::Text(t) => t.runs.first().map(|r| (r.style.stroke.clone(), r.style.stroke_width)).unwrap_or_default(), _ => (n.appearance.stroke_paint(), n.appearance.stroke_width()), diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 95fa1567..328498b8 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -766,6 +766,43 @@ fn select_same_font_size_and_fill() { assert!(s.execute("select.same.fontSize", &json!({})).is_err()); } +/// #1737: Select > Same reads type's fill and stroke from its characters (the first run), so type +/// matches type and paths both ways. +#[test] +fn select_same_paint_reads_type_characters() { + let mut s = session(); + let t1 = text(&mut s, "a"); + let t2 = text(&mut s, "b"); + let t3 = text(&mut s, "c"); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let q = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + sel(&mut s, &[t1, t2, r]); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + sel(&mut s, &[t3, q]); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + sel(&mut s, &[t1, r]); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 3})).unwrap(); + sel(&mut s, &[t3]); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + let q_stroke = node(&s, q).appearance; + assert_eq!((q_stroke.stroke_paint(), q_stroke.stroke_width()), (Paint::solid(Color::BLACK), 1.0)); + + 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 + }; + assert_eq!(same(&mut s, t1, "select.same.fillColor"), vec![t1, t2, r]); + assert_eq!(same(&mut s, q, "select.same.fillColor"), vec![t3, q]); + assert_eq!(same(&mut s, r, "select.same.strokeColor"), vec![t1, r]); + assert_eq!(same(&mut s, t3, "select.same.strokeColor"), vec![t3, q]); + assert_eq!(same(&mut s, q, "select.same.strokeWeight"), vec![t3, q]); + assert_eq!(same(&mut s, t1, "select.same.fillAndStroke"), vec![t1, r]); +} + #[test] fn select_direction_handles_selects_all_anchors() { let mut s = session();