M2: auto layout settings in one call don't depend on how their keys are spelled

`apply_order` gave `layout` the rank of every other key, so the props
map's alphabetical order decided: `gap` applied before an object
`layout`, which then replaced the whole auto layout (the gap went back
to its default), and `padding` applied after `layout: null` and turned
auto layout back on.

`layout`/`layoutMode` now apply right after the geometry keys and
before the keys that tune them, and auto layout keys beside a `layout`
of null or "none" are refused, naming them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MortenHusted
2026-10-28 08:35:33 +01:00
co-authored by Claude Opus 5.5
parent 90054a89c4
commit b45c41a02c
3 changed files with 70 additions and 10 deletions
+41 -1
View File
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{CellAlign, MainAlign, NodeId, Sizing};
use figcraft_doc::{CellAlign, CrossAlign, LayoutMode, MainAlign, NodeId, Sizing};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
@@ -233,3 +233,43 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
}
/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
/// an object that replaces the whole auto layout) applies first and the other keys tune it.
#[test]
fn auto_layout_keys_in_one_call_tune_the_mode_given_beside_them() {
for mode in [json!("vertical"), json!({"mode": "vertical"})] {
let props = json!({"layout": mode, "gap": 8, "padding": 4, "crossAlign": "center"});
let mut s = Session::new();
let created = id(s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap());
let set_on = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
set(&mut s, set_on, props.clone());
for f in [created, set_on] {
let l = s.doc().unwrap().node(f).unwrap().layout.clone().unwrap();
assert_eq!((l.mode, l.gap, l.padding, l.cross_align), (LayoutMode::Vertical, 8.0, [4.0; 4], CrossAlign::Center), "{props}");
}
}
}
/// Auto layout keys beside a `layout` that turns auto layout off would turn it back on: the call
/// is refused and the layer stays as it was.
#[test]
fn auto_layout_keys_beside_no_auto_layout_are_refused() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
for props in
[json!({"layout": null, "padding": 4}), json!({"layout": "none", "gap": 8, "padding": 4}), json!({"layoutMode": "NONE", "wrap": true})]
{
let e = s.execute("node.set", &json!({"id": f, "props": props})).unwrap_err().to_string();
assert!(e.contains("turns auto layout off"), "{props}: {e}");
assert!(s.doc().unwrap().node(f).unwrap().layout.is_none(), "{props}");
let e = s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap_err().to_string();
assert!(e.contains("turns auto layout off"), "{props}: {e}");
}
let e = s.execute("node.set", &json!({"id": f, "props": {"layout": null, "gap": 8, "padding": 4}})).unwrap_err().to_string();
assert!(e.contains("`gap`, `padding` are auto layout properties"), "{e}");
// Without other auto layout keys, `layout: null` still removes it.
set(&mut s, f, json!({"layout": "vertical", "gap": 8}));
set(&mut s, f, json!({"layout": null, "name": "Plain"}));
assert!(s.doc().unwrap().node(f).unwrap().layout.is_none());
}
+26 -8
View File
@@ -383,20 +383,37 @@ fn paint_none(v: &Value) -> bool {
v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
}
/// 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.
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, the
/// auto layout mode before the keys that tune it (an object `layout` replaces the whole auto
/// layout), 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 {
"path" | "width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
"boundVariables" => 5,
_ => 1,
"layout" | "layoutMode" => 1,
"rotation" => 3,
"x" | "y" => 4,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 5,
"boundVariables" => 6,
_ => 2,
}
}
/// Auto layout keys given beside a `layout` (or `layoutMode`) that turns auto layout off: they
/// would turn it back on (`layout_mut`), so the call is refused rather than one of them losing.
fn layout_keys_beside_none(cmd: &str, props: &Map<String, Value>) -> Result<()> {
let off = ["layout", "layoutMode"]
.iter()
.filter_map(|k| props.get(*k))
.any(|v| v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none")));
let tuning: Vec<String> = props.keys().filter(|k| LAYOUT_KEYS.contains(&k.as_str())).map(|k| format!("`{k}`")).collect();
if !off || tuning.is_empty() {
return Ok(());
}
let (what, are) = if tuning.len() == 1 { ("an auto layout property", "is") } else { ("auto layout properties", "are") };
Err(bad(cmd, format!("{} {are} {what}, and `layout` in the same call turns auto layout off", tuning.join(", "))))
}
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
/// into W or H.
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
@@ -411,6 +428,7 @@ fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
layout_keys_beside_none(cmd, props)?;
let mut unknown = Vec::new();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| apply_order(k));
+3 -1
View File
@@ -157,7 +157,9 @@ Or in a project's `.mcp.json`:
ignored); a layer a mask hides can't be clicked there, and a mask group's box and exports are
the mask's (plus any layers under the mask).
- Children of an auto-layout frame are positioned by the layout: set `layout`, `gap`, `padding`,
`mainAlign`, `crossAlign`, `sizingH`/`sizingV` instead of x/y. More auto layout props:
`mainAlign`, `crossAlign`, `sizingH`/`sizingV` instead of x/y. In one call, `layout` applies
before the keys that tune it, so `{"layout": {"mode": "vertical"}, "gap": 8}` keeps the gap;
auto layout keys beside a `layout` of `null` or `"none"` are refused. More auto layout props:
- `sizingH` / `sizingV`: `fixed`, `hug` (auto layout frames and text) or `fill` (children in an
auto layout flow). A child can't fill an axis its parent hugs: setting `fill` turns the parent
`fixed` on that axis, and a parent set to `hug` turns the children that filled it `fixed`. A