Files
figcraft/crates/engine/src/cmd/layout.rs
T
BFlatandClaude Opus 5.5 4286a3b74b M2: auto layout depth: min/max UI, ignore auto layout toggle, text baseline alignment, grid tracks and cells (#3)
- Min/max width/height: the W/H sizing menus add min/max (at the current size) and remove them;
  limit rows edit or add the missing bound. The layout clamps Fixed, Hug and Fill children and
  the frame itself; a hugging text wider than its max width wraps at the max. min > max is kept
  ordered by node.set (the other bound follows); in the layout min wins.
- Ignore auto layout (absolute position): a toggle in the Position section; the child stays
  where it is, can't Fill, shows constraints and follows them when the auto layout frame resizes
  (by hugging, filling or a direct resize).
- Text baseline alignment for horizontal layouts ("Align text baselines"; crossAlign baseline):
  first-line baselines line up, other layers align to the top, nested auto layout frames use
  their first child's baseline. Measure gains text_baseline.
- Grid auto layout: column/row tracks (fixed px, hug, fr fill) via columnTracks / rowTracks or
  layout.setTrack, cells anchored by gridColumn / gridRow with gridColumnSpan / gridRowSpan,
  auto placement in reading order, rows grow to fit, hugging grids size to their tracks.
  Panel: column/row gap, track size rows, a child's cell and spans. Old files load unchanged
  (serde defaults; missing tracks are 1fr).
- Canvas handles: wrapped-row gaps and grid column/row gaps are draggable; a selected grid's
  tracks are outlined.
- The layout section moved from right.rs to layout_panel.rs; inspect reports min/max and a
  grid child's resolved gridCell; docs list the new props.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:23:43 +07:00

271 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Auto layout commands beyond `layout.add` / `layout.remove` (in `structure`) and the layout
//! keys of `node.set` (gap, padding, alignment, sizing, min/max, absolute, grid cells): grid tracks.
use figcraft_doc::{GridTrack, LayoutMode, Sizing};
use serde_json::Value;
use super::props::sizing;
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"layout.setTrack",
"Set grid track size",
"{ids?, axis: columns|rows, index (0-based), sizing: fixed|hug|fill, value? (fixed: px, default the track's current size; fill: fr, default 1)}",
true,
set_track
)]
}
fn set_track(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layout.setTrack";
let ids = req_targets(CMD, s, p)?;
let columns = match string(p, "axis").map(|a| a.to_ascii_lowercase()).as_deref() {
Some("columns" | "column" | "col" | "x") => true,
Some("rows" | "row" | "y") => false,
_ => return Err(bad(CMD, "`axis` must be columns or rows")),
};
let index = req_num(CMD, p, "index")?;
let kind = get(p, "sizing").and_then(sizing).ok_or_else(|| bad(CMD, "`sizing` must be fixed, hug or fill"))?;
let value = num(p, "value");
let doc = s.doc_mut()?;
for id in ids {
// Current track sizes (switching to Fixed keeps the size the track has now).
let tracks_now = figcraft_layout::grid_geometry(doc, id).map(|g| if columns { g.columns } else { g.rows }).unwrap_or_default();
let n = doc.get_mut(id)?;
let Some(l) = n.layout.as_mut().filter(|l| l.mode == LayoutMode::Grid) else {
return Err(bad(CMD, format!("node {id} is not a grid auto layout frame")));
};
let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize);
if !(0.0..count as f64).contains(&index) {
return Err(bad(CMD, format!("`index` must be 0..{count}")));
}
let i = index as usize;
let track = match kind {
Sizing::Fixed => {
let current = tracks_now.get(i).map(|(a, b)| b - a).unwrap_or(100.0);
GridTrack::fixed(value.unwrap_or(current).clamp(0.0, 1e6))
}
Sizing::Hug => GridTrack::hug(),
Sizing::Fill => GridTrack::fill(value.unwrap_or(1.0).clamp(0.0, 1e4)),
};
let tracks = if columns { &mut l.column_tracks } else { &mut l.row_tracks };
if tracks.len() <= i {
tracks.resize(i + 1, GridTrack::default());
}
if let Some(t) = tracks.get_mut(i) {
*t = track;
}
}
ok()
}
#[cfg(test)]
mod tests {
use figcraft_doc::{CrossAlign, Document, GridTrack, LayoutMode, Node, NodeId, Sizing};
use serde_json::{Value, json};
use crate::{EngineError, Session, params};
fn id(v: &Value) -> NodeId {
params::id_of(&v["id"]).unwrap()
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().node(id).unwrap().clone()
}
fn x(s: &Session, id: NodeId) -> (f64, f64) {
let c = node(s, id).transform.as_coeffs();
(c[4], c[5])
}
/// A horizontal auto layout frame (padding 0, gap 10) with two 40×20 rectangles.
fn al_frame(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let f = id(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 100, "props": {"layout": "horizontal", "gap": 10}})));
let a = id(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": f})));
let b = id(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": f})));
(f, a, b)
}
#[test]
fn min_max_props_undo_and_order() {
let mut s = Session::new();
let (f, a, _) = al_frame(&mut s);
ex(&mut s, "node.set", json!({"id": f, "props": {"sizingH": "hug"}}));
assert_eq!(node(&s, f).width, 90.0);
ex(&mut s, "node.set", json!({"id": f, "props": {"minWidth": 200}}));
assert_eq!(node(&s, f).width, 200.0);
// A max below the min pulls the min down with it.
ex(&mut s, "node.set", json!({"id": f, "props": {"maxWidth": 150}}));
let n = node(&s, f);
assert_eq!((n.child_layout.min_w, n.child_layout.max_w, n.width), (Some(150.0), Some(150.0), 150.0));
s.undo().unwrap();
assert_eq!(node(&s, f).width, 200.0);
// null removes the limit.
ex(&mut s, "node.set", json!({"id": f, "props": {"minWidth": null, "maxWidth": null}}));
assert_eq!(node(&s, f).width, 90.0);
// Child limits clamp a fixed child; negatives become 0; junk is refused and rolled back.
ex(&mut s, "node.set", json!({"id": a, "props": {"minWidth": 70, "maxHeight": -5}}));
let n = node(&s, a);
assert_eq!((n.width, n.height, n.child_layout.max_h), (70.0, 0.0, Some(0.0)));
let before = s.ds().unwrap().history.undo.len();
assert!(s.execute("node.set", &json!({"id": a, "props": {"minWidth": "wide"}})).is_err());
assert!(s.execute("node.set", &json!({"id": a, "props": {"maxWidth": [1]}})).is_err());
assert_eq!(s.ds().unwrap().history.undo.len(), before);
assert_eq!(node(&s, a).child_layout.min_w, Some(70.0));
}
#[test]
fn absolute_child_keeps_place_and_follows_constraints() {
let mut s = Session::new();
let (f, a, b) = al_frame(&mut s);
ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": "fill"}}));
let at = x(&s, b);
ex(&mut s, "node.set", json!({"id": b, "props": {"absolute": true, "constraints": {"horizontal": "right"}}}));
let n = node(&s, b);
assert!(n.child_layout.absolute);
assert_eq!(n.child_layout.sizing_h, Sizing::Fixed, "an absolute child can't fill");
assert_eq!(x(&s, b), at, "toggling absolute keeps the child where it is");
// The flow ignores it: changing the gap moves nothing else.
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingLeft": 30}}));
assert_eq!(x(&s, a).0, 30.0);
assert_eq!(x(&s, b), at);
// Resizing the frame applies its constraints (right: keeps its right margin).
let right_margin = 300.0 - at.0 - node(&s, b).width;
ex(&mut s, "node.set", json!({"id": f, "props": {"width": 400}}));
assert!((400.0 - x(&s, b).0 - node(&s, b).width - right_margin).abs() < 1e-9);
// Undo the resize, then back into the flow.
s.undo().unwrap();
assert_eq!(x(&s, b), at);
ex(&mut s, "node.set", json!({"id": b, "props": {"absolute": false}}));
assert_eq!(x(&s, b).0, 30.0 + 40.0 + 10.0);
assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err());
}
#[test]
fn baseline_alignment_lines_up_text() {
let mut s = Session::new();
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "props": {"layout": "horizontal"}})));
let small = id(&ex(&mut s, "text.create", json!({"text": "Small", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 12}})));
let big = id(&ex(&mut s, "text.create", json!({"text": "Big", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 48}})));
ex(&mut s, "node.set", json!({"id": f, "props": {"crossAlign": "baseline"}}));
assert_eq!(node(&s, f).layout.unwrap().cross_align, CrossAlign::Baseline);
let base =
|s: &Session, t: NodeId| figcraft_text::layout_node(&node(s, t)).and_then(|(l, dy)| l.lines.first().map(|line| line.baseline + dy));
// Only meaningful when the text was measured (fonts available).
if let (Some(bs), Some(bb)) = (base(&s, small), base(&s, big))
&& (bb - bs).abs() > 1.0
{
assert!((x(&s, small).1 + bs - (x(&s, big).1 + bb)).abs() < 1e-6);
}
// Vertical layouts treat baseline as left.
ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "vertical"}}));
assert_eq!(x(&s, small).0, x(&s, big).0);
}
#[test]
fn grid_tracks_cells_and_undo() {
let mut s = Session::new();
let f = id(&ex(
&mut s,
"frame.create",
json!({"x": 0, "y": 0, "width": 320, "height": 200, "props": {"layout": "grid", "gridColumns": 3, "columnGap": 10, "rowGap": 10}}),
));
let kids: Vec<NodeId> =
(0..4).map(|_| id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": f})))).collect();
ex(&mut s, "node.set", json!({"id": f, "props": {"columnTracks": [100, "1fr", "auto"]}}));
let l = node(&s, f).layout.unwrap();
assert_eq!(l.column_tracks, vec![GridTrack::fixed(100.0), GridTrack::fill(1.0), GridTrack::hug()]);
// 320 - 20 gaps - 100 - 20 (hug) = 180 for the 1fr column.
assert_eq!(x(&s, kids[1]).0, 110.0);
assert_eq!(x(&s, kids[2]).0, 300.0);
// layout.setTrack: switching to fixed keeps the current size.
ex(&mut s, "layout.setTrack", json!({"ids": [f], "axis": "columns", "index": 1, "sizing": "fixed"}));
assert_eq!(node(&s, f).layout.unwrap().column_tracks.get(1).copied(), Some(GridTrack::fixed(180.0)));
ex(&mut s, "layout.setTrack", json!({"ids": [f], "axis": "rows", "index": 0, "sizing": "fixed", "value": 50}));
assert_eq!(node(&s, f).layout.unwrap().row_tracks, vec![GridTrack::fixed(50.0)]);
s.undo().unwrap();
assert!(node(&s, f).layout.unwrap().row_tracks.is_empty());
// Cells: anchor the last child at column 2, row 1, span the first over two columns.
ex(&mut s, "node.set", json!({"id": kids[3], "props": {"gridColumn": 2, "gridRow": 1}}));
ex(&mut s, "node.set", json!({"id": kids[0], "props": {"gridColumnSpan": 2, "sizingH": "fill"}}));
assert_eq!(node(&s, kids[0]).width, 100.0 + 10.0 + 180.0);
let info = crate::inspect::node_json(s.doc().unwrap(), kids[3], 0);
assert_eq!(info["gridCell"]["column"], json!(2));
assert_eq!(info["gridCell"]["row"], json!(1));
assert_eq!(info["gridCell"]["anchored"], json!(true));
ex(&mut s, "node.set", json!({"id": kids[3], "props": {"gridColumn": "auto", "gridRow": null}}));
assert_eq!(node(&s, kids[3]).child_layout.grid_column, None);
// Fewer columns drop the extra tracks.
ex(&mut s, "node.set", json!({"id": f, "props": {"gridColumns": 2}}));
assert_eq!(node(&s, f).layout.unwrap().column_tracks.len(), 2);
// Errors roll back.
for bad in [
json!({"ids": [f], "axis": "diagonal", "index": 0, "sizing": "fixed"}),
json!({"ids": [f], "axis": "columns", "index": 9, "sizing": "fixed"}),
json!({"ids": [f], "axis": "columns", "index": -1, "sizing": "hug"}),
json!({"ids": [f], "axis": "columns", "index": 0, "sizing": "stretchy"}),
json!({"ids": [kids[1]], "axis": "columns", "index": 0, "sizing": "hug"}),
] {
assert!(s.execute("layout.setTrack", &bad).is_err(), "{bad}");
}
assert!(s.execute("node.set", &json!({"id": f, "props": {"columnTracks": ["1fr", "wide"]}})).is_err());
assert!(s.execute("node.set", &json!({"id": f, "props": {"rowTracks": 5}})).is_err());
}
#[test]
fn layout_props_survive_hostile_values() {
let junk = [json!(null), json!("NaN"), json!(-1e308), json!(1e308), json!([]), json!({}), json!(true), json!("-5fr")];
let keys = [
"minWidth",
"maxWidth",
"minHeight",
"maxHeight",
"absolute",
"columnTracks",
"rowTracks",
"gridColumn",
"gridRow",
"gridColumnSpan",
"gridRowSpan",
"gridColumns",
"gridRows",
"columnGap",
"rowGap",
"crossAlign",
];
for k in keys {
for j in &junk {
let mut s = Session::new();
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"layout": "grid"}})));
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": f})));
for target in [f, r] {
if let Err(EngineError::Internal { cmd, msg }) = s.execute("node.set", &json!({"id": target, "props": {k: j}})) {
panic!("{cmd} panicked on {k}={j}: {msg}");
}
}
let doc: &Document = s.doc().unwrap();
for n in [f, r] {
let n = doc.node(n).unwrap();
assert!(n.width.is_finite() && n.height.is_finite(), "{k}={j}");
}
assert!(doc.node(f).unwrap().layout.as_ref().is_some_and(|l| l.mode == LayoutMode::Grid));
}
}
// Tracks as track objects and extreme strings.
let t: Vec<Option<GridTrack>> =
[json!({"sizing": "fill", "value": 2}), json!({"sizing": "fixed"}), json!("120px"), json!("1e400fr"), json!("hug")]
.iter()
.map(super::super::props::track_of)
.collect();
assert_eq!(t, vec![Some(GridTrack::fill(2.0)), None, Some(GridTrack::fixed(120.0)), None, Some(GridTrack::hug())]);
}
}