Merge remote-tracking branch 'origin/main' into feat/devmode-tail

# Conflicts:
#	ROADMAP.md
#	crates/engine/src/devinspect.rs
This commit is contained in:
bflatastic
2026-10-16 02:26:38 +07:00
122 changed files with 5695 additions and 324 deletions
+21
View File
@@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() {
assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column");
}
#[test]
fn single_item_wrapped_lines_move_and_drop_by_line() {
let mut s = Session::new();
// Full-width items: one per row.
let row = al(&mut s, None, "horizontal", 160.0, 200.0);
set(&mut s, row, json!({"wrap": true}));
let k: Vec<NodeId> = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect();
assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row");
let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]);
assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0]))));
arrow(&mut s, &[k[1]], 0, -1);
assert_eq!(kids(&s, row), vec![k[1], k[0]]);
// Full-height items in a vertical wrap: one per column.
let col = al(&mut s, None, "vertical", 200.0, 160.0);
set(&mut s, col, json!({"wrap": true}));
let c: Vec<NodeId> = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect();
arrow(&mut s, &[c[2]], -1, 0);
assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]);
}
#[test]
fn wrapped_rows_move_up_and_down_a_row() {
let mut s = Session::new();
+12 -3
View File
@@ -824,12 +824,21 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale:
}
/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own
/// area), or their boxes with "include bounding box" and for slices.
/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing,
/// so they take no room next to visible ones (a hidden-only export keeps its own area).
pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option<figcraft_geom::Rect> {
let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible));
ids.iter()
.filter_map(|i| {
let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice));
if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) }
let node = doc.node(*i);
let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice));
if setting.include_bounding_box || slice {
doc.world_bounds(*i)
} else if any_visible && node.is_some_and(|n| !n.visible) {
None
} else {
figcraft_render::export_bounds(doc, *i)
}
})
.reduce(|a, b| a.union(b))
}
+33 -10
View File
@@ -1,5 +1,6 @@
//! Pages.
use figcraft_doc::proto::Flow;
use figcraft_doc::*;
use serde_json::{Value, json};
@@ -23,6 +24,15 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
Ok(())
}
/// The page a command targets: the explicit `id` (which must be a page) or the open page.
fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result<NodeId> {
let Some(target) = id(p, "id") else { return s.page() };
if !s.doc()?.pages.contains(&target) {
return Err(bad(cmd, format!("{target} is not a page")));
}
Ok(target)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
@@ -71,13 +81,13 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("page.rename", "Rename page", "{id?, name} (an empty page named with a leading hyphen becomes a divider)", true, |s, p| {
let name = req_string("page.rename", p, "name")?;
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.rename", p)?;
s.doc_mut()?.get_mut(id)?.name = name;
settle(s, "page.rename")?;
ok()
}),
spec!("page.delete", "Delete page", "{id?}", true, |s, p| {
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.delete", p)?;
s.doc_mut()?.remove(id)?;
settle(s, "page.delete")?;
ok()
@@ -87,13 +97,23 @@ pub fn specs() -> Vec<CommandSpec> {
let doc = s.doc_mut()?;
let name = format!("Copy of {}", doc.get(src)?.name);
let np = doc.add_page(&name);
if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone()
&& let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind
// Each layer and its copy (also those in sections), so flows can follow their start frames.
let mut copies = Vec::new();
for c in doc.get(src)?.children.clone() {
let copy = doc.duplicate_into(c, np, None)?;
copies.extend(doc.subtree(c).into_iter().zip(doc.subtree(copy)));
}
// The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides.
if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone()
&& let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind
{
*b = background;
}
for c in doc.get(src)?.children.clone() {
doc.duplicate_into(c, np, None)?;
*f = flows
.into_iter()
.filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl }))
.collect();
*pr = prototype;
*g = guides;
}
// Keep the new page right after the source.
if let Some(si) = doc.pages.iter().position(|x| *x == src) {
@@ -117,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| {
let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?;
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.setBackground", p)?;
if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind {
*background = c;
}
@@ -163,8 +183,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
guides.remove(k);
let ds = s.ds_mut()?;
if ds.guide == Some((owner, k)) {
ds.guide = None;
match ds.guide {
Some((o, i)) if o == owner && i == k => ds.guide = None,
// A later guide of the same owner moves up one place; keep the selection on it.
Some((o, i)) if o == owner && i > k => ds.guide = Some((o, i - 1)),
_ => {}
}
ok()
}),
+3
View File
@@ -112,6 +112,9 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
let name = req_string("variable.createCollection", p, "name")?;
let doc = s.doc_mut()?;
if doc.variables.collections.iter().any(|c| c.name == name) {
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
}
let id = doc.new_collection_id();
let modes: Vec<String> = match get(p, "modes") {
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
+1 -1
View File
@@ -87,7 +87,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
let scopes = match get(p, "scopes") {
let scopes = match p.get("scopes") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Array(a)) => {
+10 -2
View File
@@ -138,8 +138,16 @@ fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
let pages = &ds.doc.pages;
let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64;
let n = pages.len() as i64;
let next = (i + dir).clamp(0, n - 1) as usize;
let Some(pg) = pages.get(next).copied() else { return ok() };
// Dividers never open: step over them, and stay put when only dividers lie ahead.
let mut next = i + dir;
let pg = loop {
let page = usize::try_from(next).ok().and_then(|k| pages.get(k).copied());
match page {
Some(p) if !ds.doc.is_divider(p) => break p,
Some(_) if dir != 0 && next >= 0 && next < n => next += dir,
_ => break ds.page,
}
};
let ds = s.ds_mut()?;
ds.page = pg;
ds.selection.clear();
+51 -1
View File
@@ -113,8 +113,58 @@ pub(crate) fn num(v: f64) -> String {
}
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
/// in "px" are left alone.
/// in "px", markup lines (layer text), string literals and comments are left alone.
pub fn px_to_rem(code: &str, root: f64) -> String {
let mut out = String::with_capacity(code.len());
let mut in_comment = false;
for line in code.split_inclusive('\n') {
if !in_comment && line.trim_start().starts_with('<') {
out.push_str(line);
continue;
}
// Lengths outside strings and comments convert; `plain` is where that stretch starts.
let mut plain = 0;
let mut chars = line.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if in_comment {
if c == '*' && chars.peek().is_some_and(|(_, n)| *n == '/') {
chars.next();
out.push_str(line.get(plain..i + 2).unwrap_or(""));
plain = i + 2;
in_comment = false;
}
} else if c == '/' && chars.peek().is_some_and(|(_, n)| *n == '*') {
chars.next();
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
plain = i;
in_comment = true;
} else if c == '"' {
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
let mut end = line.len();
while let Some((j, s)) = chars.next() {
if s == '\\' {
chars.next();
} else if s == '"' {
end = j + 1;
break;
}
}
out.push_str(line.get(i..end).unwrap_or(""));
plain = end;
}
}
let rest = line.get(plain..).unwrap_or("");
if in_comment {
out.push_str(rest);
} else {
out.push_str(&lengths_to_rem(rest, root));
}
}
out
}
/// `code` with every pixel length in rem of `root` pixels; names that end in "px" are left alone.
fn lengths_to_rem(code: &str, root: f64) -> String {
let ident = |c: char| c.is_alphanumeric() || c == '_';
let mut out = String::with_capacity(code.len());
let mut rest = code;
+10 -7
View File
@@ -615,10 +615,10 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
}
}
/// `data-*` attribute names for variant parameters.
fn data_attr(p: &Param) -> String {
/// `data-*` attribute names for variant parameters, unique among them (in order).
fn data_attrs(variants: &[&Param]) -> Vec<String> {
let mut used = Vec::new();
format!("data-{}", class_name(&p.prop, &mut used))
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect()
}
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
@@ -628,6 +628,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
let root_class = class_name(&root_name, &mut Vec::new());
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
let attr_names = data_attrs(&variants);
let pass = |root: NodeId| {
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
let mut m = Markup::new(&vctx, lang);
@@ -639,9 +640,10 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
m.root_class = Some(root_class.clone());
m.root_attrs = variants
.iter()
.map(|p| match lang {
CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)),
_ => format!(" {}={{{}}}", data_attr(p), p.ident),
.zip(&attr_names)
.map(|(p, attr)| match lang {
CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)),
_ => format!(" {attr}={{{}}}", p.ident),
})
.collect();
m.node(c.comp, None, 2, "");
@@ -653,7 +655,8 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
let pairs = variant_props(&vn.name);
let attrs: String = variants
.iter()
.filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], ""))))
.zip(&attr_names)
.filter_map(|(p, attr)| pairs.get(&p.prop).map(|val| format!("[{attr}=\"{}\"]", val.replace(['"', '\\'], ""))))
.collect();
for (path, _, decls) in pass(v) {
let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue };
@@ -314,3 +314,32 @@ fn bound_variables_become_custom_properties() {
let react = code(&mut s, c, "react");
assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}");
}
#[test]
fn case_colliding_variant_properties_get_distinct_data_attributes() {
let mut s = Session::new();
let mut variants = Vec::new();
for (k, (first, second, fill)) in [("Small", "Big", "#0000FF"), ("Large", "Small", "#FF0000")].iter().enumerate() {
let name = format!("Size={first}, size={second}");
let f = idv(&ex(
&mut s,
"frame.create",
json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": name, "props": {"fill": fill}}),
));
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
}
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
ex(&mut s, "node.set", json!({"id": set, "props": {"name": "Chip"}}));
for lang in ["react", "leptos"] {
let v = component_code(&mut s, set, lang);
let def = v["definition"].as_str().unwrap_or_default();
let root = def.lines().find(|l| l.trim_start().starts_with("<div ")).unwrap_or_default();
let attrs: Vec<&str> =
root.split_whitespace().filter_map(|w| w.strip_prefix("data-")).map(|w| w.split('=').next().unwrap_or_default()).collect();
assert_eq!(attrs.len(), 2, "{lang}: {root}");
assert_ne!(attrs[0], attrs[1], "{lang}: distinct attributes in {root}");
let sel = format!(".chip[data-{}=\"Large\"][data-{}=\"Small\"]", attrs[0], attrs[1]);
assert!(def.contains(&sel), "{lang}: {sel} in:\n{def}");
assert!(def.contains("#FF0000"), "{lang}:\n{def}");
}
}
+18
View File
@@ -219,6 +219,24 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone");
}
#[test]
fn a_pen_click_that_wobbles_a_pixel_is_still_a_click() {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
// A real click: the mouse moves a pixel or two between press and release.
s.pointer(&ev(PointerKind::Down, (100.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (102.0, 101.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Up, (102.0, 101.0), NONE)).unwrap();
let out = s.tool.pen.as_ref().and_then(|p| p.points.first()).map(|(_, _, o)| *o);
assert_eq!(out.map(|o| o.hypot()), Some(0.0), "no handle pulled out");
assert!(s.tool.overlay.pen_handles.is_empty());
// Past the slop it is a drag: the handle follows the pointer from where it was pressed.
s.pointer(&ev(PointerKind::Down, (200.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (204.0, 100.0), NONE)).unwrap();
let out = s.tool.pen.as_ref().and_then(|p| p.points.last()).map(|(_, _, o)| (o.x, o.y));
assert_eq!(out, Some((4.0, 0.0)));
}
#[test]
fn the_pen_shows_what_a_click_would_do() {
// The Pen tool drawing a new path: three points, then over each of them.
+26
View File
@@ -93,3 +93,29 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() {
click(&mut s, 350.0, 100.0);
assert_eq!(s.selection(), vec![b], "everything is hit again");
}
#[test]
fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() {
let mut s = Session::new();
let screen = frame(&mut s, 0.0, "Screen");
let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"})));
// A status a nested frame already stores (an older file, or set through node.set) is ignored.
ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}}));
assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([]));
assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready");
ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}}));
let ids = |v: serde_json::Value| -> Vec<serde_json::Value> {
v["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default()
};
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen)]);
ex(&mut s, "dev.focus", json!({"id": screen}));
for direction in ["next", "previous"] {
let v = ex(&mut s, "dev.focus", json!({"direction": direction}));
assert_eq!((v["id"].clone(), v["count"].clone()), (json!(screen), json!(1)), "{direction} wraps to the only ready frame");
}
// A frame directly inside a section still counts.
let sec = idv(&ex(&mut s, "node.create", json!({"type": "section", "x": 1000, "y": 0, "width": 400, "height": 300})));
let in_sec = idv(&ex(&mut s, "frame.create", json!({"parent": sec, "width": 100, "height": 100, "name": "In section"})));
ex(&mut s, "node.set", json!({"ids": [sec, in_sec], "props": {"devStatus": "readyForDev"}}));
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen), json!(sec), json!(in_sec)]);
}
+2 -2
View File
@@ -244,7 +244,7 @@ pub fn has_status(doc: &Document, id: NodeId) -> bool {
}
/// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page),
/// in page and layer order (bounded).
/// in page and layer order (bounded). Only layers that can carry a status are listed.
pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>) -> Vec<DevItem> {
let mut out = Vec::new();
let mut visited = 0usize;
@@ -256,7 +256,7 @@ pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>
return out;
}
let Some(n) = doc.node(id) else { continue };
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) {
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) {
let changed = n.dev_status == DevStatus::ReadyForDev && n.dev_changed;
out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status, changed });
}
+5 -2
View File
@@ -61,12 +61,15 @@ pub fn flip(r: figcraft_geom::Rect, vertical: bool) -> figcraft_geom::Rect {
/// The visual rows (columns when `vertical`) of a wrapping layout's placed children `kids`, each
/// child as (its index in `kids`, its page bounds [`flip`]ped so the flow runs along x). Children
/// wrap in flow order, so a new row starts where a child sits before the one before.
/// wrap in flow order, so a new row starts where a child sits before the one before, or where it
/// does not follow the one before along the flow but sits past it across (one child per line).
pub fn wrap_rows(doc: &Document, kids: &[NodeId], vertical: bool) -> Vec<Vec<(usize, figcraft_geom::Rect)>> {
let mut rows: Vec<Vec<(usize, figcraft_geom::Rect)>> = Vec::new();
for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, flip(b, vertical)))) {
match rows.last_mut() {
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)),
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5 && !(b.x0 < prev.x1 - 0.5 && b.y0 >= prev.y1 - 0.5)) => {
row.push((i, b))
}
_ => rows.push(vec![(i, b)]),
}
}
+40
View File
@@ -77,3 +77,43 @@ fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap();
assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]);
}
fn web_code(s: &mut Session, id: figcraft_doc::NodeId, language: &str, unit: &str) -> String {
let out = s.execute("inspect.code", &json!({"id": id, "language": language, "unit": unit})).unwrap();
out["code"].as_str().unwrap().to_string()
}
#[test]
fn rem_preserves_text_in_web_markup() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"width": 200, "height": 100, "name": "Card"})).unwrap());
s.execute("text.create", &json!({"text": "Tile 24px", "parent": frame, "name": "Label"})).unwrap();
let mut changed = Vec::new();
for language in ["html", "react", "leptos"] {
let px = web_code(&mut s, frame, language, "px");
let rem = web_code(&mut s, frame, language, "rem");
assert!(px.contains("Tile 24px"), "{language}: px control: {px}");
assert!(rem.contains("width: 12.5rem;"), "{language}: width: {rem}");
assert!(rem.contains("font-size: 0.75rem;"), "{language}: font: {rem}");
if !rem.contains("Tile 24px") {
changed.push(language);
}
}
assert!(changed.is_empty(), "text rewritten in {changed:?}");
}
#[test]
fn rem_preserves_text_property_default() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"width": 100, "height": 40, "name": "Chip"})).unwrap());
let label = id(s.execute("text.create", &json!({"text": "24px", "parent": frame, "name": "Label"})).unwrap());
let component = id(s.execute("component.create", &json!({"ids": [frame]})).unwrap());
s.execute("component.addProperty", &json!({"id": component, "name": "Label", "type": "text", "default": "24px", "layer": label})).unwrap();
for unit in ["px", "rem"] {
let code = web_code(&mut s, component, "react", unit);
assert!(code.contains("label = \"24px\""), "{unit}: changed default: {code}");
if unit == "rem" {
assert!(code.contains("width: 6.25rem;"), "rem still converts lengths: {code}");
}
}
}
+6 -2
View File
@@ -1236,8 +1236,8 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
pub fn normalize(doc: &mut Document, before: &Document) {
// Only what was touched since the last normalisation (edited, added or removed).
let mut touched: BTreeSet<NodeId> = doc.take_dirty();
// A variable edit changes no layer directly, but bound layers must follow it.
if touched.is_empty() && doc.variables == before.variables {
// A variable or style edit changes no layer directly, but bound and linked layers must follow it.
if touched.is_empty() && doc.variables == before.variables && doc.styles == before.styles {
return;
}
let m = measure::TextMeasure;
@@ -1562,6 +1562,8 @@ mod invite_links_tests;
#[cfg(test)]
mod keymap_tests;
#[cfg(test)]
mod library_range_styles_tests;
#[cfg(test)]
mod library_variables_tests;
#[cfg(test)]
mod live_access_tests;
@@ -1590,6 +1592,8 @@ mod open_issues_tests;
#[cfg(test)]
mod outline_options_tests;
#[cfg(test)]
mod page_target_tests;
#[cfg(test)]
mod paint_reorder_tests;
#[cfg(test)]
mod paint_tests;
+23 -3
View File
@@ -134,9 +134,12 @@ pub fn closure(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
out
}
/// The style ids a layer links to (fill, stroke, effect, grid, text).
/// The style ids a layer links to (fill, stroke, effect, grid, text, and text styles on
/// character ranges).
pub(crate) fn style_links(n: &Node) -> impl Iterator<Item = &String> {
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten()
let chars =
n.kind.text().into_iter().flat_map(|t| std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style))).filter_map(|s| s.style_id.as_ref());
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten().chain(chars)
}
/// Style ids the layers under `roots` link to.
@@ -582,11 +585,15 @@ fn local_key(lid: &str, key: &str, taken: impl Fn(&str) -> bool) -> String {
}
/// Copy library styles `keys` into `doc` as linked remote styles (with `update`, copies older than
/// the library's are refreshed). Returns library style id → local id.
/// the library's are refreshed). The variables a written copy binds are imported too and its
/// bindings point at their copies (never at this file's variables of the same ids). Returns library
/// style id → local id.
pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet<String>, update: bool) -> BTreeMap<String, String> {
let lid = lib.manifest.id.clone();
let mut out = BTreeMap::new();
let mut hashes = BTreeSet::new();
// Local ids of the copies written now, with the library style they copy.
let mut written: Vec<(String, &Style)> = Vec::new();
for key in keys {
let Some(style) = lib.doc.styles.iter().find(|s| s.id == *key) else { continue };
let version = lib.manifest.style(key).map_or(lib.manifest.version, |s| s.version);
@@ -598,6 +605,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
{
*slot = Style { id: r.local.clone(), ..style.clone() };
set_record(&mut doc.libraries_mut().styles, ImportedStyle { version, ..r.clone() });
written.push((r.local.clone(), style));
}
r.local
}
@@ -605,6 +613,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
let local = local_key(&lid, key, |l| doc.styles.iter().any(|s| s.id == l));
doc.styles.push(Style { id: local.clone(), ..style.clone() });
set_record(&mut doc.libraries_mut().styles, ImportedStyle { library: lid.clone(), key: key.clone(), local: local.clone(), version });
written.push((local.clone(), style));
local
}
};
@@ -613,6 +622,17 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
}
out.insert(key.clone(), local);
}
let bound: BTreeSet<VariableId> = written.iter().flat_map(|(_, s)| s.bound.values().cloned()).collect();
if !bound.is_empty() {
let vars = import_variables(doc, lib, &bound, &BTreeSet::new(), update);
for (local, _) in &written {
if let Some(slot) = doc.styles.iter_mut().find(|s| s.id == *local) {
// Bindings to variables the library lacks are dropped (the published value stays).
slot.bound =
std::mem::take(&mut slot.bound).into_iter().filter_map(|(f, v)| vars.variables.get(&v).map(|l| (f, l.clone()))).collect();
}
}
}
copy_images(doc, &lib.doc, &hashes);
out
}
@@ -0,0 +1,66 @@
//! Text styles linked on character ranges travel with components (publish and place).
use crate::Session;
use figcraft_doc::NodeId;
use serde_json::{Value, json};
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command}: {e}"))
}
fn node(value: Value) -> NodeId {
NodeId(value["id"].as_u64().unwrap())
}
/// A component with "Hello" (chars 0..2 linked to a range style) and "World" (whole-layer style).
fn fixture() -> (Session, NodeId, String, String) {
let mut s = Session::new();
let frame = node(run(&mut s, "frame.create", json!({"name":"Card", "width":100, "height":40})));
let text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"Hello"})));
let range = run(&mut s, "style.create", json!({"type":"text", "name":"Emphasis", "value":{"fontSize":32}}))["id"].as_str().unwrap().to_owned();
run(&mut s, "style.apply", json!({"id":text, "style":range, "start":0, "end":2}));
let whole_text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"World"})));
let whole = run(&mut s, "style.create", json!({"type":"text", "name":"Whole", "value":{"fontSize":24}}))["id"].as_str().unwrap().to_owned();
run(&mut s, "style.apply", json!({"id":whole_text, "style":whole}));
let component = node(run(&mut s, "component.create", json!({"ids":[frame]})));
let source = s.doc().unwrap().node(text).unwrap();
let chars = source.kind.text().unwrap();
assert!(source.styles.text.is_none());
assert_eq!(chars.style_at(0).style_id.as_deref(), Some(range.as_str()));
assert_eq!(chars.style_at(2).style_id, None);
(s, component, range, whole)
}
#[test]
fn component_publish_keeps_range_text_style() {
let (mut s, component, range, whole) = fixture();
run(&mut s, "library.publish", json!({"include":[component]}));
let snapshot = &s.doc().unwrap().published.as_ref().unwrap().doc;
assert!(snapshot.style(&whole).is_some());
assert!(snapshot.style(&range).is_some(), "character-range style missing from snapshot");
}
#[test]
fn component_place_imports_range_text_style() {
let (mut source, component, range, whole) = fixture();
let publication = run(&mut source, "library.publish", json!({}));
let library = source.doc().unwrap().published.as_ref().unwrap().clone();
assert!(library.doc.style(&range).is_some());
let mut target = Session::new();
let path = "figcraft://library.test/range-styles";
target.libraries.insert(path, Ok(library));
run(&mut target, "library.add", json!({"path":path}));
let instance = node(run(&mut target, "library.place", json!({"library":publication["id"], "component":component})));
let doc = target.doc().unwrap();
let children = &doc.node(instance).unwrap().children;
let ranged = doc.node(children[0]).unwrap().kind.text().unwrap();
let full = doc.node(children[1]).unwrap().kind.text().unwrap();
let whole_record = doc.libraries.styles.iter().find(|r| r.key == whole).unwrap();
assert_eq!(full.style_at(0).style_id.as_deref(), Some(whole_record.local.as_str()));
assert_eq!(ranged.style_at(0).font_size, 32.0);
assert!(ranged.style_at(2).style_id.is_none());
let record = doc.libraries.styles.iter().find(|r| r.key == range);
assert!(record.is_some(), "character-range style missing after placement");
assert_eq!(ranged.style_at(0).style_id.as_deref(), record.map(|r| r.local.as_str()));
assert!(doc.style(ranged.style_at(0).style_id.as_deref().unwrap()).is_some());
}
@@ -314,3 +314,33 @@ fn library_variable_commands_fail_cleanly() {
}
assert!(s.doc().unwrap().libraries.is_empty());
}
#[test]
fn library_style_bindings_ignore_unrelated_local_variables() {
let d = dir("style-bound");
let path = path_str(&d.join("lib.figcraft"));
let mut lib = Session::new();
ex(&mut lib, "variable.createCollection", json!({"name": "Tokens"}));
let var = ex(&mut lib, "variable.create", json!({"collection": "Tokens", "name": "Brand", "type": "color", "value": "#0000ff"}))["id"].clone();
let style = ex(&mut lib, "style.create", json!({"type": "paint", "name": "Brand", "value": "#0000ff"}))["id"].clone();
ex(&mut lib, "style.bind", json!({"style": style, "field": "fill", "variable": var}));
let lid = publish_to(&mut lib, &path);
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Local"}));
let local = ex(&mut s, "variable.create", json!({"collection": "Local", "name": "Unrelated", "type": "color", "value": "#ff0000"}))["id"].clone();
assert_eq!(local, var, "the ids must collide for this test");
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 40, "height": 40})));
ex(&mut s, "library.add", json!({"path": path}));
ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "id": r}));
assert_eq!(fill(&s, r), "0000FF");
healthy(&s);
// An updated style follows the library's variable, not the local one.
ex(&mut lib, "variable.set", json!({"variable": "Brand", "value": "#00ffff"}));
republish(&mut lib);
ex(&mut s, "library.acceptUpdates", json!({}));
assert_eq!(fill(&s, r), "00FFFF");
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
+63
View File
@@ -0,0 +1,63 @@
//! Page commands act on pages: a layer id (a rectangle, a frame) is refused before anything about
//! the layer changes, as `page.select` already does.
use figcraft_doc::{NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn name(s: &Session, id: NodeId) -> String {
s.doc().unwrap().get(id).unwrap().name.clone()
}
#[test]
fn page_commands_reject_a_rectangle() {
let mut s = Session::new();
let page = s.page().unwrap();
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
let before = name(&s, r);
assert!(s.execute("page.select", &json!({"id": r})).is_err());
assert!(s.execute("page.rename", &json!({"id": r, "name": "Not a page"})).is_err(), "rename refuses a layer");
assert_eq!(name(&s, r), before, "the layer keeps its name");
assert!(s.execute("page.setBackground", &json!({"id": r, "color": "#123456"})).is_err(), "background refuses a layer");
assert!(s.execute("page.delete", &json!({"id": r})).is_err(), "delete refuses a layer");
assert!(s.doc().unwrap().contains(r), "the layer is still there");
assert!(s.doc().unwrap().contains(page));
}
#[test]
fn page_delete_keeps_a_frame_and_its_child() {
let mut s = Session::new();
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f})));
assert_eq!(s.doc().unwrap().get(c).unwrap().parent, Some(f));
assert!(s.execute("page.delete", &json!({"id": f})).is_err());
assert!(s.doc().unwrap().contains(f) && s.doc().unwrap().contains(c), "the frame and its child remain");
assert!(s.execute("page.rename", &json!({"id": f, "name": "Not a page"})).is_err());
assert_ne!(name(&s, f), "Not a page");
}
#[test]
fn page_commands_still_work_on_pages() {
let mut s = Session::new();
let first = s.page().unwrap();
let second = id_of(&ex(&mut s, "page.add", json!({})));
ex(&mut s, "page.rename", json!({"id": second, "name": "Flows"}));
assert_eq!(name(&s, second), "Flows");
ex(&mut s, "page.setBackground", json!({"id": second, "color": "#123456"}));
match s.doc().unwrap().get(second).unwrap().kind {
NodeKind::Page { background, .. } => assert_eq!(background.to_css_hex().to_uppercase(), "#123456"),
_ => panic!("not a page"),
}
ex(&mut s, "page.delete", json!({"id": second}));
assert!(!s.doc().unwrap().contains(second));
assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays");
}
@@ -425,3 +425,25 @@ fn duplicating_a_layer_named_past_the_counter_does_not_overflow() {
assert_eq!(name(&s, dup), "Layer 18446744073709551615 2");
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
}
#[test]
fn page_navigation_skips_dividers() {
// A trailing divider: Next page stays on the ordinary page.
let mut s = Session::new();
let first = s.page().unwrap();
let div = id(&ex(&mut s, "page.add", json!({"name": "-"})));
assert!(s.execute("page.select", &json!({"id": div})).is_err(), "a divider isn't a page to open");
let r = ex(&mut s, "view.nextPage", json!({}));
assert_eq!(s.page().unwrap(), first, "Next page doesn't open a trailing divider");
assert_eq!(id_of(&r["page"]), Some(first), "the result names the current page");
// A middle divider: Previous page crosses it, Next page crosses it back.
let later = id(&ex(&mut s, "page.add", json!({"name": "Later"})));
assert_eq!(s.page().unwrap(), later);
let r = ex(&mut s, "view.prevPage", json!({}));
assert_eq!(s.page().unwrap(), first, "Previous page crosses a divider");
assert_eq!(id_of(&r["page"]), Some(first));
ex(&mut s, "view.nextPage", json!({}));
assert_eq!(s.page().unwrap(), later, "Next page crosses a divider");
ex(&mut s, "view.nextPage", json!({}));
assert_eq!(s.page().unwrap(), later, "Next page stops on the last page");
}
+1 -1
View File
@@ -322,7 +322,7 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
let whole_shape =
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
if merge && whole_shape {
return Ok(json!({"id": null, "changed": [], "removed": [], "regions": pick}));
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
}
let doc = s.doc_mut()?;
let created = if merge {
+19 -4
View File
@@ -66,7 +66,8 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
made += 4;
}
// A card group (5 layers).
if budget(made) >= 5 {
// The card and the instances leave one layer for the grid, which starts with a rectangle.
if budget(made) > 5 {
let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?;
// Children are in the group's space; normalising fits the group around them.
let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?;
@@ -78,7 +79,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
}
// Three button instances (3 layers each).
for k in 0..3 {
if budget(made) < 3 {
if budget(made) < 3 + 1 {
break;
}
crate::components::create_instance(&mut doc, button, screen, 12.0 + k as f64 * 108.0, SCREEN_H - 44.0)?;
@@ -89,12 +90,14 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
for k in 0..rest {
let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0);
let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0;
let id = match k % 5 {
// The grid starts with a rectangle so even the smallest workload has one.
let kind = (k + 2) % 5;
let id = match kind {
0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?,
1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?,
_ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?,
};
if k % 5 != 0 {
if kind != 0 {
fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0));
if let Some(n) = doc.node_mut(id) {
n.corners.radii = [3.0; 4];
@@ -124,4 +127,16 @@ mod tests {
assert!(doc.page_content_bounds(page).is_some());
}
}
#[test]
fn small_documents_hold_the_benchmark_targets() {
for n in [10, 11, 12, 13, 14, 15] {
let (doc, page) = super::large_document(n).unwrap();
let nodes: Vec<_> = doc.subtree(page).into_iter().filter_map(|id| doc.node(id)).collect();
let has = |f: &dyn Fn(&figcraft_doc::Node) -> bool| nodes.iter().any(|node| f(node));
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Rectangle) && x.name == "Rectangle"), "{n}: no rectangle");
assert!(has(&|x| x.name == "Title"), "{n}: no title");
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Component { .. })), "{n}: no component");
}
}
}
+15 -8
View File
@@ -38,16 +38,17 @@ pub fn end(s: &mut Session) -> Result<()> {
let _ = doc.remove(te.id);
s.ds_mut()?.selection.clear();
}
s.commit_interaction()?;
if empty && te.created {
// Nothing to undo for a text box that was never filled.
// Nothing to undo for a text box that was never filled. The gesture snapshot was taken
// after the layer was added, so it still holds it: re-base it on the document without the
// layer, and committing then records no step (and leaves the redo stack alone).
let ds = s.ds_mut()?;
if let Some(last) = ds.history.undo.last()
&& *last.doc == *ds.doc
{
ds.history.undo.pop();
let doc = ds.doc.clone();
if let Some(it) = ds.interaction.as_mut() {
it.doc = doc;
}
}
s.commit_interaction()?;
let ds = s.ds_mut()?;
ds.revision += 1;
Ok(())
@@ -146,9 +147,15 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
}
/// Cmd/Ctrl+] and Cmd/Ctrl+[ while editing: the selected list items a level deeper or out, like
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list stays as it is).
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list and items already
/// at the deepest level stay as they are).
pub fn list_indent(s: &mut Session, out: bool) -> Result<bool> {
list_key(s, "Tab", out)
let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(false) };
let before = text_of(s, id)?;
if !list_key(s, "Tab", out)? {
return Ok(false);
}
Ok(text_of(s, id)? != before)
}
/// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word
+46
View File
@@ -182,3 +182,49 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() {
let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap();
assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
}
#[test]
fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
let mut s = Session::new();
ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}));
let start = ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))["id"].clone();
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Checkout"}));
ex(&mut s, "proto.settings", json!({"device": "iPhone 16", "responsive": true}));
ex(&mut s, "guide.add", json!({"vertical": true, "offset": 25}));
ex(&mut s, "select.none", json!({}));
let source = s.page().unwrap();
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
let frames = s.doc().unwrap().node(copy).unwrap().children.clone();
let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()),
_ => panic!("expected a page"),
};
let (flows, prototype, guides) = state(&s, copy);
let (src_flows, src_prototype, src_guides) = state(&s, source);
assert_eq!(flows.len(), 1);
assert_eq!(flows[0].name, "Checkout");
assert_eq!(flows[0].start, frames[1], "the flow starts at the copied frame");
assert_eq!(src_flows[0].start, id_of(&start).unwrap(), "the source keeps its own");
assert_eq!(prototype, src_prototype);
assert_eq!(prototype.device, "iPhone 16");
assert!(prototype.responsive);
assert_eq!(guides, src_guides);
assert_eq!(guides.len(), 1);
assert_eq!(ex(&mut s, "proto.present", json!({}))["start"].as_u64(), Some(frames[1].0));
}
#[test]
fn a_copy_of_a_page_keeps_a_flow_that_starts_in_a_section() {
let mut s = Session::new();
let start = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
ex(&mut s, "object.wrapInSection", json!({"ids": [start]}));
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Onboarding"}));
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
let doc = s.doc().unwrap();
let section = doc.node(copy).unwrap().children[0];
let frame = doc.node(section).unwrap().children[0];
match &doc.node(copy).unwrap().kind {
NodeKind::Page { flows, .. } => assert_eq!(flows.iter().map(|f| (f.name.as_str(), f.start)).collect::<Vec<_>>(), vec![("Onboarding", frame)]),
_ => panic!("expected a page"),
}
}
+5 -3
View File
@@ -1468,7 +1468,8 @@ impl Session {
}
Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()),
Gesture::PenDrag { anchor } => {
let out = ev.p() - *anchor;
// A click's wobble pulls out no handle.
let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor };
if let Some(pen) = self.tool.pen.as_mut()
&& let Some(last) = pen.points.last_mut()
{
@@ -1870,9 +1871,10 @@ impl Session {
self.widget_click(ev.p(), ev.tol() * 2.0)?;
return Ok(());
}
// The second press began a move or a resize: a double-click isn't one.
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. }) {
// The second press began a move, a resize, a shape or a marquee: a double-click isn't one.
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) {
self.tool.gesture = Gesture::None;
self.tool.overlay.marquee = None;
self.cancel_interaction()?;
}
let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
@@ -114,3 +114,14 @@ fn field_scopes_by_property_and_layer() {
assert_eq!(scope(f, "paddingHorizontal"), Some("GAP"));
assert_eq!(scope(f, "visible"), None);
}
#[test]
fn create_collection_refuses_a_taken_name() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let before = s.doc().unwrap().variables.collections.len();
assert!(s.execute("variable.createCollection", &json!({"name": "Theme"})).is_err());
assert_eq!(s.doc().unwrap().variables.collections.len(), before);
ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"}));
assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1);
}
+96 -4
View File
@@ -3,7 +3,7 @@
//! `vectoredit.rs` re-fit the node afterwards.
use figcraft_doc::*;
use figcraft_geom::kurbo::{CubicBez, ParamCurve};
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
use figcraft_geom::{Affine, Point, Vec2};
fn tvec(t: (f64, f64)) -> Vec2 {
@@ -236,6 +236,12 @@ pub fn bend(net: &mut VectorNetwork, seg: usize, t: f64, p: Point) {
pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usize> {
let s = net.segments.get(seg).copied()?;
let c: CubicBez = net.cubic(seg)?;
// Which way each region loop walks `seg`, read before the split changes its endpoints.
let reversed: Vec<Vec<bool>> = net
.regions
.iter()
.map(|reg| reg.loops.iter().map(|lp| net.loop_walk(lp).iter().find(|(i, _)| *i == seg).is_some_and(|(_, rev)| *rev)).collect())
.collect();
let t = if t.is_finite() { t.clamp(0.01, 0.99) } else { 0.5 };
let l = c.subsegment(0.0..t);
let r = c.subsegment(t..1.0);
@@ -263,10 +269,12 @@ pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usiz
};
net.segments.push(new_seg);
let ni = net.segments.len() - 1;
for reg in &mut net.regions {
for lp in &mut reg.loops {
for (r, reg) in net.regions.iter_mut().enumerate() {
for (l, lp) in reg.loops.iter_mut().enumerate() {
if let Some(pos) = lp.iter().position(|x| *x == seg) {
lp.insert(pos + 1, ni);
// Walked backwards, the new half (mid→b) comes before `seg` (a→mid).
let rev = reversed.get(r).and_then(|v| v.get(l)).copied().unwrap_or(false);
lp.insert(if rev { pos } else { pos + 1 }, ni);
}
}
}
@@ -766,6 +774,19 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
if ia { b } else { a }
};
let n = 64;
// Upper bound on the curve's speed: no point of a sample interval is farther than the whole
// interval length from either end, so only intervals that come that close need a closer look.
let speed = 3.0 * [(cb.p1 - cb.p0).hypot(), (cb.p2 - cb.p1).hypot(), (cb.p3 - cb.p2).hypot()].into_iter().fold(0.0, f64::max);
let reach = speed / f64::from(n);
// A crossing narrower than an interval whose ends are both outside: the closest point.
let dip = |a: f64, b: f64| -> Option<f64> {
if (cb.eval(a) - c).hypot().min((cb.eval(b) - c).hypot()) > r + reach {
return None;
}
let near = cb.subsegment(a..b).nearest(c, 1e-12);
let m = a + (b - a) * near.t;
(m > a && m < b && inside(m)).then_some(m)
};
let mut out = Vec::new();
let mut start: Option<f64> = if inside(0.0) { Some(0.0) } else { None };
let mut prev = 0.0;
@@ -773,6 +794,11 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
let t = f64::from(i) / f64::from(n);
match (start, inside(t)) {
(None, true) => start = Some(edge(prev, t)),
(None, false) => {
if let Some(m) = dip(prev, t) {
out.push((edge(prev, m), edge(m, t)));
}
}
(Some(s0), false) => {
out.push((s0, edge(prev, t)));
start = None;
@@ -856,6 +882,38 @@ mod tests {
assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}");
}
fn loop_is_continuous(n: &VectorNetwork, lp: &[usize]) -> bool {
let ends: Vec<(usize, usize)> = n
.loop_walk(lp)
.iter()
.map(|&(i, rev)| {
let s = n.segments[i];
if rev { (s.b, s.a) } else { (s.a, s.b) }
})
.collect();
(0..ends.len()).all(|k| ends[k].1 == ends[(k + 1) % ends.len()].0)
}
#[test]
fn split_keeps_region_loop_in_ring_order() {
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
n.segments.push(Segment::line(0, 2));
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
let p = Point::new(25.0, 10.0);
assert!(n.fill_contains(p));
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
let mut m = n.clone();
m.regions[0].loops = vec![lp];
let mid = split_segment(&mut m, seg, 0.5).unwrap();
assert!(loop_is_continuous(&m, &m.regions[0].loops[0]), "loop {:?}", m.regions[0].loops);
assert!(m.fill_contains(p), "split of {seg} removed {p:?} from the fill");
if seg == 1 {
assert_eq!(m.vertices[mid].point(), Point::new(100.0, 50.0));
}
}
}
#[test]
fn inferred_mirroring() {
let mut n = open_line();
@@ -999,4 +1057,38 @@ mod tests {
assert!(!erase(&mut m, Point::new(f64::NAN, 0.0), 5.0));
assert!(!erase(&mut m, Point::new(0.0, 0.0), -1.0));
}
#[test]
fn eraser_catches_narrow_crossing_between_samples() {
// A radius-1 brush on a long line: the crossing fits between the sampled parameters.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
assert!(erase(&mut n, Point::new(5050.0, 0.0), 1.0));
assert_eq!(n.segments.len(), 2);
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
assert!(ends.iter().any(|x| (x - 5049.0).abs() < 0.01) && ends.iter().any(|x| (x - 5051.0).abs() < 0.01), "{ends:?}");
assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 6400.0).abs() < 1e-9), "{ends:?}");
// A curved segment that only grazes the brush between samples.
let mut c = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
c.segments[0].ta = (0.0, 3000.0);
c.segments[0].tb = (0.0, 3000.0);
let cb = c.cubic(0).unwrap();
let t = 0.7039;
let p = cb.eval(t);
let d = figcraft_geom::kurbo::ParamCurveDeriv::deriv(&cb).eval(t).to_vec2().normalize();
let normal = Vec2::new(-d.y, d.x);
assert!(erase(&mut c, p + normal * 0.5, 1.0));
assert_eq!(c.segments.len(), 2);
}
#[test]
fn eraser_catches_crossing_at_full_speed_between_samples() {
// The line moves fastest at t = 0.5; centre a tiny brush halfway between two samples there.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
let centre = n.cubic(0).unwrap().eval(32.5 / 64.0);
assert!(erase(&mut n, centre, 0.5));
assert_eq!(n.segments.len(), 2);
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}");
assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}");
}
}
+17
View File
@@ -456,6 +456,23 @@ fn a_selected_guide_is_deleted_and_nudged_with_the_keyboard() {
assert!(s.execute("guide.select", &json!({"index": 7})).is_err(), "only an existing guide can be selected");
}
#[test]
fn removing_an_earlier_guide_keeps_the_selected_guide() {
let mut s = Session::new();
for offset in [100, 200, 300] {
ex(&mut s, "guide.add", json!({"vertical": true, "offset": offset}));
}
let page = s.page().unwrap();
ex(&mut s, "guide.select", json!({"index": 1}));
ex(&mut s, "guide.remove", json!({"index": 0}));
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)), "the guide at 200 stays selected at its new index");
assert!(s.tool_key("ArrowRight", false, false, false).unwrap());
assert_eq!(guide_list(&s, page), vec![(true, 201.0), (true, 300.0)], "the arrow nudges the selected guide, not its neighbour");
// A later guide leaves the selection alone.
ex(&mut s, "guide.remove", json!({"index": 1}));
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)));
}
#[test]
fn guides_snap_and_measure() {
let mut s = Session::new();