M3: style links given in a call apply after its values

apply_order gave fillStyle, strokeStyle, effectStyle, gridStyle and
textStyle the rank of plain values, so the JSON map's byte order decided:
fills, strokes, effects, grids, layoutGrids and variationSettings sort
after their link and overrode it (node.set then unlinked the style;
node.create kept a link its values contradicted). Style links now apply
after the values and x/y, just before boundVariables. A deviation set in a
later call still unlinks.
This commit is contained in:
MortenHusted
2026-10-22 11:11:59 +02:00
parent a86897dc91
commit 24a5855cab
3 changed files with 77 additions and 6 deletions
+5 -3
View File
@@ -372,14 +372,16 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
}
}
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
/// bindings last so paints set in the same call don't detach them.
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
/// links after the values so a link wins over values its style sets, variable bindings last so
/// paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
match k {
"width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"boundVariables" => 4,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
"boundVariables" => 5,
_ => 1,
}
}
@@ -80,6 +80,20 @@ fn link(s: &Session, n: NodeId, kind: &str) -> Option<String> {
}
}
/// The key linking a style of `kind` (a paint style links fills).
fn style_key(kind: &str) -> String {
if kind == "paint" { "fillStyle".into() } else { format!("{kind}Style") }
}
/// `node.create` params for an unselected layer that takes `kind` styles, with `props`.
fn new_layer(kind: &str, props: Value) -> Value {
if kind == "text" {
json!({"type": "text", "text": "Hej", "select": false, "props": props})
} else {
json!({"type": "frame", "select": false, "props": props})
}
}
#[test]
fn a_style_holds_the_whole_property_and_links_its_source() {
// Documented: colour styles can hold several fills (gradients, images); effect styles combine
@@ -234,6 +248,59 @@ fn editing_a_styled_property_detaches_that_style_only() {
let _ = st;
}
#[test]
fn a_style_link_wins_over_values_in_the_same_call() {
// Docs silent on one call setting both (a linked layer shows its style's values; editing one
// detaches it); pinned: the link wins whatever the keys are called, on node.create and
// node.set, and a deviation set in a later call unlinks as before.
let mut s = Session::new();
let rows = styled(&mut s);
let pairs: [(&str, &str, Value); 12] = [
("paint", "fill", json!("#123456")),
("paint", "fills", json!(["#123456"])),
("stroke", "stroke", json!("#123456")),
("stroke", "strokes", json!(["#123456"])),
("effect", "effects", json!([])),
("grid", "grids", json!([])),
("grid", "layoutGrids", json!([])),
("text", "fontStyle", json!("Bold")),
("text", "lineHeight", json!(40)),
("text", "textDecoration", json!("underline")),
("text", "letterSpacing", json!(5)),
("text", "variationSettings", json!({"wght": 700})),
];
for (kind, key, value) in pairs {
let (source, st, _) = rows.iter().find(|r| r.2 == kind).unwrap().clone();
let link_key = style_key(kind);
let props = json!({key: value.clone(), link_key.as_str(): st.clone()});
let made = id(run(&mut s, "node.create", new_layer(kind, props.clone())));
let set = if kind == "text" { text(&mut s, 400.0) } else { rect(&mut s, 400.0) };
run(&mut s, "node.set", json!({"id": set, "props": props}));
for (how, layer) in [("node.create", made), ("node.set", set)] {
assert_eq!(link(&s, layer, kind).as_deref(), Some(st.as_str()), "{how} `{key}` + `{link_key}`: linked");
assert_eq!(shown(&s, layer, kind), shown(&s, source, kind), "{how} `{key}` + `{link_key}`: the style's values");
}
run(&mut s, "node.set", json!({"id": set, "props": {key: value}}));
assert_eq!(link(&s, set, kind), None, "`{key}` in a later call unlinks");
assert_ne!(shown(&s, set, kind), shown(&s, source, kind), "`{key}` in a later call applies");
}
}
#[test]
fn a_style_link_leaves_values_its_style_does_not_set() {
// Docs silent; pinned: alignment given beside a text style applies, on node.create and node.set.
let mut s = Session::new();
let st = styled(&mut s).into_iter().find(|r| r.2 == "text").unwrap().1;
let props = json!({"textStyle": st, "textAlign": "center"});
let made = id(run(&mut s, "node.create", new_layer("text", props.clone())));
let set = text(&mut s, 400.0);
run(&mut s, "node.set", json!({"id": set, "props": props}));
for (how, layer) in [("node.create", made), ("node.set", set)] {
assert_eq!(link(&s, layer, "text").as_deref(), Some(st.as_str()), "{how}: linked");
assert_eq!(node(&s, layer).kind.text().unwrap().align_h, figcraft_doc::TextAlignH::Center, "{how}: `textAlign` applies");
}
}
#[test]
fn applying_a_style_replaces_variables_bound_to_that_property() {
// Documented: applying a style sets the property to the style's values. Docs silent on bound
+5 -3
View File
@@ -267,9 +267,11 @@ Or in a project's `.mcp.json`:
- **Styles stay true to their layers**: editing a styled fill, stroke, effect or layout grid list
(`node.set`, `fill.set`, `fill.add`, a bound variable…) unlinks that style (`styles.fill` etc. go
`null`), so a later `style.update` doesn't overwrite the edit; `style.apply` replaces variables
bound to what the style sets; a text style goes on text layers only. Names are trimmed per `/`
folder part. `style.move {styles, before?, folder?}` reorders (and with `folder`, renames into it),
`style.duplicate {style}` adds "<name> copy" right below, `style.renameFolder {folder, name}`.
bound to what the style sets; a text style goes on text layers only. In one call, a style link
(`fillStyle`, `textStyle`…) wins over values it sets; set a deviation in a later call, which
unlinks it. Names are trimmed per `/` folder part. `style.move {styles, before?, folder?}`
reorders (and with `folder`, renames into it), `style.duplicate {style}` adds "<name> copy" right
below, `style.renameFolder {folder, name}`.
- **Variables**: `variable.bind` refuses a variable of a type the field doesn't take (a colour on
`width`); scopes only filter pickers. `variable.delete` / `variable.deleteCollection` unbind the
layers using them (they keep their last value) and give aliases to them their values. An