mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 18:26:50 -06:00
- compose: a test frame literal named section_marker twice. - export: PPTX rasterizing keeps a wrap stand-in for each rasterized object (next's rasterize_where takes keep_wrap). - flex: to_bytes returns a Result on main. - package, combined_close_glyph test: load_document_fonts takes an access check. - pptx test: Graphic literal gains next's clip and wrap fields. - render placed_pdf test: PdfCrop imported twice.
582 lines
27 KiB
Rust
582 lines
27 KiB
Rust
//! Flex Layout: Object › Flex Layout and the Flex Layout panel. A container is a frame whose
|
||
//! nested objects [`designcraft_doc::flex::layout`] places; [`sync`] lays every container out
|
||
//! after each edit, so moving, resizing or editing an object inside one reflows it.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use designcraft_doc::flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize};
|
||
use designcraft_doc::{Content, Document, Item, ItemId, Selection, Shape};
|
||
use designcraft_geom::{Rect, shapes};
|
||
use serde_json::{Value, json};
|
||
|
||
use super::object::RemoveKeep;
|
||
use super::{CommandSpec, bad, cmd, has_doc, has_selection, id_param, targets};
|
||
use crate::{Result, Session};
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"flex.add",
|
||
"Add to Flex Layout",
|
||
["Object", "Flex Layout"],
|
||
None,
|
||
"{ids?} — the selected objects (top level, one spread) in a new Flex Layout container where they are; aligned objects get that direction, Auto size and Space Between, others a wrapping row of their width. One group or empty rectangle frame is converted instead (flex.convert) → {id}",
|
||
has_selection,
|
||
add
|
||
),
|
||
cmd!(
|
||
"flex.convert",
|
||
"Convert to Flex Layout",
|
||
["Object", "Flex Layout"],
|
||
None,
|
||
"{ids?} — a group (its objects) or a rectangle frame (with what is pasted into it) becomes a Flex Layout container → {id}",
|
||
has_selection,
|
||
convert
|
||
),
|
||
cmd!(
|
||
"flex.remove",
|
||
"Remove Flex Layout",
|
||
["Object", "Flex Layout"],
|
||
None,
|
||
"{ids?} — the container goes; its objects stay where they are, grouped when there are several (a container with its own fill or stroke stays as a frame holding them) → {ids}",
|
||
is_container,
|
||
remove
|
||
),
|
||
cmd!(
|
||
"flex.set",
|
||
"Flex Layout Options",
|
||
[],
|
||
None,
|
||
"{ids?, direction?: row|rowReverse|column|columnReverse, wrap?: bool, justify?: flexStart|center|flexEnd|spaceBetween|spaceAround|spaceEvenly, align?: start|center|end (across), cell?: 0..8 (the alignment grid, row by row from the top left: sets justification and alignment by direction), width?: auto|fixed|number, height?: auto|fixed|number, padding?: number|[top, left, bottom, right], gapH?, gapV?} → {changed}",
|
||
is_container,
|
||
set
|
||
),
|
||
cmd!(
|
||
"flex.reorder",
|
||
"Reorder in Flex Layout",
|
||
[],
|
||
None,
|
||
"{id (an object in a container), to?: index | by?: ±n} — its place in the container's order → {index}",
|
||
has_doc,
|
||
reorder
|
||
),
|
||
]
|
||
}
|
||
|
||
fn is_container(s: &Session) -> std::result::Result<(), String> {
|
||
has_selection(s)?;
|
||
let st = s.active().ok_or("no document")?;
|
||
if st.selection.items.iter().any(|i| st.doc.item(*i).is_some_and(|it| it.flex.is_some())) {
|
||
Ok(())
|
||
} else {
|
||
Err("select a Flex Layout container".into())
|
||
}
|
||
}
|
||
|
||
/// Lay out every container (called after each edit). Spreads without one aren't copied.
|
||
pub(crate) fn sync(d: &mut Document) {
|
||
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
|
||
if sp.items.iter().any(|i| designcraft_doc::flex::contains_flex(i)) {
|
||
designcraft_doc::flex::layout_all(&mut Arc::make_mut(sp).items);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// InDesign's detection when objects become a container: objects in a row (or column) keep it,
|
||
/// with Auto size and Space Between at their own spacing; others wrap in a row of their width.
|
||
fn detect(bounds: &[Rect]) -> FlexLayout {
|
||
let tol = 0.5;
|
||
let in_line = |row: bool| {
|
||
let mut v: Vec<&Rect> = bounds.iter().collect();
|
||
v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) });
|
||
let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol });
|
||
let gaps: Vec<f64> = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect();
|
||
(apart && bounds.len() > 1).then(|| gaps.iter().sum::<f64>() / gaps.len().max(1) as f64)
|
||
};
|
||
let same = |f: &dyn Fn(&Rect) -> f64| bounds.windows(2).all(|w| (f(&w[0]) - f(&w[1])).abs() <= tol);
|
||
let align = |row: bool| {
|
||
let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) =
|
||
if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) };
|
||
if same(a) {
|
||
FlexAlign::Start
|
||
} else if same(c) {
|
||
FlexAlign::Center
|
||
} else if same(e) {
|
||
FlexAlign::End
|
||
} else {
|
||
FlexAlign::Start
|
||
}
|
||
};
|
||
let auto = |gap: f64, row: bool| FlexLayout {
|
||
direction: if row { FlexDirection::Row } else { FlexDirection::Column },
|
||
justify: FlexJustify::SpaceBetween,
|
||
align: align(row),
|
||
width: FlexSize::Auto,
|
||
height: FlexSize::Auto,
|
||
gap_h: if row { gap.max(0.0) } else { 0.0 },
|
||
gap_v: if row { 0.0 } else { gap.max(0.0) },
|
||
..Default::default()
|
||
};
|
||
match (in_line(true), in_line(false)) {
|
||
(Some(gap), _) => auto(gap, true),
|
||
(None, Some(gap)) => auto(gap, false),
|
||
_ => FlexLayout { wrap: true, height: FlexSize::Auto, ..Default::default() },
|
||
}
|
||
}
|
||
|
||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = targets(s, p)?;
|
||
if let [one] = ids.as_slice()
|
||
&& s.doc()?.doc.item(*one).is_some_and(convertible)
|
||
{
|
||
return convert(s, p);
|
||
}
|
||
if ids.is_empty() {
|
||
return Err(bad("flex.add", "select the objects to put in a Flex Layout"));
|
||
}
|
||
s.edit(|d, sel| {
|
||
let sr = d.find(ids[0]).map(|l| l.spread).ok_or(designcraft_doc::DocError::NoItem(ids[0]))?;
|
||
let order: Vec<ItemId> = d.spread(sr).map(|sp| sp.items.iter().map(|i| i.id).filter(|i| ids.contains(i)).collect()).unwrap_or_default();
|
||
if order.len() != ids.len() {
|
||
return Err(bad("flex.add", "select objects on one spread, not inside a group or frame"));
|
||
}
|
||
let front = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| Some(&i.id) == order.last())).unwrap_or(0);
|
||
let layer = d.item(order[0]).map(|i| i.layer).unwrap_or_default();
|
||
let mut kids: Vec<Item> = Vec::new();
|
||
for id in &order {
|
||
kids.push(d.remove_item_keep_story(*id)?);
|
||
}
|
||
// Their order in the container: along the line they are in, else top to bottom, left to right.
|
||
let bounds: Vec<Rect> = kids.iter().map(Item::bounds).collect();
|
||
let f = detect(&bounds);
|
||
// A row reads left to right (wrapped: row by row); a column top to bottom (wrapped: column by
|
||
// column).
|
||
let key = |r: &Rect| if f.direction.is_row() != f.wrap { (r.x0, r.y0) } else { (r.y0, r.x0) };
|
||
kids.sort_by(|a, b| {
|
||
let (ka, kb) = (key(&a.bounds()), key(&b.bounds()));
|
||
ka.0.total_cmp(&kb.0).then(ka.1.total_cmp(&kb.1))
|
||
});
|
||
let rect = bounds.iter().copied().reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO);
|
||
let cid = ItemId(d.alloc());
|
||
let mut c = Item::new(cid, layer, Shape::Rectangle, shapes::rectangle(rect));
|
||
c.content = Content::Group { items: kids.into_iter().map(Arc::new).collect() };
|
||
c.flex = Some(f);
|
||
d.insert_item(sr, c, Some(front + 1 - order.len()))?;
|
||
*sel = Selection::items(vec![cid]);
|
||
Ok(json!({"id": cid.0}))
|
||
})
|
||
}
|
||
|
||
/// A group, or a rectangle frame holding nothing or pasted-in objects.
|
||
fn convertible(it: &Item) -> bool {
|
||
it.flex.is_none() && (it.is_group() || it.shape == Shape::Rectangle && matches!(it.content, Content::Unassigned | Content::Group { .. }))
|
||
}
|
||
|
||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = targets(s, p)?;
|
||
let [id] = ids.as_slice() else { return Err(bad("flex.convert", "select one group or rectangle frame")) };
|
||
let id = *id;
|
||
s.edit(|d, sel| {
|
||
let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?.clone();
|
||
if !convertible(&it) {
|
||
return Err(bad("flex.convert", "select one group or rectangle frame"));
|
||
}
|
||
let kids: Vec<Arc<Item>> = match &it.content {
|
||
Content::Group { items } => items.clone(),
|
||
_ => Vec::new(),
|
||
};
|
||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||
if it.is_group() {
|
||
// The group's box becomes the container's, in its own space.
|
||
let rect = kids.iter().map(|k| k.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO);
|
||
it.shape = Shape::Rectangle;
|
||
it.path = shapes::rectangle(rect);
|
||
}
|
||
let bounds: Vec<Rect> = kids.iter().map(|k| k.bounds()).collect();
|
||
it.flex = Some(if bounds.is_empty() { FlexLayout::default() } else { detect(&bounds) });
|
||
it.content = Content::Group { items: kids };
|
||
*sel = Selection::items(vec![id]);
|
||
Ok(json!({"id": id.0}))
|
||
})
|
||
}
|
||
|
||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = targets(s, p)?;
|
||
s.edit(|d, sel| {
|
||
let mut out = Vec::new();
|
||
for id in &ids {
|
||
let Some(loc) = d.find(*id) else { continue };
|
||
let Some(c) = d.item(*id).cloned() else { continue };
|
||
if c.flex.is_none() {
|
||
continue;
|
||
}
|
||
let kids = match &c.content {
|
||
Content::Group { items } => items.clone(),
|
||
_ => Vec::new(),
|
||
};
|
||
// A frame with its own look stays one, holding its objects.
|
||
if !c.fill.is_none() || !c.stroke.is_none() || kids.is_empty() {
|
||
if let Some(it) = d.item_mut(*id) {
|
||
it.flex = None;
|
||
}
|
||
out.push(*id);
|
||
continue;
|
||
}
|
||
if loc.path.len() != 1 {
|
||
return Err(bad("flex.remove", "a Flex Layout inside a group or another container keeps its objects; remove the outer one first"));
|
||
}
|
||
let Some(sp) = d.spread_mut(loc.spread) else { continue };
|
||
sp.items.remove(loc.path[0]);
|
||
let new = if let [one] = kids.as_slice() {
|
||
let mut k = (**one).clone();
|
||
k.xf = c.xf * k.xf;
|
||
k
|
||
} else {
|
||
let mut g = Item::new(c.id, c.layer, Shape::Group, designcraft_geom::PathData::default());
|
||
g.xf = c.xf;
|
||
g.content = Content::Group { items: kids };
|
||
g
|
||
};
|
||
out.push(new.id);
|
||
sp.items.insert(loc.path[0], Arc::new(new));
|
||
}
|
||
*sel = Selection::items(out.clone());
|
||
Ok(json!({"ids": out.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||
})
|
||
}
|
||
|
||
fn parse<T: serde::de::DeserializeOwned>(p: &Value, key: &str) -> Result<Option<T>> {
|
||
match p.get(key) {
|
||
None => Ok(None),
|
||
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|_| bad("flex.set", format!("`{key}`: unknown value {v}"))),
|
||
}
|
||
}
|
||
|
||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = targets(s, p)?;
|
||
let direction: Option<FlexDirection> = parse(p, "direction")?;
|
||
let justify: Option<FlexJustify> = parse(p, "justify")?;
|
||
let align: Option<FlexAlign> = parse(p, "align")?;
|
||
let cell = p.get("cell").and_then(Value::as_u64);
|
||
if cell.is_some_and(|c| c > 8) {
|
||
return Err(bad("flex.set", "`cell` is 0..8"));
|
||
}
|
||
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 100_000.0));
|
||
let padding: Option<[f64; 4]> = match p.get("padding") {
|
||
None => None,
|
||
Some(Value::Number(n)) => n.as_f64().filter(|v| v.is_finite()).map(|v| [v.clamp(0.0, 100_000.0); 4]),
|
||
Some(v) => {
|
||
let a: Vec<f64> = serde_json::from_value(v.clone()).map_err(|_| bad("flex.set", "`padding`: a number or [top, left, bottom, right]"))?;
|
||
let [t, l, b, r] = a.as_slice() else { return Err(bad("flex.set", "`padding`: a number or [top, left, bottom, right]")) };
|
||
Some([*t, *l, *b, *r].map(|v| if v.is_finite() { v.clamp(0.0, 100_000.0) } else { 0.0 }))
|
||
}
|
||
};
|
||
let size = |k: &str| -> Result<Option<(FlexSize, Option<f64>)>> {
|
||
match p.get(k) {
|
||
None => Ok(None),
|
||
Some(Value::String(s)) if s == "auto" => Ok(Some((FlexSize::Auto, None))),
|
||
Some(Value::String(s)) if s == "fixed" => Ok(Some((FlexSize::Fixed, None))),
|
||
Some(Value::Number(n)) => Ok(n.as_f64().filter(|v| v.is_finite() && *v > 0.0).map(|v| (FlexSize::Fixed, Some(v.min(100_000.0))))),
|
||
Some(v) => Err(bad("flex.set", format!("`{k}`: auto, fixed or a size, not {v}"))),
|
||
}
|
||
};
|
||
let (width, height) = (size("width")?, size("height")?);
|
||
let (gap_h, gap_v, wrap) = (num("gapH"), num("gapV"), p.get("wrap").and_then(Value::as_bool));
|
||
s.edit(|d, _| {
|
||
let mut changed = 0;
|
||
for id in &ids {
|
||
let Some(it) = d.item_mut(*id) else { continue };
|
||
let Some(f) = it.flex.as_mut() else { continue };
|
||
if let Some(v) = direction {
|
||
f.direction = v;
|
||
}
|
||
if let Some(v) = wrap {
|
||
f.wrap = v;
|
||
}
|
||
if let Some(v) = justify {
|
||
f.justify = v;
|
||
}
|
||
if let Some(v) = align {
|
||
f.align = v;
|
||
}
|
||
if let Some(c) = cell {
|
||
// The grid is what you see: its column is left/centre/right, its row top/middle/bottom.
|
||
let pos = |i: u64| [FlexAlign::Start, FlexAlign::Center, FlexAlign::End][i as usize % 3];
|
||
let (h, v) = (pos(c % 3), pos(c / 3));
|
||
let (main, cross) = if f.direction.is_row() { (h, v) } else { (v, h) };
|
||
let main = if f.direction.is_reverse() {
|
||
match main {
|
||
FlexAlign::Start => FlexAlign::End,
|
||
FlexAlign::End => FlexAlign::Start,
|
||
m => m,
|
||
}
|
||
} else {
|
||
main
|
||
};
|
||
if !f.justify.distributes() {
|
||
f.justify = match main {
|
||
FlexAlign::Start => FlexJustify::FlexStart,
|
||
FlexAlign::Center => FlexJustify::Center,
|
||
FlexAlign::End => FlexJustify::FlexEnd,
|
||
};
|
||
}
|
||
f.align = cross;
|
||
}
|
||
if let Some(v) = padding {
|
||
f.padding = v;
|
||
}
|
||
if let Some(v) = gap_h {
|
||
f.gap_h = v;
|
||
}
|
||
if let Some(v) = gap_v {
|
||
f.gap_v = v;
|
||
}
|
||
let r = it.inner_bounds();
|
||
let mut rect = r;
|
||
if let Some((mode, value)) = width {
|
||
if let Some(f) = it.flex.as_mut() {
|
||
f.width = mode;
|
||
}
|
||
if let Some(w) = value {
|
||
rect.x1 = rect.x0 + w;
|
||
}
|
||
}
|
||
if let Some((mode, value)) = height {
|
||
if let Some(f) = it.flex.as_mut() {
|
||
f.height = mode;
|
||
}
|
||
if let Some(h) = value {
|
||
rect.y1 = rect.y0 + h;
|
||
}
|
||
}
|
||
if rect != r {
|
||
it.path = shapes::rectangle(rect);
|
||
}
|
||
changed += 1;
|
||
}
|
||
Ok(json!({"changed": changed}))
|
||
})
|
||
}
|
||
|
||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let id = id_param(p, "id").ok_or_else(|| bad("flex.reorder", "missing `id`"))?;
|
||
let to = p.get("to").and_then(Value::as_i64);
|
||
let by = p.get("by").and_then(Value::as_i64);
|
||
if to.is_none() && by.is_none() {
|
||
return Err(bad("flex.reorder", "give `to` or `by`"));
|
||
}
|
||
s.edit(|d, sel| {
|
||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||
let (last, parent_path) = loc.path.split_last().ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||
let parent_loc = designcraft_doc::ItemLoc { spread: loc.spread, path: parent_path.to_vec() };
|
||
let parent = d
|
||
.item_at(&parent_loc)
|
||
.filter(|c| c.flex.is_some())
|
||
.map(|c| c.id)
|
||
.ok_or_else(|| bad("flex.reorder", "the object isn't in a Flex Layout"))?;
|
||
let c = d.item_mut(parent).ok_or(designcraft_doc::DocError::NoItem(parent))?;
|
||
let Content::Group { items } = &mut c.content else { return Err(bad("flex.reorder", "the object isn't in a Flex Layout")) };
|
||
let n = items.len() as i64;
|
||
let from = *last as i64;
|
||
let target = to.unwrap_or(from.saturating_add(by.unwrap_or(0))).clamp(0, n - 1);
|
||
let moved = items.remove(from as usize);
|
||
items.insert(target as usize, moved);
|
||
*sel = Selection::items(vec![id]);
|
||
Ok(json!({"index": target}))
|
||
})
|
||
}
|
||
|
||
/// An object moved inside a container goes to the place it was dropped on: before the nearest
|
||
/// object if it lies before that one's centre along the main axis, else after it.
|
||
pub(crate) fn drop_in_container(d: &mut Document, id: ItemId) {
|
||
let Some(loc) = d.find(id) else { return };
|
||
let Some((&at, parent)) = loc.path.split_last() else { return };
|
||
let parent_loc = designcraft_doc::ItemLoc { spread: loc.spread, path: parent.to_vec() };
|
||
let Some(cid) = d.item_at(&parent_loc).filter(|c| c.flex.is_some()).map(|c| c.id) else { return };
|
||
let Some(c) = d.item_mut(cid) else { return };
|
||
let Some(f) = c.flex.clone() else { return };
|
||
let Content::Group { items } = &mut c.content else { return };
|
||
if at >= items.len() {
|
||
return;
|
||
}
|
||
let moved = items.remove(at);
|
||
let m = moved.bounds().center();
|
||
let along = |p: designcraft_geom::Point| if f.direction.is_row() { p.x } else { p.y };
|
||
let nearest = items.iter().enumerate().map(|(i, k)| (i, k.bounds().center())).min_by(|a, b| (a.1 - m).hypot().total_cmp(&(b.1 - m).hypot()));
|
||
let index = match nearest {
|
||
Some((i, k)) => {
|
||
let before = (along(m) < along(k)) != f.direction.is_reverse();
|
||
if before { i } else { i + 1 }
|
||
}
|
||
None => 0,
|
||
};
|
||
items.insert(index.min(items.len()), moved);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn session_with_boxes(boxes: &[[f64; 4]]) -> (Session, Vec<u64>) {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 1920, "height": 1080, "margins": 84})).unwrap();
|
||
let ids = boxes.iter().map(|r| s.execute("frame.create", &json!({"rect": r})).unwrap()["id"].as_u64().unwrap()).collect();
|
||
(s, ids)
|
||
}
|
||
|
||
fn rect_of(s: &Session, id: u64) -> Rect {
|
||
s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds()
|
||
}
|
||
|
||
#[test]
|
||
fn reorder_by_a_huge_step_clamps_instead_of_overflowing() {
|
||
let (mut s, ids) = session_with_boxes(&[[84.0, 84.0, 184.0, 184.0], [284.0, 84.0, 384.0, 184.0]]);
|
||
s.execute("selection.set", &json!({"ids": ids})).unwrap();
|
||
s.execute("flex.add", &json!({})).unwrap();
|
||
// `from + by` used to overflow (a panic with overflow checks on).
|
||
s.execute("flex.reorder", &json!({"id": ids[0], "by": i64::MAX})).unwrap();
|
||
s.execute("flex.reorder", &json!({"id": ids[0], "by": i64::MIN})).unwrap();
|
||
}
|
||
|
||
/// The video: four objects in a row become a container that keeps them where they are (Row,
|
||
/// Auto size, Space Between at their spacing, aligned to the top), then a fixed 2-column layout.
|
||
#[test]
|
||
fn four_objects_in_a_row_then_two_wrapped_columns() {
|
||
// As in the video after Shift+Cmd shrinks them: 400 × 430, 22 apart.
|
||
let boxes = [[84.0, 461.0, 484.0, 891.0], [506.0, 461.0, 906.0, 891.0], [928.0, 461.0, 1328.0, 891.0], [1350.0, 461.0, 1750.0, 891.0]];
|
||
let (mut s, ids) = session_with_boxes(&boxes);
|
||
s.execute("selection.set", &json!({"ids": ids})).unwrap();
|
||
let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||
let d = s.doc().unwrap().doc.clone();
|
||
let it = d.item(ItemId(c)).unwrap();
|
||
let f = it.flex.clone().unwrap();
|
||
assert_eq!(
|
||
(f.direction, f.justify, f.align, f.width, f.height, f.wrap),
|
||
(FlexDirection::Row, FlexJustify::SpaceBetween, FlexAlign::Start, FlexSize::Auto, FlexSize::Auto, false)
|
||
);
|
||
assert!((f.gap_h - 22.0).abs() < 1e-9);
|
||
assert_eq!(it.bounds(), Rect::new(84.0, 461.0, 1750.0, 891.0));
|
||
for (id, b) in ids.iter().zip(&boxes) {
|
||
assert_eq!(rect_of(&s, *id), Rect::new(b[0], b[1], b[2], b[3]), "nothing moves");
|
||
}
|
||
assert_eq!(d.spreads[0].items.len(), 1, "the objects are in the container");
|
||
// Resize the container (Fixed), Flex Start, a wrapping column, spacing, centred.
|
||
s.execute(
|
||
"flex.set",
|
||
&json!({"width": 868, "height": 912, "justify": "flexStart", "direction": "column", "wrap": true, "gapH": 56, "gapV": 38}),
|
||
)
|
||
.unwrap();
|
||
s.execute("flex.set", &json!({"cell": 4})).unwrap();
|
||
let f = s.doc().unwrap().doc.item(ItemId(c)).unwrap().flex.clone().unwrap();
|
||
assert_eq!((f.justify, f.align, f.width), (FlexJustify::Center, FlexAlign::Center, FlexSize::Fixed));
|
||
// Two columns of two: 400 + 56 + 400 across 868, 430 + 38 + 430 down 912, centred.
|
||
let r: Vec<Rect> = ids.iter().map(|i| rect_of(&s, *i)).collect();
|
||
assert_eq!(r[0].x0, r[1].x0);
|
||
assert_eq!(r[2].x0, r[3].x0);
|
||
assert!((r[2].x0 - r[0].x1 - 56.0).abs() < 1e-6);
|
||
assert!((r[1].y0 - r[0].y1 - 38.0).abs() < 1e-6);
|
||
let c_rect = s.doc().unwrap().doc.item(ItemId(c)).unwrap().bounds();
|
||
assert!((r[0].x0 - c_rect.x0 - 6.0).abs() < 1e-6 && (r[0].y0 - c_rect.y0 - 7.0).abs() < 1e-6, "{r:?} in {c_rect:?}");
|
||
// Reorder: 2 goes to the top of the second column.
|
||
s.execute("flex.reorder", &json!({"id": ids[1], "to": 2})).unwrap();
|
||
assert_eq!(rect_of(&s, ids[1]).x0, r[2].x0, "now in the second column");
|
||
assert_eq!(rect_of(&s, ids[1]).y0, r[0].y0);
|
||
// One undo step per command; undo the reorder.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(rect_of(&s, ids[1]), r[1]);
|
||
}
|
||
|
||
/// Resizing the container by its handles changes its box (Fixed), not its objects' size.
|
||
#[test]
|
||
fn resizing_a_container_reflows_instead_of_scaling() {
|
||
let (mut s, ids) = session_with_boxes(&[[100.0, 100.0, 200.0, 200.0], [220.0, 100.0, 320.0, 200.0]]);
|
||
s.execute("selection.set", &json!({"ids": ids})).unwrap();
|
||
let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("flex.set", &json!({"justify": "flexEnd"})).unwrap();
|
||
s.execute("transform.resize", &json!({"from": [100, 100, 320, 200], "to": [100, 100, 520, 300]})).unwrap();
|
||
let it = s.doc().unwrap().doc.item(ItemId(c)).unwrap().clone();
|
||
let b = it.bounds();
|
||
assert!((b.x1 - 520.0).abs() < 1e-6 && (b.y1 - 300.0).abs() < 1e-6 && b.x0 == 100.0, "{b:?}");
|
||
let f = it.flex.unwrap();
|
||
assert_eq!((f.width, f.height), (FlexSize::Fixed, FlexSize::Fixed));
|
||
let r1 = rect_of(&s, ids[1]);
|
||
assert!((r1.x1 - 520.0).abs() < 1e-6 && (r1.width() - 100.0).abs() < 1e-6 && r1.y0 == 100.0, "same size, at the end of the row: {r1:?}");
|
||
assert_eq!(rect_of(&s, ids[0]).width(), 100.0);
|
||
s.execute("transform.set", &json!({"width": 300, "ref": 0})).unwrap();
|
||
assert!((rect_of(&s, ids[1]).x1 - 400.0).abs() < 1e-6);
|
||
}
|
||
|
||
/// Moving an object inside the container is undone by the layout (it places them); a new
|
||
/// object in the container is laid out at once.
|
||
#[test]
|
||
fn the_layout_follows_every_edit() {
|
||
let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [150.0, 0.0, 250.0, 50.0]]);
|
||
s.execute("selection.set", &json!({"ids": ids})).unwrap();
|
||
s.execute("flex.add", &json!({})).unwrap();
|
||
s.execute("transform.set", &json!({"ids": [ids[0]], "width": 300, "ref": 0})).unwrap();
|
||
// Auto width: the container grows, the second object follows at the spacing.
|
||
assert_eq!(rect_of(&s, ids[1]).x0, 350.0);
|
||
}
|
||
|
||
#[test]
|
||
fn remove_groups_the_objects_where_they_are() {
|
||
let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [150.0, 0.0, 250.0, 50.0]]);
|
||
s.execute("selection.set", &json!({"ids": ids})).unwrap();
|
||
s.execute("flex.add", &json!({})).unwrap();
|
||
s.execute("flex.set", &json!({"justify": "flexStart", "gapH": 10})).unwrap();
|
||
let before = rect_of(&s, ids[1]);
|
||
let r = s.execute("flex.remove", &json!({})).unwrap();
|
||
let g = r["ids"][0].as_u64().unwrap();
|
||
let d = s.doc().unwrap().doc.clone();
|
||
assert!(d.item(ItemId(g)).unwrap().is_group());
|
||
assert_eq!(rect_of(&s, ids[1]), before);
|
||
assert_eq!(before.x0, 110.0);
|
||
// A group converts back.
|
||
s.execute("flex.convert", &json!({"ids": [g]})).unwrap();
|
||
assert!(s.doc().unwrap().doc.item(ItemId(g)).unwrap().flex.is_some());
|
||
}
|
||
|
||
/// Dragging an object in a container drops it in the nearest place (the video moves group 2 to
|
||
/// the top of the second column).
|
||
#[test]
|
||
fn dragging_an_object_reorders_the_container() {
|
||
let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [110.0, 0.0, 210.0, 50.0], [220.0, 0.0, 320.0, 50.0]]);
|
||
s.execute("selection.set", &json!({"ids": ids})).unwrap();
|
||
s.execute("flex.add", &json!({})).unwrap();
|
||
s.execute("flex.set", &json!({"justify": "flexStart"})).unwrap();
|
||
// The first box dropped past the third's centre: it goes last.
|
||
s.execute("transform.move", &json!({"ids": [ids[0]], "dx": 250, "dy": 5})).unwrap();
|
||
let x: Vec<f64> = ids.iter().map(|i| rect_of(&s, *i).x0.round()).collect();
|
||
assert_eq!(x, [220.0, 0.0, 110.0]);
|
||
// Back before the second (now at 0): first again.
|
||
s.execute("transform.move", &json!({"ids": [ids[0]], "dx": -225, "dy": 0})).unwrap();
|
||
let x: Vec<f64> = ids.iter().map(|i| rect_of(&s, *i).x0.round()).collect();
|
||
assert_eq!(x, [0.0, 110.0, 220.0]);
|
||
}
|
||
|
||
/// Flex settings are saved with the document.
|
||
#[test]
|
||
fn a_container_round_trips_through_the_file() {
|
||
let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [110.0, 0.0, 210.0, 50.0]]);
|
||
s.execute("selection.set", &json!({"ids": ids})).unwrap();
|
||
let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("flex.set", &json!({"direction": "columnReverse", "wrap": true, "padding": [1, 2, 3, 4], "gapV": 7})).unwrap();
|
||
let d = s.doc().unwrap().doc.clone();
|
||
let back = crate::cmd::from_bytes(&crate::cmd::to_bytes(&d).unwrap()).unwrap();
|
||
assert_eq!(back.item(ItemId(c)).unwrap().flex, d.item(ItemId(c)).unwrap().flex);
|
||
assert!(back.item(ItemId(c)).unwrap().flex.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn bad_params_are_refused() {
|
||
let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0]]);
|
||
s.execute("selection.set", &json!({"ids": ids})).unwrap();
|
||
s.execute("flex.add", &json!({})).unwrap();
|
||
assert!(s.execute("flex.set", &json!({"direction": "diagonal"})).is_err());
|
||
assert!(s.execute("flex.set", &json!({"cell": 9})).is_err());
|
||
assert!(s.execute("flex.set", &json!({"padding": [1, 2]})).is_err());
|
||
assert!(s.execute("flex.reorder", &json!({"id": ids[0]})).is_err());
|
||
s.execute("flex.set", &json!({"gapH": f64::MAX, "width": -5})).unwrap();
|
||
}
|
||
}
|