From a652c57537e288d666f541923673d2f3a44866ae Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 17:27:48 +0200 Subject: [PATCH] MATCHPROP: source/destination picker and Settings (part of #439) (#449) Squash of PR #449 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/props.rs | 32 +- crates/engine/src/cmd/props/matchprop.rs | 400 +++++++++++++++++++++++ crates/engine/src/lib.rs | 3 + crates/engine/tests/matchprop.rs | 138 ++++++++ 5 files changed, 550 insertions(+), 24 deletions(-) create mode 100644 crates/engine/src/cmd/props/matchprop.rs create mode 100644 crates/engine/tests/matchprop.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1b10e44..0703d4e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -26,6 +26,7 @@ mod modify2; mod mtext_opts; mod pline_opts; mod props; +pub use props::MatchProps; mod qselect; mod selecttools; mod settings; diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 6e03b98..ac2552a 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -1,7 +1,10 @@ //! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units, RENAME. +mod matchprop; mod rename; +pub use matchprop::MatchProps; + use cadcraft_color::Color; use cadcraft_doc::{EntityKind, Lineweight, Transparency}; use cadcraft_geom::Vec3; @@ -14,7 +17,11 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(), CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, }"), - CommandSpec::new("matchprop", "Match Properties", run_matchprop).menu(&["Modify", "Match Properties"]).alias(&["ma", "painter"]).params("{source, targets: [hex]}"), + CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop) + .menu(&["Modify", "Match Properties"]) + .alias(&["ma", "painter"]) + .params("{source, targets?: [hex] (default: selection), properties?: [name] | {name: bool} (default: all; names: color, layer, linetype, ltscale, lineweight, transparency, thickness, plotStyle, dimension, text, hatch, polyline, viewport, table, multileader)} → {changed}") + .interactive(|_| Ok(Box::new(matchprop::MatchPropM::new()))), CommandSpec::new("color", "Color...", run_color).menu(&["Format", "Color..."]).alias(&["col", "colour"]).params("{color: \"ByLayer\" | \"red\" | 1..255 | \"r,g,b\"}"), CommandSpec::new("linetype", "Linetype...", run_linetype).menu(&["Format", "Linetype..."]).alias(&["lt", "ltype"]).params("{current?: name, load?: name | \"*\"}"), CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"), @@ -341,29 +348,6 @@ fn run_set(s: &mut Session, p: &Value) -> Result { Ok(json!({ "changed": hs.len() })) } -fn run_matchprop(s: &mut Session, p: &Value) -> Result { - let src = - p.get("source").and_then(Value::as_str).and_then(cadcraft_doc::Handle::parse_hex).ok_or_else(|| bad("matchprop", "`source` is required"))?; - let tg: Vec = p - .get("targets") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|v| v.as_str().and_then(cadcraft_doc::Handle::parse_hex)).collect()) - .unwrap_or_default(); - let d = s.doc_mut()?; - let c = d.entity(src).map(|e| e.common.clone()).ok_or_else(|| bad("matchprop", "no such source"))?; - for h in &tg { - d.modify_entity(*h, |e| { - e.common.layer = c.layer.clone(); - e.common.color = c.color; - e.common.linetype = c.linetype.clone(); - e.common.lineweight = c.lineweight; - e.common.ltscale = c.ltscale; - e.common.transparency = c.transparency; - })?; - } - Ok(json!({ "changed": tg.len() })) -} - fn run_color(s: &mut Session, p: &Value) -> Result { let c = p.get("color").and_then(parse_color).ok_or_else(|| bad("color", "`color` is required"))?; let d = s.doc_mut()?; diff --git a/crates/engine/src/cmd/props/matchprop.rs b/crates/engine/src/cmd/props/matchprop.rs new file mode 100644 index 0000000..a53f6a5 --- /dev/null +++ b/crates/engine/src/cmd/props/matchprop.rs @@ -0,0 +1,400 @@ +//! MATCHPROP (alias MA, PAINTER): copy properties from a source object to destination objects. +//! Typed: "Select source object", then "Select destination object(s) or [Settings]" until Enter; +//! Settings toggles which properties are copied (remembered for the next typed MATCHPROP). The +//! JSON form copies every property unless `properties` names some. + +use cadcraft_doc::{Entity, EntityKind, Handle}; +use serde_json::{Value, json}; + +use super::super::*; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +/// Which properties MATCHPROP copies (its Settings). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MatchProps { + pub color: bool, + pub layer: bool, + pub linetype: bool, + pub ltscale: bool, + pub lineweight: bool, + pub transparency: bool, + pub thickness: bool, + pub plot_style: bool, + /// Dimension style and overrides of dimensions; the style of leaders. + pub dimension: bool, + /// Text style, height, width factor and oblique angle of text and multiline text. + pub text: bool, + /// Pattern, scale, angle and gradient of hatches. + pub hatch: bool, + /// Global width and linetype generation of polylines. + pub polyline: bool, + /// Display locking and scale of layout viewports. + pub viewport: bool, + /// Table style of tables. + pub table: bool, + /// Style, arrowhead size, landing and leader type of multileaders. + pub multileader: bool, +} + +impl Default for MatchProps { + fn default() -> Self { + MatchProps::all(true) + } +} + +/// Keyword, settings name (JSON and the "Current active settings" echo), field. +type Field = fn(&mut MatchProps) -> &mut bool; +const FIELDS: &[(&str, &str, Field)] = &[ + ("Color", "color", |m| &mut m.color), + ("LAyer", "layer", |m| &mut m.layer), + ("LType", "linetype", |m| &mut m.linetype), + ("LTScale", "ltscale", |m| &mut m.ltscale), + ("LWeight", "lineweight", |m| &mut m.lineweight), + ("TRansparency", "transparency", |m| &mut m.transparency), + ("THickness", "thickness", |m| &mut m.thickness), + ("PLotstyle", "plotStyle", |m| &mut m.plot_style), + ("DImension", "dimension", |m| &mut m.dimension), + ("TExt", "text", |m| &mut m.text), + ("Hatch", "hatch", |m| &mut m.hatch), + ("POlyline", "polyline", |m| &mut m.polyline), + ("Viewport", "viewport", |m| &mut m.viewport), + ("TAble", "table", |m| &mut m.table), + ("Multileader", "multileader", |m| &mut m.multileader), +]; + +impl MatchProps { + pub fn all(on: bool) -> Self { + MatchProps { + color: on, + layer: on, + linetype: on, + ltscale: on, + lineweight: on, + transparency: on, + thickness: on, + plot_style: on, + dimension: on, + text: on, + hatch: on, + polyline: on, + viewport: on, + table: on, + multileader: on, + } + } + /// The settings that are on, by name. + pub fn names(&self) -> Vec<&'static str> { + let mut m = *self; + FIELDS.iter().filter(|(_, _, f)| *f(&mut m)).map(|(_, n, _)| *n).collect() + } + fn set(&mut self, name: &str, on: bool) -> bool { + match FIELDS.iter().find(|(k, n, _)| n.eq_ignore_ascii_case(name) || k.eq_ignore_ascii_case(name)) { + Some((_, _, f)) => { + *f(self) = on; + true + } + None => false, + } + } + fn toggle(&mut self, name: &str) -> bool { + match FIELDS.iter().find(|(k, n, _)| n.eq_ignore_ascii_case(name) || k.eq_ignore_ascii_case(name)) { + Some((_, _, f)) => { + let b = f(self); + *b = !*b; + true + } + None => false, + } + } + /// From JSON: a list of names to copy, or `{name: bool}` over the defaults (all on). + fn from_json(v: &Value) -> Result { + let unknown = + |n: &str| bad("matchprop", format!("unknown property `{n}` (known: {})", FIELDS.iter().map(|f| f.1).collect::>().join(", "))); + match v { + Value::Array(a) => { + let mut m = MatchProps::all(false); + for x in a { + let n = x.as_str().ok_or_else(|| bad("matchprop", format!("`properties` holds names, got {x}")))?; + if !m.set(n, true) { + return Err(unknown(n)); + } + } + Ok(m) + } + Value::Object(o) => { + let mut m = MatchProps::default(); + for (n, x) in o { + let on = x.as_bool().ok_or_else(|| bad("matchprop", format!("`properties.{n}` must be true or false")))?; + if !m.set(n, on) { + return Err(unknown(n)); + } + } + Ok(m) + } + other => Err(bad("matchprop", format!("`properties` must be a list of names or {{name: bool}}, got {other}"))), + } + } +} + +/// Copy the chosen properties of `src` onto `dst`. +fn copy_props(src: &Entity, dst: &mut Entity, m: &MatchProps) { + let (c, d) = (&src.common, &mut dst.common); + if m.color { + d.color = c.color; + } + if m.layer { + d.layer.clone_from(&c.layer); + } + if m.linetype { + d.linetype.clone_from(&c.linetype); + } + if m.ltscale { + d.ltscale = c.ltscale; + } + if m.lineweight { + d.lineweight = c.lineweight; + } + if m.transparency { + d.transparency = c.transparency; + } + if m.thickness { + d.thickness = c.thickness; + } + if m.plot_style { + d.plot_style.clone_from(&c.plot_style); + } + match (&src.kind, &mut dst.kind) { + (EntityKind::Dimension(a), EntityKind::Dimension(b)) if m.dimension => { + b.style.clone_from(&a.style); + b.overrides.clone_from(&a.overrides); + b.block = None; + } + (EntityKind::Leader(a), EntityKind::Leader(b)) if m.dimension => b.style.clone_from(&a.style), + (EntityKind::Text(a), EntityKind::Text(b)) if m.text => { + b.style.clone_from(&a.style); + b.height = a.height; + b.width_factor = a.width_factor; + b.oblique = a.oblique; + } + (EntityKind::Text(a), EntityKind::MText(b)) if m.text => { + b.style.clone_from(&a.style); + b.height = a.height; + } + (EntityKind::MText(a), EntityKind::Text(b)) if m.text => { + b.style.clone_from(&a.style); + b.height = a.height; + } + (EntityKind::MText(a), EntityKind::MText(b)) if m.text => { + b.style.clone_from(&a.style); + b.height = a.height; + } + (EntityKind::Hatch(a), EntityKind::Hatch(b)) if m.hatch => { + b.pattern.clone_from(&a.pattern); + b.solid = a.solid; + b.scale = a.scale; + b.angle = a.angle; + b.gradient.clone_from(&a.gradient); + b.pattern_lines.clone_from(&a.pattern_lines); + } + (EntityKind::LwPolyline(a), EntityKind::LwPolyline(b)) if m.polyline => { + b.const_width = a.const_width; + b.plinegen = a.plinegen; + if a.const_width > 0.0 { + for v in &mut b.vertices { + (v.start_width, v.end_width) = (0.0, 0.0); + } + } + } + (EntityKind::Viewport(a), EntityKind::Viewport(b)) if m.viewport => { + b.locked = a.locked; + // The same scale: paper height per model height. + let k = a.view_height / a.height; + if a.height > 0.0 && k.is_finite() && k > 0.0 && b.height > 0.0 { + b.view_height = b.height * k; + } + } + (EntityKind::Table(a), EntityKind::Table(b)) if m.table => b.style.clone_from(&a.style), + (EntityKind::MLeader(a), EntityKind::MLeader(b)) if m.multileader => { + b.style.clone_from(&a.style); + b.arrow_size = a.arrow_size; + b.dogleg = a.dogleg; + b.spline = a.spline; + } + _ => {} + } +} + +/// Copy the chosen properties from `src` to `targets`, skipping the source itself and objects on +/// locked layers. Returns how many objects changed. +fn match_onto(s: &mut Session, src: Handle, targets: &[Handle], m: &MatchProps) -> Result { + let d = s.doc_mut()?; + let source = d.entity(src).map(|e| (**e).clone()).ok_or_else(|| bad("matchprop", "no such source object"))?; + if m.layer { + d.ensure_layer(&source.common.layer); + } + let mut n = 0; + for h in targets { + if *h == src { + continue; + } + let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked); + if locked || d.entity(*h).is_none() { + continue; + } + d.modify_entity(*h, |e| copy_props(&source, e, m))?; + n += 1; + } + Ok(n) +} + +pub(super) fn run_matchprop(s: &mut Session, p: &Value) -> Result { + let src = match p.get("source") { + None | Some(Value::Null) => return Err(bad("matchprop", "`source` (object handle) is required")), + Some(v) => targets(s, &json!({ "handle": v }))?.first().copied().ok_or_else(|| bad("matchprop", "`source` is required"))?, + }; + let tg = match p.get("targets") { + None | Some(Value::Null) => targets(s, &json!({}))?, + Some(v) => targets(s, &json!({ "handles": v }))?, + }; + let m = match p.get("properties") { + None | Some(Value::Null) => MatchProps::default(), + Some(v) => MatchProps::from_json(v)?, + }; + let n = match_onto(s, src, &tg, &m)?; + Ok(json!({ "changed": n })) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Stage { + Source, + Dest, + Settings, +} + +pub(super) struct MatchPropM { + stage: Stage, + source: Option, +} + +impl MatchPropM { + pub(super) fn new() -> Self { + MatchPropM { stage: Stage::Source, source: None } + } + fn echo_settings(s: &mut Session) { + let on = s.last_used.matchprop.names(); + let text = if on.is_empty() { "None".to_string() } else { on.iter().map(|n| label(n)).collect::>().join(" ") }; + s.echo(format!("Current active settings: {text}")); + } + fn source(&mut self, s: &mut Session, h: Handle) -> Step { + self.source = Some(h); + self.stage = Stage::Dest; + s.set_selection(Vec::new()); + Self::echo_settings(s); + Step::Continue + } +} + +/// The settings echo's word for a property. +fn label(name: &str) -> &'static str { + match name { + "color" => "Color", + "layer" => "Layer", + "linetype" => "Ltype", + "ltscale" => "Ltscale", + "lineweight" => "Lineweight", + "transparency" => "Transparency", + "thickness" => "Thickness", + "plotStyle" => "PlotStyle", + "dimension" => "Dim", + "text" => "Text", + "hatch" => "Hatch", + "polyline" => "Polyline", + "viewport" => "Viewport", + "table" => "Table", + _ => "Multileader", + } +} + +impl Interactive for MatchPropM { + fn name(&self) -> &'static str { + "MATCHPROP" + } + fn begin(&mut self, s: &mut Session) -> Result { + // One preselected object is the source. + let pre = s.selection(); + if s.settings.pickfirst + && let [h] = pre.as_slice() + { + return Ok(self.source(s, *h)); + } + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + Stage::Source => Prompt::new("Select source object", Accept::SELECT), + Stage::Dest => Prompt::new("Select destination object(s)", Accept::SELECT).kw(&["Settings"]), + Stage::Settings => { + let mut kws: Vec<&str> = FIELDS.iter().map(|f| f.0).collect(); + kws.extend(["ALL", "None"]); + Prompt::new("Enter a property to turn on or off", Accept::TEXT).kw(&kws).default("Done") + } + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.stage { + Stage::Source => match i { + Input::Pick(hs) => match hs.first() { + Some(h) => Ok(self.source(s, *h)), + None => Ok(Step::Continue), + }, + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + }, + Stage::Dest => match i { + Input::Keyword(k) if k.eq_ignore_ascii_case("settings") => { + self.stage = Stage::Settings; + Ok(Step::Continue) + } + Input::Pick(hs) => { + let Some(src) = self.source else { return Ok(Step::Done) }; + let m = s.last_used.matchprop; + match_onto(s, src, &hs, &m)?; + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Done), + Input::Text(_) | Input::Keyword(_) => { + s.echo("*Invalid selection*"); + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + }, + Stage::Settings => { + match &i { + Input::Enter => { + self.stage = Stage::Dest; + return Ok(Step::Continue); + } + Input::Keyword(k) | Input::Text(k) => { + let k = k.trim(); + if k.eq_ignore_ascii_case("all") { + s.last_used.matchprop = MatchProps::all(true); + } else if k.eq_ignore_ascii_case("none") { + s.last_used.matchprop = MatchProps::all(false); + } else if k.eq_ignore_ascii_case("done") { + self.stage = Stage::Dest; + return Ok(Step::Continue); + } else if !s.last_used.matchprop.toggle(k) { + s.echo("Invalid option keyword."); + return Ok(Step::Continue); + } + Self::echo_settings(s); + } + _ => {} + } + Ok(Step::Continue) + } + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5339735..2e541fa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -398,6 +398,8 @@ pub struct LastUsed { pub rotate_angle: f64, /// SCALE factor. pub scale_factor: f64, + /// MATCHPROP Settings: the properties it copies. + pub matchprop: cmd::MatchProps, /// MLEADER placement order and Options. pub mleader: MLeaderOptions, /// MLEADER's block content name (Options > Content type > Block). @@ -411,6 +413,7 @@ impl Default for LastUsed { polygon_circumscribed: false, rotate_angle: 0.0, scale_factor: 1.0, + matchprop: cmd::MatchProps::default(), mleader: MLeaderOptions::default(), mleader_block: String::new(), } diff --git a/crates/engine/tests/matchprop.rs b/crates/engine/tests/matchprop.rs new file mode 100644 index 0000000..e957f90 --- /dev/null +++ b/crates/engine/tests/matchprop.rs @@ -0,0 +1,138 @@ +//! MATCHPROP (#439): the typed source/destination picker with Settings, and the JSON form with a +//! property list. + +use cadcraft_engine::doc::color::Color; +use cadcraft_engine::doc::{EntityKind, Handle}; +use cadcraft_engine::{Input, Session}; +use serde_json::json; + +fn last(s: &Session) -> Handle { + s.doc().unwrap().model.last().unwrap().handle +} + +/// A red line on layer A (the source) and two plain lines on layer 0. +fn setup() -> (Session, Handle, Handle, Handle) { + let mut s = Session::new(); + s.execute("layer.new", &json!({ "name": "A" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + let src = last(&s); + s.execute("properties.set", &json!({ "handles": [src.hex()], "layer": "A", "color": "red", "ltscale": 2.5 })).unwrap(); + s.execute("line", &json!({ "points": [[0, 5], [10, 5]] })).unwrap(); + let a = last(&s); + s.execute("line", &json!({ "points": [[0, 9], [10, 9]] })).unwrap(); + let b = last(&s); + (s, src, a, b) +} + +fn props(s: &Session, h: Handle) -> (String, Color, f64) { + let e = s.doc().unwrap().entity(h).cloned().unwrap(); + (e.common.layer.clone(), e.common.color, e.common.ltscale) +} + +#[test] +fn matchprop_json_with_property_list() { + let (mut s, src, a, b) = setup(); + let r = s.execute("matchprop", &json!({ "source": src.hex(), "targets": [a.hex()] })).unwrap(); + assert_eq!(r["changed"], 1); + assert_eq!(props(&s, a), ("A".into(), Color::Index(1), 2.5)); + // Only the named properties; `{name: false}` turns single ones off. + s.execute("matchprop", &json!({ "source": src.hex(), "targets": [b.hex()], "properties": ["color"] })).unwrap(); + assert_eq!(props(&s, b), ("0".into(), Color::Index(1), 1.0)); + s.execute("matchprop", &json!({ "source": src.hex(), "targets": [b.hex()], "properties": { "layer": false } })).unwrap(); + assert_eq!(props(&s, b), ("0".into(), Color::Index(1), 2.5)); + // The source itself is not counted; bad params are refused. + let r = s.execute("matchprop", &json!({ "source": src.hex(), "targets": [src.hex()] })).unwrap(); + assert_eq!(r["changed"], 0); + for bad in [ + json!({}), + json!({ "source": null, "targets": [a.hex()] }), + json!({ "source": "FFFFF", "targets": [a.hex()] }), + json!({ "source": src.hex(), "targets": "1A" }), + json!({ "source": src.hex(), "targets": [a.hex()], "properties": ["colour"] }), + json!({ "source": src.hex(), "targets": [a.hex()], "properties": { "color": 1 } }), + json!({ "source": src.hex(), "targets": [a.hex()], "properties": 7 }), + ] { + assert!(s.execute("matchprop", &bad).is_err(), "{bad}"); + } + assert!(s.running.is_none(), "JSON calls never prompt"); +} + +#[test] +fn matchprop_copies_object_specific_properties() { + let mut s = Session::new(); + s.execute("text", &json!({ "at": [0, 0], "height": 5, "text": "src" })).unwrap(); + let t1 = last(&s); + s.execute("text", &json!({ "at": [0, 20], "height": 1, "text": "dst" })).unwrap(); + let t2 = last(&s); + s.execute("pline", &json!({ "vertices": [[0, 0], [10, 0], [10, 10]], "width": 2 })).unwrap(); + let p1 = last(&s); + s.execute("pline", &json!({ "vertices": [[20, 0], [30, 0]] })).unwrap(); + let p2 = last(&s); + s.execute("matchprop", &json!({ "source": t1.hex(), "targets": [t2.hex()], "properties": ["layer"] })).unwrap(); + let height = |s: &Session, h: Handle| match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::Text(t) => t.height, + _ => f64::NAN, + }; + assert_eq!(height(&s, t2), 1.0, "text off: height stays"); + s.execute("matchprop", &json!({ "source": t1.hex(), "targets": [t2.hex()] })).unwrap(); + assert_eq!(height(&s, t2), 5.0); + s.execute("matchprop", &json!({ "source": p1.hex(), "targets": [p2.hex(), t2.hex()] })).unwrap(); + match &s.doc().unwrap().entity(p2).unwrap().kind { + EntityKind::LwPolyline(p) => assert_eq!(p.const_width, 2.0), + k => panic!("{k:?}"), + } + assert_eq!(height(&s, t2), 5.0, "a polyline source leaves text properties alone"); +} + +#[test] +fn matchprop_typed_picker_and_settings() { + let (mut s, src, a, b) = setup(); + let undo = s.state().unwrap().undo.len(); + s.cmdline("ma").unwrap(); + assert!(s.prompt_text().contains("Select source object"), "{}", s.prompt_text()); + s.input(Input::Pick(vec![src])).unwrap(); + assert!(s.log.iter().any(|l| l.starts_with("Current active settings: Color Layer Ltype")), "{:?}", s.log); + assert!(s.prompt_text().contains("Select destination object(s) or [Settings]"), "{}", s.prompt_text()); + s.input(Input::Pick(vec![a])).unwrap(); + assert_eq!(props(&s, a), ("A".into(), Color::Index(1), 2.5)); + // Settings: turn Layer and Ltscale off; Enter goes back to the destination prompt. + s.cmdline("s").unwrap(); + assert!(s.prompt_text().contains("[Color/LAyer/LType"), "{}", s.prompt_text()); + s.cmdline("la").unwrap(); + s.cmdline("lts").unwrap(); + assert!(s.log.last().is_some_and(|l| l.contains("Color Ltype Lineweight")), "{:?}", s.log.last()); + s.cmdline("bogus").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Invalid option keyword")), "{:?}", s.log); + s.cmdline("").unwrap(); + assert!(s.prompt_text().contains("Select destination object(s)"), "{}", s.prompt_text()); + s.input(Input::Pick(vec![b])).unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(props(&s, b), ("0".into(), Color::Index(1), 1.0)); + assert_eq!(s.state().unwrap().undo.len(), undo + 1, "one undo step"); + // Settings are remembered for the next typed MATCHPROP; a preselected object is the source. + s.set_selection(vec![src]); + s.cmdline("matchprop").unwrap(); + assert!(s.prompt_text().contains("Select destination"), "{}", s.prompt_text()); + assert!(s.log.last().is_some_and(|l| !l.contains("Layer")), "{:?}", s.log.last()); + s.cmdline("s").unwrap(); + s.cmdline("none").unwrap(); + assert!(s.log.last().is_some_and(|l| l.ends_with("None")), "{:?}", s.log.last()); + s.cmdline("all").unwrap(); + s.cmdline("").unwrap(); + s.cancel(); + // A JSON call ignores the typed settings. + s.execute("matchprop", &json!({ "source": src.hex(), "targets": [b.hex()] })).unwrap(); + assert_eq!(props(&s, b), ("A".into(), Color::Index(1), 2.5)); +} + +#[test] +fn matchprop_skips_locked_layers() { + let (mut s, src, a, _) = setup(); + s.execute("layer.new", &json!({ "name": "L" })).unwrap(); + s.execute("properties.set", &json!({ "handles": [a.hex()], "layer": "L" })).unwrap(); + s.execute("layer.set", &json!({ "name": "L", "locked": true })).unwrap(); + let r = s.execute("matchprop", &json!({ "source": src.hex(), "targets": [a.hex()] })).unwrap(); + assert_eq!(r["changed"], 0); + assert_eq!(props(&s, a).0, "L"); +}