mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
The tail of the text area, from the public documentation of the target app's text settings, with a table-driven behaviour suite (each case derived in one line; "silent" cases pin today's behaviour).
- **Lists per paragraph** (`TextData::paragraphs`, one `ParaList {list, indent}` per paragraph; empty when the layer has one list style at level 0, so old files load and save unchanged). The list buttons, Shift+Cmd+8 / Shift+Cmd+7, `text.listStyle` and Type settings act on the paragraphs the selection touches while editing (the whole layer otherwise). The per-paragraph list follows edits: a new paragraph copies the one it split from, joined paragraphs keep the first one's.
- **Nesting** up to 8 levels: Tab nests a list item, Shift+Tab moves it out; Enter on an empty item and Backspace at an item's start move it a level out, then out of the list. Typing `- `, `* ` or `1. ` at the start of a plain paragraph starts a list. Numbers count per level (1. a. i., then round again), bullets go • ◦ ▪; a plain paragraph, a shallower item or the other list kind starts the count again. Each level indents by one marker gutter; a nested bullet a font lacks comes from a fallback font.
- **List spacing** (space between two items of a list, in place of paragraph spacing), **hanging lists** (the outer gutter outside the box, item text lined up with plain paragraphs) and **hanging punctuation** (opening quotes and brackets outside the start edge of left-aligned and justified lines, closing punctuation outside the end edge of right-aligned and justified ones; hung punctuation takes no room when lines wrap).
- **Decoration look** per character (`TextStyle::decoration_look`, boxed, None by default): style (solid, dotted, wavy), thickness and underline offset (auto, px or % of the font size), skip ink (underlines break around descenders) and colour (its own style index in the layout, so canvas, PNG and PDF paint it without renderer changes). Decorations are drawn from `Decoration::path()` (bar, dots or wave) on the canvas, in PNG, PDF and outlined SVG.
- `node.set` keys: `listStyle` and `indentation` (range-aware with start/end), `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`, `textDecorationThickness`, `textDecorationOffset`, `textDecorationSkipInk`, `textDecorationColor`; documented in `KEYS` and docs/mcp.md. `text.edit.key` documents Tab.
- Inspect: `listStyle`, or `lineTypes` and `lineIndentations` per paragraph; `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration keys. CSS: `text-decoration-style`, `-thickness`, `-color`, `text-underline-offset`, `text-decoration-skip-ink: none` (browsers skip ink by default). Dev Mode rows: decoration style, list spacing.
- REST import: `lineTypes` with `lineIndentations` per paragraph (they collapsed to one list style for the layer before), `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration style, thickness, offset, skip ink and colour; instance override names for them.
- Type settings ▸ Basics: decoration style, thickness and offset fields (with Auto), Skip ink, decoration colour; List spacing; Hanging punctuation and Hanging list checkboxes; the list control acts on the edited paragraphs. New strings in en/es.
- Components carry the new text fields in the characters and text-style override groups; scaling scales list spacing and px decoration lengths.
- `TextData::path` is boxed (`Option<Box<TextPath>>`) to keep `NodeKind` under clippy's enum size limit after the new fields.
440 lines
22 KiB
Rust
440 lines
22 KiB
Rust
use serde_json::json;
|
|
|
|
use super::*;
|
|
use figcraft_doc::NodeKind;
|
|
|
|
fn sess() -> Session {
|
|
Session::new()
|
|
}
|
|
|
|
fn id(v: &Value) -> NodeId {
|
|
params::id_of(&v["id"]).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn create_select_undo_redo() {
|
|
let mut s = sess();
|
|
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
|
|
let rid = id(&r);
|
|
assert_eq!(s.selection(), vec![rid]);
|
|
assert_eq!(s.doc().unwrap().world_bounds(rid).unwrap().x0, 10.0);
|
|
assert!(s.undo().unwrap());
|
|
assert!(!s.doc().unwrap().contains(rid));
|
|
assert!(s.redo().unwrap());
|
|
assert!(s.doc().unwrap().contains(rid));
|
|
}
|
|
|
|
#[test]
|
|
fn node_set_and_errors_roll_back() {
|
|
let mut s = sess();
|
|
let rid = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
|
s.execute("node.set", &json!({"id": rid, "props": {"fill": "#ff0000", "cornerRadius": 4, "opacity": 0.5, "name": "Red"}})).unwrap();
|
|
let n = s.doc().unwrap().node(rid).unwrap().clone();
|
|
assert_eq!(n.name, "Red");
|
|
assert_eq!(n.corners.radii, [4.0; 4]);
|
|
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF0000");
|
|
let undo = s.ds().unwrap().history.undo.len();
|
|
assert!(s.execute("node.set", &json!({"id": rid, "props": {"name": "X", "bogus": 1}})).is_err());
|
|
assert_eq!(s.doc().unwrap().node(rid).unwrap().name, "Red");
|
|
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
|
assert!(s.execute("no.such", &json!({})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn group_ungroup_and_frame() {
|
|
let mut s = sess();
|
|
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
|
let b = id(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap());
|
|
let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap());
|
|
let doc = s.doc().unwrap();
|
|
assert_eq!(doc.node(g).unwrap().kind.type_name(), "GROUP");
|
|
assert_eq!(doc.world_bounds(g).unwrap().width(), 60.0);
|
|
s.execute("object.ungroup", &json!({"ids": [g]})).unwrap();
|
|
assert!(!s.doc().unwrap().contains(g));
|
|
let f = id(&s.execute("object.frameSelection", &json!({"ids": [a, b]})).unwrap());
|
|
assert_eq!(s.doc().unwrap().node(a).unwrap().parent, Some(f));
|
|
assert_eq!(s.doc().unwrap().world_bounds(a).unwrap().x0, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_layout_add_hugs() {
|
|
let mut s = sess();
|
|
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap());
|
|
let b = id(&s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 40, "height": 20})).unwrap());
|
|
let r = s.execute("layout.add", &json!({"ids": [a, b]})).unwrap();
|
|
let f = params::id_of(&r["ids"][0]).unwrap();
|
|
let doc = s.doc().unwrap();
|
|
let fnode = doc.node(f).unwrap();
|
|
assert!(fnode.layout.is_some());
|
|
assert_eq!(fnode.width, 100.0);
|
|
// Change gap: frame hugs.
|
|
s.execute("node.set", &json!({"id": f, "props": {"gap": 0}})).unwrap();
|
|
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 80.0);
|
|
// Padding.
|
|
s.execute("node.set", &json!({"id": f, "props": {"padding": 10}})).unwrap();
|
|
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 100.0);
|
|
assert_eq!(s.doc().unwrap().node(f).unwrap().height, 40.0);
|
|
}
|
|
|
|
#[test]
|
|
fn text_create_autosize_and_edit() {
|
|
let mut s = sess();
|
|
let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap());
|
|
let w0 = s.doc().unwrap().node(t).unwrap().width;
|
|
textedit::begin(&mut s, t, Some(5), false).unwrap();
|
|
textedit::insert(&mut s, " world").unwrap();
|
|
textedit::key(&mut s, "Backspace", false, false, false).unwrap();
|
|
textedit::end(&mut s).unwrap();
|
|
let n = s.doc().unwrap().node(t).unwrap().clone();
|
|
assert_eq!(n.kind.text().unwrap().characters, "Hello worl");
|
|
if figcraft_text::find_face("Inter", "Regular").is_some() {
|
|
assert!(n.width > w0);
|
|
}
|
|
// One undo step for the whole edit session.
|
|
s.undo().unwrap();
|
|
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "Hello");
|
|
}
|
|
|
|
#[test]
|
|
fn components_and_instances_sync() {
|
|
let mut s = sess();
|
|
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 50, "name": "Button"})).unwrap());
|
|
let label = id(&s.execute("text.create", &json!({"text": "OK", "x": 10, "y": 10, "parent": f})).unwrap());
|
|
let c = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap());
|
|
assert_eq!(c, f);
|
|
let i = id(&s.execute("instance.create", &json!({"component": c, "x": 200, "y": 0})).unwrap());
|
|
let doc = s.doc().unwrap();
|
|
assert_eq!(doc.node(i).unwrap().children.len(), 1);
|
|
let inst_label = doc.node(i).unwrap().children[0];
|
|
// Edit the main: instance follows.
|
|
s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap();
|
|
assert_eq!(s.doc().unwrap().node(i).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
|
// Override text in the instance, then change main text: override wins.
|
|
s.execute("text.set", &json!({"id": inst_label, "characters": "Cancel"})).unwrap();
|
|
s.execute("text.set", &json!({"id": label, "characters": "Yes"})).unwrap();
|
|
let doc = s.doc().unwrap();
|
|
let kid = doc.node(i).unwrap().children[0];
|
|
assert_eq!(kid, inst_label, "ids are stable across syncs");
|
|
assert_eq!(doc.node(kid).unwrap().kind.text().unwrap().characters, "Cancel");
|
|
// Reset clears overrides.
|
|
s.execute("component.resetInstance", &json!({"ids": [i]})).unwrap();
|
|
let doc = s.doc().unwrap();
|
|
let kid = doc.node(i).unwrap().children[0];
|
|
assert_eq!(doc.node(kid).unwrap().kind.text().unwrap().characters, "Yes");
|
|
// Detach.
|
|
s.execute("component.detach", &json!({"ids": [i]})).unwrap();
|
|
assert_eq!(s.doc().unwrap().node(i).unwrap().kind.type_name(), "FRAME");
|
|
assert!(s.doc().unwrap().validate().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn variants_switch() {
|
|
let mut s = sess();
|
|
let a = id(&s
|
|
.execute(
|
|
"node.create",
|
|
&json!({"type": "component", "x": 0, "y": 0, "width": 40, "height": 40, "name": "State=Default", "props": {"fill": "#ff0000"}}),
|
|
)
|
|
.unwrap());
|
|
let b = id(&s
|
|
.execute(
|
|
"node.create",
|
|
&json!({"type": "component", "x": 60, "y": 0, "width": 40, "height": 40, "name": "State=Hover", "props": {"fill": "#0000ff"}}),
|
|
)
|
|
.unwrap());
|
|
s.execute("component.combineVariants", &json!({"ids": [a, b]})).unwrap();
|
|
let i = id(&s.execute("instance.create", &json!({"component": a, "x": 0, "y": 200})).unwrap());
|
|
s.execute("instance.setProperties", &json!({"ids": [i], "properties": {"State": "Hover"}})).unwrap();
|
|
let n = s.doc().unwrap().node(i).unwrap().clone();
|
|
assert!(matches!(n.kind, NodeKind::Instance { component, .. } if component == b));
|
|
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "0000FF");
|
|
}
|
|
|
|
#[test]
|
|
fn styles_and_variables() {
|
|
let mut s = sess();
|
|
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#123456"}})).unwrap());
|
|
let st = s.execute("style.create", &json!({"type": "paint", "name": "Brand/Primary", "from": r})).unwrap();
|
|
let sid = st["id"].as_str().unwrap().to_string();
|
|
let r2 = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap());
|
|
s.execute("style.apply", &json!({"ids": [r2], "style": "Brand/Primary"})).unwrap();
|
|
assert_eq!(s.doc().unwrap().node(r2).unwrap().fills[0].solid_color().unwrap().to_hex(), "123456");
|
|
s.execute("style.update", &json!({"style": sid, "value": ["#ff00ff"]})).unwrap();
|
|
assert_eq!(s.doc().unwrap().node(r2).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
|
|
// Variables with modes.
|
|
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
|
|
s.execute("variable.create", &json!({"collection": "Theme", "name": "bg", "type": "color", "values": {"Light": "#ffffff", "Dark": "#000000"}}))
|
|
.unwrap();
|
|
s.execute("variable.bind", &json!({"ids": [r], "field": "fill", "variable": "bg"})).unwrap();
|
|
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "FFFFFF");
|
|
let page = s.page().unwrap();
|
|
s.execute("variable.setMode", &json!({"ids": [page], "collection": "Theme", "mode": "Dark"})).unwrap();
|
|
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "000000");
|
|
}
|
|
|
|
#[test]
|
|
fn align_distribute_zorder() {
|
|
let mut s = sess();
|
|
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
|
let b = id(&s.execute("shape.rectangle", &json!({"x": 30, "y": 20, "width": 10, "height": 10})).unwrap());
|
|
let c = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 5, "width": 10, "height": 10})).unwrap());
|
|
s.execute("align.top", &json!({"ids": [a, b, c]})).unwrap();
|
|
assert_eq!(s.doc().unwrap().world_bounds(b).unwrap().y0, 0.0);
|
|
s.execute("distribute.horizontal", &json!({"ids": [a, b, c]})).unwrap();
|
|
assert_eq!(s.doc().unwrap().world_bounds(b).unwrap().x0, 50.0);
|
|
s.execute("arrange.back", &json!({"ids": [c]})).unwrap();
|
|
let page = s.page().unwrap();
|
|
assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_paste_and_duplicate() {
|
|
let mut s = sess();
|
|
let a = id(&s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap());
|
|
s.execute("edit.copy", &json!({"ids": [a]})).unwrap();
|
|
let r = s.execute("edit.paste", &json!({})).unwrap();
|
|
let pid = params::id_of(&r["ids"][0]).unwrap();
|
|
assert_ne!(pid, a);
|
|
assert_eq!(s.doc().unwrap().world_bounds(pid).unwrap().x0, 5.0);
|
|
let d = s.execute("edit.duplicate", &json!({"ids": [a]})).unwrap();
|
|
assert_eq!(d["ids"].as_array().unwrap().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn boolean_and_flatten() {
|
|
let mut s = sess();
|
|
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap());
|
|
let b = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap());
|
|
let g = id(&s.execute("bool.union", &json!({"ids": [a, b]})).unwrap());
|
|
assert_eq!(s.doc().unwrap().node(g).unwrap().kind.type_name(), "BOOLEAN_OPERATION");
|
|
let v = id(&s.execute("vector.flatten", &json!({"ids": [g]})).unwrap());
|
|
let n = s.doc().unwrap().node(v).unwrap().clone();
|
|
assert_eq!(n.kind.type_name(), "VECTOR");
|
|
assert_eq!((n.width, n.height), (30.0, 30.0));
|
|
}
|
|
|
|
#[test]
|
|
fn pointer_draw_move_resize() {
|
|
let mut s = sess();
|
|
s.set_tool(ToolId::Rectangle);
|
|
let z = 1.0;
|
|
s.pointer(&PointerEvent::at(PointerKind::Down, 10.0, 10.0, z)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Move, 60.0, 40.0, z)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Up, 60.0, 40.0, z)).unwrap();
|
|
let r = s.selection()[0];
|
|
assert_eq!(s.doc().unwrap().world_bounds(r).unwrap(), figcraft_geom::Rect::new(10.0, 10.0, 60.0, 40.0));
|
|
assert_eq!(s.tool.tool, ToolId::Move);
|
|
let undo_len = s.ds().unwrap().history.undo.len();
|
|
// Move it.
|
|
s.pointer(&PointerEvent::at(PointerKind::Down, 30.0, 20.0, z)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Move, 130.0, 120.0, z)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Up, 130.0, 120.0, z)).unwrap();
|
|
assert_eq!(s.doc().unwrap().world_bounds(r).unwrap().x0, 110.0);
|
|
assert_eq!(s.ds().unwrap().history.undo.len(), undo_len + 1);
|
|
// Resize from the bottom-right corner.
|
|
s.pointer(&PointerEvent::at(PointerKind::Down, 160.0, 140.0, z)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Move, 210.0, 190.0, z)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Up, 210.0, 190.0, z)).unwrap();
|
|
let b = s.doc().unwrap().world_bounds(r).unwrap();
|
|
assert_eq!((b.width(), b.height()), (100.0, 80.0));
|
|
// Marquee empty space deselects then selects.
|
|
s.pointer(&PointerEvent::at(PointerKind::Down, 500.0, 500.0, z)).unwrap();
|
|
assert!(s.selection().is_empty());
|
|
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 100.0, z)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, z)).unwrap();
|
|
assert_eq!(s.selection(), vec![r]);
|
|
}
|
|
|
|
#[test]
|
|
fn every_command_survives_junk_params() {
|
|
// A junk-parameter sweep: no command may panic (guard turns panics into Internal errors,
|
|
// which would fail this test).
|
|
let junk = [
|
|
json!({}),
|
|
json!(null),
|
|
json!({"ids": ["x", -1, 99999]}),
|
|
json!({"id": "nope", "x": "NaN", "props": 5}),
|
|
json!([1, 2, 3]),
|
|
json!({"variations": {"wght": 1e308, "": "x", "slnt": null}, "props": {"fontVariations": [1], "variationSettings": {"wdth": "-1e9"}}, "text": 7, "cursor": -3}),
|
|
json!({"style": 3, "props": {"listStyle": 5, "indentation": 1e308, "listSpacing": "NaN", "hangingList": "yes", "start": -5, "end": 1e12,
|
|
"textDecorationThickness": {"unit": "pixels", "value": "x"}, "textDecorationOffset": -1e308, "textDecorationColor": [1]}}),
|
|
];
|
|
for spec in all_commands() {
|
|
if matches!(
|
|
spec.id,
|
|
"file.open"
|
|
| "file.save"
|
|
| "file.saveAs"
|
|
| "file.import"
|
|
| "file.export"
|
|
| "file.exportSvg"
|
|
| "file.placeImage"
|
|
| "image.place"
|
|
| "file.revert"
|
|
) {
|
|
continue;
|
|
}
|
|
for j in &junk {
|
|
let mut s = sess();
|
|
let _ = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
|
let _ = s.execute("text.create", &json!({"text": "t"}));
|
|
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
|
|
panic!("{cmd} panicked on {j}: {msg}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parity_catalog_mostly_covered() {
|
|
let (done, total, missing) = catalog::parity(&[]);
|
|
assert!(total > 150);
|
|
assert!(done as f64 / total as f64 > 0.8, "{done}/{total} missing: {missing:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn export_frames_to_pdf() {
|
|
let mut s = sess();
|
|
let a = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
|
|
let b = id(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 120, "height": 80})).unwrap());
|
|
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
|
let r = s.execute("file.exportFramesPdf", &json!({})).unwrap();
|
|
assert_eq!(r["pages"], 2);
|
|
use base64::Engine as _;
|
|
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap();
|
|
assert!(bytes.starts_with(b"%PDF"));
|
|
let text = String::from_utf8_lossy(&bytes);
|
|
assert_eq!(text.matches("/Type/Page").count() - text.matches("/Type/Pages").count(), 2);
|
|
assert!(text.contains("/MediaBox[0 0 200 100]") && text.contains("/MediaBox[0 0 120 80]"));
|
|
// file.export with format pdf: frames get a page each, other nodes share one.
|
|
let r = s.execute("file.export", &json!({"ids": [a, b], "format": "pdf"})).unwrap();
|
|
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap();
|
|
let text = String::from_utf8_lossy(&bytes);
|
|
assert_eq!(text.matches("/Type/Page").count() - text.matches("/Type/Pages").count(), 2);
|
|
// A page without frames is refused, not panicking.
|
|
s.execute("page.add", &json!({})).ok();
|
|
let empty = s.doc().unwrap().pages.last().copied().unwrap();
|
|
assert!(s.execute("file.exportFramesPdf", &json!({"page": empty})).is_err());
|
|
assert!(s.execute("file.exportFramesPdf", &json!({"page": a})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn vector_edit_mode() {
|
|
let mut s = sess();
|
|
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
|
s.execute("vector.edit.begin", &json!({"id": r})).unwrap();
|
|
assert_eq!(s.doc().unwrap().node(r).unwrap().kind.type_name(), "VECTOR");
|
|
// Move one corner out: the box grows.
|
|
s.execute("vector.edit.select", &json!({"vertices": [2]})).unwrap();
|
|
s.execute("vector.edit.moveVertices", &json!({"dx": 50, "dy": 50})).unwrap();
|
|
let b = s.doc().unwrap().world_bounds(r).unwrap();
|
|
assert_eq!((b.width(), b.height()), (150.0, 150.0));
|
|
// Add a point on segment 0 and delete-heal it.
|
|
let v = s.execute("vector.edit.addVertex", &json!({"segment": 0, "t": 0.5})).unwrap();
|
|
assert!(v["vertex"].as_u64().is_some());
|
|
let n0 = match &s.doc().unwrap().node(r).unwrap().kind {
|
|
NodeKind::Vector { network } => network.vertices.len(),
|
|
_ => 0,
|
|
};
|
|
s.execute("vector.deleteHeal", &json!({})).unwrap();
|
|
let n1 = match &s.doc().unwrap().node(r).unwrap().kind {
|
|
NodeKind::Vector { network } => (network.vertices.len(), network.regions.len()),
|
|
_ => (0, 0),
|
|
};
|
|
assert_eq!(n1, (n0 - 1, 1), "healed and still closed");
|
|
// Pointer: drag a vertex in edit mode.
|
|
let before = s.doc().unwrap().world_bounds(r).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Move, -20.0, 0.0, 1.0)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Up, -20.0, 0.0, 1.0)).unwrap();
|
|
let after = s.doc().unwrap().world_bounds(r).unwrap();
|
|
assert_eq!(after.x0, before.x0 - 20.0);
|
|
s.execute("vector.edit.end", &json!({})).unwrap();
|
|
assert!(s.ds().unwrap().vector_edit.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn arrows_reorder_auto_layout_children_and_cmd_arrows_resize() {
|
|
let mut s = sess();
|
|
let row = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 40})).unwrap());
|
|
let kids: Vec<NodeId> =
|
|
(0..3).map(|i| id(&s.execute("shape.rectangle", &json!({"x": i * 60, "y": 0, "width": 40, "height": 20, "parent": row})).unwrap())).collect();
|
|
s.execute("layout.add", &json!({"ids": [row]})).unwrap();
|
|
let order = |s: &Session| s.doc().unwrap().node(row).unwrap().children.clone();
|
|
assert_eq!(order(&s), kids);
|
|
// Right moves the first child one place later; Down does nothing in a horizontal flow.
|
|
s.execute("transform.nudge", &json!({"ids": [kids[0]], "dx": 1, "dy": 0})).unwrap();
|
|
assert_eq!(order(&s), vec![kids[1], kids[0], kids[2]]);
|
|
s.execute("transform.nudge", &json!({"ids": [kids[0]], "dx": 0, "dy": 1})).unwrap();
|
|
assert_eq!(order(&s), vec![kids[1], kids[0], kids[2]]);
|
|
// A selected block moves together and stops at the end.
|
|
s.execute("transform.nudge", &json!({"ids": [kids[1], kids[0]], "dx": 1, "dy": 0, "big": true})).unwrap();
|
|
assert_eq!(order(&s), vec![kids[2], kids[1], kids[0]]);
|
|
s.execute("transform.nudge", &json!({"ids": [kids[0]], "dx": -1, "dy": 0})).unwrap();
|
|
assert_eq!(order(&s), vec![kids[2], kids[0], kids[1]]);
|
|
s.undo().unwrap();
|
|
assert_eq!(order(&s), vec![kids[2], kids[1], kids[0]]);
|
|
// Free layers still nudge.
|
|
let free = id(&s.execute("shape.rectangle", &json!({"x": 500, "y": 500, "width": 10, "height": 10})).unwrap());
|
|
s.execute("transform.nudge", &json!({"ids": [free], "dx": 1, "dy": 0, "big": true})).unwrap();
|
|
assert_eq!(s.doc().unwrap().world_bounds(free).unwrap().x0, 510.0);
|
|
// Cmd+arrows: resize by the nudge amount, top-left fixed, only the changed axis becomes fixed.
|
|
s.execute("transform.nudgeSize", &json!({"ids": [free], "dw": 1, "dh": 0})).unwrap();
|
|
let n = s.doc().unwrap().node(free).unwrap().clone();
|
|
assert_eq!((n.width, n.height), (11.0, 10.0));
|
|
assert_eq!(s.doc().unwrap().world_bounds(free).unwrap().x0, 510.0);
|
|
s.execute("transform.nudgeSize", &json!({"ids": [free], "dw": 0, "dh": -1, "big": true})).unwrap();
|
|
assert_eq!(s.doc().unwrap().node(free).unwrap().height, 0.0, "never negative");
|
|
// A hugging auto layout frame keeps hugging vertically when only its width is nudged.
|
|
s.execute("node.set", &json!({"id": row, "props": {"sizingH": "hug", "sizingV": "hug"}})).unwrap();
|
|
s.execute("transform.nudgeSize", &json!({"ids": [row], "dw": 1, "dh": 0, "big": true})).unwrap();
|
|
let r = s.doc().unwrap().node(row).unwrap().clone();
|
|
assert_eq!(r.child_layout.sizing_v, figcraft_doc::Sizing::Hug);
|
|
}
|
|
|
|
#[test]
|
|
fn document_colors_are_counted_per_page() {
|
|
let mut s = sess();
|
|
for (i, c) in ["#FF0000", "#FF0000", "#00FF00"].iter().enumerate() {
|
|
let r = id(&s.execute("shape.rectangle", &json!({"x": i * 20, "y": 0, "width": 10, "height": 10})).unwrap());
|
|
s.execute("node.set", &json!({"id": r, "props": {"fills": [c], "strokes": ["#0000FF"]}})).unwrap();
|
|
}
|
|
let v = s.execute("paint.documentColors", &json!({})).unwrap();
|
|
let list: Vec<(String, u64)> =
|
|
v["colors"].as_array().unwrap().iter().map(|c| (c["color"].as_str().unwrap().to_string(), c["count"].as_u64().unwrap())).collect();
|
|
assert_eq!(list.first(), Some(&("0000FF".to_string(), 3)));
|
|
assert!(list.contains(&("FF0000".to_string(), 2)));
|
|
assert!(list.contains(&("00FF00".to_string(), 1)));
|
|
let v = s.execute("paint.documentColors", &json!({"limit": 1})).unwrap();
|
|
assert_eq!(v["colors"].as_array().unwrap().len(), 1);
|
|
// Another page is separate.
|
|
s.execute("page.add", &json!({})).unwrap();
|
|
let v = s.execute("paint.documentColors", &json!({})).unwrap();
|
|
assert!(v["colors"].as_array().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn guides_add_move_remove() {
|
|
let mut s = sess();
|
|
let guides = |s: &Session| match &s.doc().unwrap().node(s.page().unwrap()).unwrap().kind {
|
|
NodeKind::Page { guides, .. } => guides.clone(),
|
|
_ => vec![],
|
|
};
|
|
let r = s.execute("guide.add", &json!({"vertical": true, "offset": 120})).unwrap();
|
|
assert_eq!(r["index"], json!(0));
|
|
s.execute("guide.add", &json!({"vertical": false, "offset": 40.5})).unwrap();
|
|
s.execute("guide.move", &json!({"index": 0, "offset": 150})).unwrap();
|
|
assert_eq!(guides(&s)[0].offset, 150.0);
|
|
s.undo().unwrap();
|
|
assert_eq!(guides(&s)[0].offset, 120.0);
|
|
s.execute("guide.remove", &json!({"index": 1})).unwrap();
|
|
assert_eq!(guides(&s).len(), 1);
|
|
for j in [json!({"index": -1}), json!({"index": 5, "offset": 1}), json!({"index": "x"}), json!({"offset": "NaN"})] {
|
|
assert!(s.execute("guide.remove", &j).is_err() || guides(&s).len() == 1, "{j}");
|
|
let _ = s.execute("guide.move", &j);
|
|
}
|
|
assert_eq!(guides(&s).len(), 1, "junk never removes the first guide");
|
|
s.execute("guide.clear", &json!({})).unwrap();
|
|
assert!(guides(&s).is_empty());
|
|
}
|