mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 18:36:44 -06:00
- 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>
271 lines
13 KiB
Rust
271 lines
13 KiB
Rust
//! 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())]);
|
||
}
|
||
}
|