M2: auto layout: wrapped-line alignment

This commit is contained in:
bflatastic
2026-10-13 13:36:54 +07:00
committed by BFlat
parent 1667b7bc91
commit a2a0f80252
19 changed files with 455 additions and 38 deletions
+115
View File
@@ -0,0 +1,115 @@
//! Wrapped-line alignment through commands: the `contentAlign` prop and its alias, undo, the CSS
//! and inspect output, instance overrides, the REST and `.fig` mappings, and junk values.
use serde_json::{Value, json};
use crate::params::id_of;
use crate::{EngineError, Session};
use figcraft_doc::{ContentAlign, NodeId};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
/// A 100 × 100 wrapping frame of three 40 × 20 rectangles (gap 10, row gap 5): two rows.
fn wrapping(s: &mut Session) -> (NodeId, Vec<NodeId>) {
let props = json!({"layout": "horizontal", "wrap": true, "padding": 0, "gap": 10, "crossGap": 5});
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": props})).unwrap());
let kids = (0..3).map(|_| id(s.execute("shape.rectangle", &json!({"parent": f, "x": 0, "y": 0, "width": 40, "height": 20})).unwrap())).collect();
(f, kids)
}
fn ys(s: &Session, kids: &[NodeId]) -> Vec<f64> {
kids.iter().map(|k| s.doc().unwrap().node(*k).unwrap().transform.as_coeffs()[5]).collect()
}
fn content(s: &Session, f: NodeId) -> ContentAlign {
s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().content_align
}
#[test]
fn content_align_spreads_the_rows_and_undoes() {
let mut s = Session::new();
let (f, kids) = wrapping(&mut s);
assert_eq!(ys(&s, &kids), [0.0, 0.0, 25.0]);
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "spaceBetween"}})).unwrap();
assert_eq!(ys(&s, &kids), [0.0, 0.0, 80.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((content(&s, f), ys(&s, &kids)), (ContentAlign::Auto, vec![0.0, 0.0, 25.0]));
// The file format's name and spelling work too; packed rows follow the cross alignment.
s.execute("node.set", &json!({"id": f, "props": {"counterAxisAlignContent": "SPACE_BETWEEN"}})).unwrap();
assert_eq!(content(&s, f), ContentAlign::SpaceBetween);
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "auto", "crossAlign": "max"}})).unwrap();
assert_eq!(ys(&s, &kids), [55.0, 55.0, 80.0]);
}
#[test]
fn css_and_inspect_name_the_lines() {
let mut s = Session::new();
let (f, _) = wrapping(&mut s);
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": f})).unwrap().to_string();
assert!(css(&mut s).contains("align-content: flex-start;"), "{}", css(&mut s));
s.execute("node.set", &json!({"id": f, "props": {"crossAlign": "center"}})).unwrap();
assert!(css(&mut s).contains("align-content: center;"));
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "spaceBetween"}})).unwrap();
assert!(css(&mut s).contains("align-content: space-between;"));
let rows = s.execute("inspect.properties", &json!({"id": f})).unwrap().to_string();
assert!(rows.contains("Auto (space between)"), "{rows}");
// One line has no lines to align.
s.execute("node.set", &json!({"id": f, "props": {"wrap": false}})).unwrap();
assert!(!css(&mut s).contains("align-content"));
}
#[test]
fn an_instance_keeps_its_own_line_alignment() {
let mut s = Session::new();
let (f, _) = wrapping(&mut s);
let comp = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
let inst = id(s.execute("instance.create", &json!({"component": comp, "x": 200, "y": 0})).unwrap());
s.execute("node.set", &json!({"id": inst, "props": {"contentAlign": "spaceBetween"}})).unwrap();
// A change to the main reaches the instance; its own alignment stays.
s.execute("node.set", &json!({"id": comp, "props": {"padding": 2}})).unwrap();
assert_eq!((content(&s, comp), content(&s, inst)), (ContentAlign::Auto, ContentAlign::SpaceBetween));
}
#[test]
fn rest_and_fig_carry_the_setting() {
let frame = |extra: Value| {
let mut f = json!({"id": "1:2", "name": "Wrap", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 100, "y": 100},
"layoutMode": "HORIZONTAL", "layoutWrap": "WRAP", "children": []});
f.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
json!({"name": "File", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [f]}]}})
};
let read = |file: &Value| {
let (doc, _) = figcraft_format::rest::import(file, &figcraft_format::rest::no_images).unwrap();
doc
};
let of = |doc: &figcraft_doc::Document| doc.nodes().find(|n| n.name == "Wrap").unwrap().layout.as_ref().unwrap().content_align;
assert_eq!(of(&read(&frame(json!({})))), ContentAlign::Auto);
assert_eq!(of(&read(&frame(json!({"counterAxisAlignContent": "AUTO"})))), ContentAlign::Auto);
let spread = read(&frame(json!({"counterAxisAlignContent": "SPACE_BETWEEN"})));
assert_eq!(of(&spread), ContentAlign::SpaceBetween);
// Written to `.fig` and read back, both values survive.
for doc in [spread, read(&frame(json!({})))] {
let (bytes, _) = figcraft_format::fig::export(&doc, None).unwrap();
let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
assert_eq!(of(&back), of(&doc));
}
}
#[test]
fn junk_values_are_refused_not_fatal() {
let junk =
[json!(null), json!(true), json!(-1), json!(1e308), json!(""), json!("sideways"), json!([1, 2]), json!({"a": 1}), json!("x".repeat(10_000))];
for k in ["contentAlign", "counterAxisAlignContent"] {
for j in &junk {
let mut s = Session::new();
let (f, kids) = wrapping(&mut s);
for target in [f, kids[0]] {
let r = s.execute("node.set", &json!({"id": target, "props": {k: j}}));
assert!(!matches!(r, Err(EngineError::Internal { .. })), "{k} = {j}: {r:?}");
}
assert_eq!(content(&s, f), ContentAlign::Auto, "{k} = {j}");
}
}
}
+5 -2
View File
@@ -16,7 +16,7 @@ pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relat
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \
gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRowSpan, gridAlignH / gridAlignV (a grid child's alignment in its cell: min|center|max, auto/null = the frame's), sizingH / sizingV (fixed|hug|fill), \
absolute (ignore auto layout; follows constraints), minWidth, maxWidth, minHeight, maxHeight (null removes), constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \
@@ -472,6 +472,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
"crossAlign" | "counterAxisAlignItems" => {
ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
}
"contentAlign" | "counterAxisAlignContent" => {
ensure_layout(doc.get_mut(id)?).content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
}
"wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false),
"gridColumns" | "gridRows" => {
let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
@@ -795,7 +798,7 @@ pub fn override_group(k: &str) -> &'static str {
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
"gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap",
"padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding",
"mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" => "alignment",
"mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment",
"grids" | "layoutGrids" | "gridStyle" => "grids",
"exports" | "exportSettings" => "exports",
"interactions" => "interactions",
+15
View File
@@ -343,6 +343,21 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
CrossAlign::Baseline => "baseline",
};
put("align-items", align.into());
if l.wrap {
// The lines: spread, or packed where the items align (baseline: at the start).
let spread = l.content_align == ContentAlign::SpaceBetween;
put(
"align-content",
if spread {
"space-between"
} else if align == "baseline" {
"flex-start"
} else {
align
}
.into(),
);
}
}
if matches!(n.kind, NodeKind::Ellipse { .. }) {
put("border-radius", "50%".into());
+3 -2
View File
@@ -375,7 +375,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
match g {
"padding" => b.padding = a.padding,
"gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap),
_ => (b.main_align, b.cross_align) = (a.main_align, a.cross_align),
_ => (b.main_align, b.cross_align, b.content_align) = (a.main_align, a.cross_align, a.content_align),
}
}
}
@@ -1003,7 +1003,7 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
if x.gap != y.gap || x.cross_gap != y.cross_gap {
out.push("gap");
}
if x.main_align != y.main_align || x.cross_align != y.cross_align {
if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align {
out.push("alignment");
}
}
@@ -1680,6 +1680,7 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
let settings = |x: &Node| {
x.layout.clone().map(|mut l| {
(l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min);
l.content_align = ContentAlign::Auto;
l
})
};
+2 -1
View File
@@ -550,7 +550,8 @@ fn auto_layout(doc: &Document, n: &Node, l: &AutoLayout) -> Section {
rows.push(prop("Gap", gap).token(variable(doc, n, "gap")));
if l.wrap {
let label = if l.mode == LayoutMode::Vertical { "Column gap" } else { "Row gap" };
rows.push(prop(label, num(l.cross_gap)).token(variable(doc, n, "crossGap")));
let spread = l.content_align == ContentAlign::SpaceBetween;
rows.push(if spread { prop(label, "Auto (space between)") } else { prop(label, num(l.cross_gap)).token(variable(doc, n, "crossGap")) });
}
}
let [t, r, b, lft] = l.padding;
+2
View File
@@ -1318,6 +1318,8 @@ mod a11y_keys_tests;
#[cfg(test)]
mod al_behaviour_tests;
#[cfg(test)]
mod al_content_align_tests;
#[cfg(test)]
mod al_drag_tests;
#[cfg(test)]
mod al_keys_tests;