mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 04:22:46 +00:00
Bands that start at different offsets across the flow (a centred title above a card) are lined up at the smallest offset, which moves some of them, so the result now carries a warning for it. Instances and layers inside them are refused before anything changes, with the same error Convert to group gives; a main component still works. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
326 lines
14 KiB
Rust
326 lines
14 KiB
Rust
//! Suggest auto layout (#1823): a frame's layers are split into rows or columns by the gaps between
|
|
//! them, each band of several layers gets a frame of its own, and the design keeps its placement.
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::Session;
|
|
use crate::params::id_of;
|
|
use figcraft_doc::{LayoutMode, NodeId, NodeKind, Sizing};
|
|
|
|
fn id(v: &Value) -> NodeId {
|
|
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 frame(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
|
id(&ex(s, "frame.create", json!({"x": x, "y": y, "width": w, "height": h})))
|
|
}
|
|
|
|
fn rect(s: &mut Session, parent: NodeId, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
|
id(&ex(s, "shape.rectangle", json!({"parent": parent, "x": x, "y": y, "width": w, "height": h})))
|
|
}
|
|
|
|
/// A layer's box on the page: `[x0, y0, x1, y1]`, rounded.
|
|
fn page_box(s: &Session, n: NodeId) -> [f64; 4] {
|
|
let b = s.doc().unwrap().world_bounds(n).unwrap();
|
|
[b.x0, b.y0, b.x1, b.y1].map(|v| (v * 1000.0).round() / 1000.0)
|
|
}
|
|
|
|
fn boxes(s: &Session, ns: &[NodeId]) -> Vec<[f64; 4]> {
|
|
ns.iter().map(|n| page_box(s, *n)).collect()
|
|
}
|
|
|
|
fn kids(s: &Session, n: NodeId) -> Vec<NodeId> {
|
|
s.doc().unwrap().node(n).unwrap().children.clone()
|
|
}
|
|
|
|
fn mode(s: &Session, n: NodeId) -> Option<LayoutMode> {
|
|
s.doc().unwrap().node(n).unwrap().layout.as_ref().map(|l| l.mode)
|
|
}
|
|
|
|
/// A frame made by Suggest auto layout: no fill, no clipping, hugging what is in it.
|
|
fn assert_band(s: &Session, n: NodeId, m: LayoutMode) {
|
|
let node = s.doc().unwrap().node(n).unwrap();
|
|
assert!(matches!(node.kind, NodeKind::Frame), "a band is a frame");
|
|
assert!(node.fills.is_empty() && !node.clip, "a band has no fill and does not clip");
|
|
assert_eq!(mode(s, n), Some(m));
|
|
assert_eq!((node.child_layout.sizing_h, node.child_layout.sizing_v), (Sizing::Hug, Sizing::Hug));
|
|
}
|
|
|
|
fn warnings(r: &Value) -> Vec<String> {
|
|
r["warnings"].as_array().map(|a| a.iter().filter_map(|w| w.as_str().map(String::from)).collect()).unwrap_or_default()
|
|
}
|
|
|
|
/// The issue's design: four 50 x 50 squares in a 200 x 200 frame, two rows of two.
|
|
fn squares() -> (Session, NodeId, [NodeId; 4]) {
|
|
let mut s = Session::new();
|
|
let f = frame(&mut s, 100.0, 50.0, 200.0, 200.0);
|
|
let sq = [(0.0, 0.0), (60.0, 0.0), (0.0, 60.0), (60.0, 60.0)].map(|(x, y)| rect(&mut s, f, x, y, 50.0, 50.0));
|
|
(s, f, sq)
|
|
}
|
|
|
|
#[test]
|
|
fn two_rows_of_two_squares_become_a_vertical_frame_of_two_horizontal_rows() {
|
|
let (mut s, f, sq) = squares();
|
|
let before_frame = page_box(&s, f);
|
|
let before = boxes(&s, &sq);
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [f]}));
|
|
assert_eq!(result["ids"], json!([f]));
|
|
assert!(warnings(&result).is_empty(), "{result}");
|
|
assert_eq!(mode(&s, f), Some(LayoutMode::Vertical));
|
|
let rows = kids(&s, f);
|
|
assert_eq!(rows.len(), 2, "two rows");
|
|
for (row, pair) in rows.iter().zip([[sq[0], sq[1]], [sq[2], sq[3]]]) {
|
|
assert_band(&s, *row, LayoutMode::Horizontal);
|
|
assert_eq!(kids(&s, *row), pair);
|
|
}
|
|
let node = s.doc().unwrap().node(f).unwrap();
|
|
assert_eq!(node.child_layout.sizing_h, Sizing::Fixed, "the frame keeps its size");
|
|
assert_eq!(node.child_layout.sizing_v, Sizing::Fixed);
|
|
assert_eq!(page_box(&s, f), before_frame);
|
|
assert_eq!(boxes(&s, &sq), before, "every square stays where it was");
|
|
assert_eq!(s.selection(), vec![f]);
|
|
}
|
|
|
|
#[test]
|
|
fn suggesting_auto_layout_is_one_undo_step() {
|
|
let (mut s, f, sq) = squares();
|
|
let rows_before = kids(&s, f);
|
|
let steps = s.ds().unwrap().history.undo.len();
|
|
ex(&mut s, "layout.suggest", json!({"ids": [f]}));
|
|
assert_eq!(s.ds().unwrap().history.undo.len(), steps + 1);
|
|
let rows = kids(&s, f);
|
|
s.undo().unwrap();
|
|
assert_eq!(kids(&s, f), rows_before);
|
|
assert_eq!(mode(&s, f), None);
|
|
assert!(rows.iter().all(|r| s.doc().unwrap().node(*r).is_none()), "the band frames are gone");
|
|
assert_eq!(
|
|
boxes(&s, &sq),
|
|
[[100.0, 50.0, 150.0, 100.0], [160.0, 50.0, 210.0, 100.0], [100.0, 110.0, 150.0, 160.0], [160.0, 110.0, 210.0, 160.0]]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn three_squares_in_a_row_get_one_horizontal_layout_without_nesting() {
|
|
let mut s = Session::new();
|
|
let f = frame(&mut s, 20.0, 30.0, 200.0, 100.0);
|
|
let sq = [0.0, 70.0, 140.0].map(|x| rect(&mut s, f, x, 25.0, 50.0, 50.0));
|
|
let before = boxes(&s, &sq);
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [f]}));
|
|
assert!(warnings(&result).is_empty(), "{result}");
|
|
assert_eq!(mode(&s, f), Some(LayoutMode::Horizontal));
|
|
assert_eq!(kids(&s, f), sq, "the squares are the frame's own layers");
|
|
assert_eq!(boxes(&s, &sq), before);
|
|
}
|
|
|
|
#[test]
|
|
fn three_squares_in_a_column_get_one_vertical_layout_without_nesting() {
|
|
let mut s = Session::new();
|
|
let f = frame(&mut s, 20.0, 30.0, 100.0, 200.0);
|
|
let sq = [0.0, 70.0, 140.0].map(|y| rect(&mut s, f, 25.0, y, 50.0, 50.0));
|
|
let before = boxes(&s, &sq);
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [f]}));
|
|
assert!(warnings(&result).is_empty(), "{result}");
|
|
assert_eq!(mode(&s, f), Some(LayoutMode::Vertical));
|
|
assert_eq!(kids(&s, f), sq);
|
|
assert_eq!(boxes(&s, &sq), before);
|
|
}
|
|
|
|
#[test]
|
|
fn a_header_above_two_columns_of_squares_gives_a_row_per_line() {
|
|
let mut s = Session::new();
|
|
let f = frame(&mut s, 0.0, 0.0, 200.0, 200.0);
|
|
let header = rect(&mut s, f, 0.0, 0.0, 200.0, 50.0);
|
|
let sq = [(0.0, 60.0), (0.0, 120.0), (110.0, 60.0), (110.0, 120.0)].map(|(x, y)| rect(&mut s, f, x, y, 50.0, 50.0));
|
|
let all = [vec![header], sq.to_vec()].concat();
|
|
let before = boxes(&s, &all);
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [f]}));
|
|
assert!(warnings(&result).is_empty(), "{result}");
|
|
// The squares line up in two rows, so the gaps between rows win over the gap between columns.
|
|
assert_eq!(mode(&s, f), Some(LayoutMode::Vertical));
|
|
let top = kids(&s, f);
|
|
assert_eq!(top.len(), 3);
|
|
assert_eq!(top.first(), Some(&header));
|
|
assert_band(&s, top[1], LayoutMode::Horizontal);
|
|
assert_eq!(kids(&s, top[1]), vec![sq[0], sq[2]]);
|
|
assert_band(&s, top[2], LayoutMode::Horizontal);
|
|
assert_eq!(kids(&s, top[2]), vec![sq[1], sq[3]]);
|
|
assert_eq!(boxes(&s, &all), before);
|
|
}
|
|
|
|
#[test]
|
|
fn a_header_above_a_tall_layer_beside_a_column_nests_three_levels() {
|
|
let mut s = Session::new();
|
|
let f = frame(&mut s, 40.0, 10.0, 200.0, 200.0);
|
|
let header = rect(&mut s, f, 0.0, 0.0, 200.0, 40.0);
|
|
let tall = rect(&mut s, f, 0.0, 60.0, 50.0, 110.0);
|
|
let sq = [60.0, 120.0].map(|y| rect(&mut s, f, 110.0, y, 50.0, 50.0));
|
|
let all = [header, tall, sq[0], sq[1]];
|
|
let before = boxes(&s, &all);
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [f]}));
|
|
assert!(warnings(&result).is_empty(), "{result}");
|
|
assert_eq!(mode(&s, f), Some(LayoutMode::Vertical));
|
|
let top = kids(&s, f);
|
|
assert_eq!(top.len(), 2);
|
|
assert_eq!(top.first(), Some(&header));
|
|
let band = top[1];
|
|
assert_band(&s, band, LayoutMode::Horizontal);
|
|
let cols = kids(&s, band);
|
|
assert_eq!(cols.len(), 2);
|
|
assert_eq!(cols.first(), Some(&tall));
|
|
assert_band(&s, cols[1], LayoutMode::Vertical);
|
|
assert_eq!(kids(&s, cols[1]), sq);
|
|
assert_eq!(boxes(&s, &all), before);
|
|
}
|
|
|
|
#[test]
|
|
fn unequal_gaps_use_the_smallest_and_say_so() {
|
|
let mut s = Session::new();
|
|
let f = frame(&mut s, 0.0, 0.0, 300.0, 100.0);
|
|
let sq = [0.0, 60.0, 130.0].map(|x| rect(&mut s, f, x, 10.0, 50.0, 50.0));
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [f]}));
|
|
assert_eq!(mode(&s, f), Some(LayoutMode::Horizontal));
|
|
let w = warnings(&result);
|
|
assert_eq!(w.len(), 1, "{result}");
|
|
assert!(w[0].contains("gaps"), "{}", w[0]);
|
|
let b = boxes(&s, &sq);
|
|
assert_eq!(b[0], [0.0, 10.0, 50.0, 60.0]);
|
|
assert_eq!(b[1], [60.0, 10.0, 110.0, 60.0]);
|
|
assert_eq!(b[2], [120.0, 10.0, 170.0, 60.0], "the last square moves along the row only");
|
|
}
|
|
|
|
#[test]
|
|
fn overlapping_layers_leave_the_frame_without_auto_layout_and_say_so() {
|
|
let mut s = Session::new();
|
|
let f = frame(&mut s, 0.0, 0.0, 200.0, 200.0);
|
|
let a = rect(&mut s, f, 0.0, 0.0, 100.0, 100.0);
|
|
let b = rect(&mut s, f, 50.0, 50.0, 100.0, 100.0);
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [f]}));
|
|
assert_eq!(mode(&s, f), None);
|
|
assert_eq!(kids(&s, f), vec![a, b]);
|
|
assert_eq!(warnings(&result).len(), 1, "{result}");
|
|
assert_eq!(boxes(&s, &[a, b]), vec![[0.0, 0.0, 100.0, 100.0], [50.0, 50.0, 150.0, 150.0]]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_group_becomes_a_frame_in_place_and_follows_the_rules() {
|
|
let mut s = Session::new();
|
|
let page = s.page().unwrap();
|
|
let (a, b, c, d) = (
|
|
rect(&mut s, page, 10.0, 20.0, 40.0, 40.0),
|
|
rect(&mut s, page, 70.0, 20.0, 40.0, 40.0),
|
|
rect(&mut s, page, 10.0, 80.0, 40.0, 40.0),
|
|
rect(&mut s, page, 70.0, 80.0, 40.0, 40.0),
|
|
);
|
|
let group = id(&ex(&mut s, "object.group", json!({"ids": [a, b, c, d]})));
|
|
let before = boxes(&s, &[group, a, b, c, d]);
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [group]}));
|
|
assert_eq!(result["ids"], json!([group]), "the group keeps its identity");
|
|
assert!(matches!(s.doc().unwrap().node(group).unwrap().kind, NodeKind::Frame));
|
|
assert_eq!(mode(&s, group), Some(LayoutMode::Vertical));
|
|
let rows = kids(&s, group);
|
|
assert_eq!(rows.len(), 2);
|
|
assert_band(&s, rows[0], LayoutMode::Horizontal);
|
|
assert_eq!(kids(&s, rows[0]), vec![a, b]);
|
|
assert_eq!(kids(&s, rows[1]), vec![c, d]);
|
|
assert_eq!(boxes(&s, &[group, a, b, c, d]), before);
|
|
}
|
|
|
|
#[test]
|
|
fn several_layers_are_wrapped_in_a_frame_that_is_then_split() {
|
|
let mut s = Session::new();
|
|
let page = s.page().unwrap();
|
|
let sq = [(0.0, 0.0), (80.0, 0.0), (0.0, 80.0), (80.0, 80.0)].map(|(x, y)| rect(&mut s, page, x, y, 50.0, 50.0));
|
|
let before = boxes(&s, &sq);
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": sq}));
|
|
let wrapper = id_of(&result["ids"][0]).unwrap();
|
|
assert!(s.doc().unwrap().node(wrapper).unwrap().fills.is_empty());
|
|
assert_eq!(mode(&s, wrapper), Some(LayoutMode::Vertical));
|
|
assert_eq!(kids(&s, wrapper).len(), 2);
|
|
assert_eq!(boxes(&s, &sq), before);
|
|
}
|
|
|
|
#[test]
|
|
fn a_direction_forces_the_top_level_and_nested_frames_follow_the_gaps() {
|
|
let (mut s, f, sq) = squares();
|
|
let before = boxes(&s, &sq);
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [f], "direction": "horizontal"}));
|
|
assert!(warnings(&result).is_empty(), "{result}");
|
|
assert_eq!(mode(&s, f), Some(LayoutMode::Horizontal));
|
|
let cols = kids(&s, f);
|
|
assert_eq!(cols.len(), 2, "two columns");
|
|
assert_band(&s, cols[0], LayoutMode::Vertical);
|
|
assert_eq!(kids(&s, cols[0]), vec![sq[0], sq[2]]);
|
|
assert_band(&s, cols[1], LayoutMode::Vertical);
|
|
assert_eq!(kids(&s, cols[1]), vec![sq[1], sq[3]]);
|
|
assert_eq!(boxes(&s, &sq), before);
|
|
}
|
|
|
|
#[test]
|
|
fn a_frame_with_one_layer_is_laid_out_like_add_auto_layout() {
|
|
let mut s = Session::new();
|
|
let f = frame(&mut s, 0.0, 0.0, 200.0, 100.0);
|
|
let a = rect(&mut s, f, 20.0, 20.0, 60.0, 40.0);
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [f]}));
|
|
assert!(warnings(&result).is_empty(), "{result}");
|
|
assert_eq!(mode(&s, f), Some(LayoutMode::Horizontal));
|
|
assert_eq!(kids(&s, f), vec![a]);
|
|
}
|
|
|
|
#[test]
|
|
fn add_auto_layout_still_makes_a_single_row() {
|
|
let (mut s, f, sq) = squares();
|
|
ex(&mut s, "layout.add", json!({"ids": [f]}));
|
|
let mut got = kids(&s, f);
|
|
got.sort();
|
|
assert_eq!(got, sq.to_vec(), "no nested frames");
|
|
}
|
|
|
|
#[test]
|
|
fn a_centred_title_above_a_card_is_lined_up_at_the_card_edge_with_a_warning() {
|
|
let mut s = Session::new();
|
|
let f = frame(&mut s, 0.0, 0.0, 200.0, 200.0);
|
|
let title = rect(&mut s, f, 60.0, 0.0, 80.0, 30.0);
|
|
let card = rect(&mut s, f, 0.0, 40.0, 200.0, 100.0);
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [f]}));
|
|
assert_eq!(mode(&s, f), Some(LayoutMode::Vertical));
|
|
assert_eq!(kids(&s, f), vec![title, card]);
|
|
let w = warnings(&result);
|
|
assert_eq!(w.len(), 1, "{result}");
|
|
assert!(w[0].contains("different offsets"), "{}", w[0]);
|
|
assert_eq!(page_box(&s, title), [0.0, 0.0, 80.0, 30.0], "the title's left edge is the card's now");
|
|
assert_eq!(page_box(&s, card), [0.0, 40.0, 200.0, 140.0], "the rest stays");
|
|
}
|
|
|
|
#[test]
|
|
fn instances_and_layers_inside_them_are_refused_and_a_main_component_works() {
|
|
let mut s = Session::new();
|
|
let f = frame(&mut s, 0.0, 0.0, 200.0, 200.0);
|
|
rect(&mut s, f, 0.0, 0.0, 50.0, 50.0);
|
|
rect(&mut s, f, 0.0, 60.0, 50.0, 30.0);
|
|
let nested = id(&ex(&mut s, "frame.create", json!({"parent": f, "x": 0, "y": 100, "width": 100, "height": 50})));
|
|
rect(&mut s, nested, 0.0, 0.0, 20.0, 20.0);
|
|
rect(&mut s, nested, 40.0, 0.0, 20.0, 20.0);
|
|
let comp = id(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
|
let inst = id(&ex(&mut s, "instance.create", json!({"component": comp, "x": 400, "y": 0})));
|
|
let inside: Vec<NodeId> = kids(&s, inst).into_iter().filter(|k| matches!(s.doc().unwrap().node(*k).unwrap().kind, NodeKind::Frame)).collect();
|
|
assert!(!inside.is_empty(), "the instance has a frame of its own");
|
|
let page = s.page().unwrap();
|
|
let snapshot = |s: &Session| (kids(s, page), kids(s, inst), kids(s, inside[0]));
|
|
let before = snapshot(&s);
|
|
let steps = s.ds().unwrap().history.undo.len();
|
|
for target in [inst, inside[0]] {
|
|
let e = s.execute("layout.suggest", &json!({"ids": [target]})).unwrap_err().to_string();
|
|
assert!(e.contains("instance"), "{e}");
|
|
}
|
|
assert_eq!(snapshot(&s), before, "nothing changed");
|
|
assert_eq!(s.ds().unwrap().history.undo.len(), steps, "no history step");
|
|
assert!(s.doc().unwrap().node(inst).unwrap().layout.is_none());
|
|
// The main component is a frame and works.
|
|
let result = ex(&mut s, "layout.suggest", json!({"ids": [comp]}));
|
|
assert_eq!(result["ids"], json!([comp]));
|
|
}
|