MATCHPROP: source/destination picker and Settings (part of #439) (#449)

Squash of PR #449 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 17:27:48 +02:00 committed by GitHub
parent 151722feb0
commit a652c57537
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 550 additions and 24 deletions

View File

@ -26,6 +26,7 @@ mod modify2;
mod mtext_opts;
mod pline_opts;
mod props;
pub use props::MatchProps;
mod qselect;
mod selecttools;
mod settings;

View File

@ -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<CommandSpec> {
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?, <geometry fields: radius, center, start, end, text, height, rotation…>}"),
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<Value> {
Ok(json!({ "changed": hs.len() }))
}
fn run_matchprop(s: &mut Session, p: &Value) -> Result<Value> {
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<cadcraft_doc::Handle> = 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<Value> {
let c = p.get("color").and_then(parse_color).ok_or_else(|| bad("color", "`color` is required"))?;
let d = s.doc_mut()?;

View File

@ -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<Self> {
let unknown =
|n: &str| bad("matchprop", format!("unknown property `{n}` (known: {})", FIELDS.iter().map(|f| f.1).collect::<Vec<_>>().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<usize> {
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<Value> {
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<Handle>,
}
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::<Vec<_>>().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<Step> {
// 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<Step> {
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)
}
}
}
}

View File

@ -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(),
}

View File

@ -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");
}