Merge main into #1006

This commit is contained in:
bflatastic
2026-10-24 12:53:50 +07:00
263 changed files with 11821 additions and 1375 deletions
+1 -1
View File
@@ -171,7 +171,7 @@ fn ready_for_dev_lists_frames_by_status() {
};
let ready = ex(&mut s, "inspect.readyForDev", json!({}));
assert_eq!(names(&ready), vec!["Login", "Onboarding", "Profile"], "every page, page and layer order");
assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev"}));
assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev", "changed": false}));
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]);
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any", "page": page}))), vec!["Login", "Signup", "Onboarding"]);
assert!(s.execute("inspect.readyForDev", &json!({"status": "none"})).is_err());
+1 -1
View File
@@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() {
let fills = s.doc().unwrap().node(r).unwrap().fills.clone();
assert_eq!(fills.len(), 2, "the second fill stays");
assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) });
assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included");
assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity");
assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) });
// Removing the bound (first) fill takes the binding with it.
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
File diff suppressed because it is too large Load Diff
+363
View File
@@ -0,0 +1,363 @@
//! Boards: the file kind, sticky notes, shapes with text, connectors that follow what they join
//! (locally, through undo and in live sessions), stamps, ink and the eraser, and refusals.
use figcraft_doc::{
BOARD_PAGE, BoardItem, BoardShape, Connector, End, FileKind, Node, NodeId, NodeKind, Routing, Side, Stamp, StrokeCap, board_color,
};
use figcraft_geom::Point;
use serde_json::{Value, json};
use crate::board::{route, sticky_font_size};
use crate::collab_live_tests::Room;
use crate::tools::{PointerEvent, PointerKind, ToolId};
use crate::{EngineError, Session};
pub(crate) fn board() -> Session {
let mut s = Session::new();
s.execute("file.newBoard", &json!({"name": "Ideas"})).unwrap();
s
}
pub(crate) fn id(v: &Value, key: &str) -> NodeId {
v[key].as_u64().map(NodeId).unwrap_or_else(|| panic!("no {key} in {v}"))
}
pub(crate) fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().node(id).unwrap().clone()
}
pub(crate) fn connector(s: &Session, id: NodeId) -> Connector {
match node(s, id).board {
Some(BoardItem::Connector(c)) => c,
other => panic!("not a connector: {other:?}"),
}
}
/// Connector `id`'s points on the page.
pub(crate) fn drawn(s: &Session, id: NodeId) -> Vec<Point> {
let doc = s.doc().unwrap();
let NodeKind::Vector { network } = &doc.node(id).unwrap().kind else { panic!("not a vector") };
let w = doc.world_transform(id);
network.vertices.iter().map(|v| w * v.point()).collect()
}
pub(crate) fn near(a: Point, b: Point) -> bool {
(a - b).hypot() < 1e-6
}
#[test]
fn a_board_is_a_kind_of_file_that_saves_and_reopens() {
let s = board();
let doc = s.doc().unwrap();
assert_eq!(doc.kind, FileKind::Board);
assert!(matches!(doc.node(doc.pages[0]).unwrap().kind, NodeKind::Page { background, .. } if background == BOARD_PAGE));
let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap();
assert_eq!(back.kind, FileKind::Board);
// Files from before boards (no kind) are design files, and design files don't write one.
let mut d = Session::new();
d.execute("file.new", &json!({})).unwrap();
let json = figcraft_format::to_json(d.doc().unwrap()).unwrap();
assert!(json.get("kind").is_none());
assert_eq!(figcraft_format::from_json(&json.to_string()).unwrap().0.kind, FileKind::Design);
assert_eq!(crate::inspect::document(&s, 0, false)["kind"], "board", "inspect shows the kind");
}
#[test]
fn board_commands_refuse_on_design_files_and_prototyping_refuses_on_boards() {
let mut d = Session::new();
d.execute("file.new", &json!({})).unwrap();
for (cmd, p) in [("board.sticky", json!({"x": 0, "y": 0})), ("tool.sticky", json!({})), ("tool.set", json!({"tool": "marker"}))] {
assert!(matches!(d.execute(cmd, &p), Err(EngineError::Disabled(..))), "{cmd} on a design file");
}
let mut s = board();
for cmd in ["view.devMode", "view.prototype", "proto.present", "proto.flow.add", "dev.focus"] {
let e = s.execute(cmd, &json!({})).unwrap_err();
assert!(e.to_string().contains("boards have no prototypes"), "{cmd}: {e}");
}
}
#[test]
fn a_sticky_is_a_square_frame_whose_note_shrinks_as_it_grows() {
let mut s = board();
s.prefs.user_name = "Ada".into();
let r = s.execute("board.sticky", &json!({"x": 10, "y": 20, "text": "Hi", "color": "blue"})).unwrap();
let (st, note) = (id(&r, "id"), id(&r, "text"));
let n = node(&s, st);
assert!(n.is_board_atom() && matches!(&n.board, Some(BoardItem::Sticky { author }) if author == "Ada"));
assert_eq!((n.width, n.height), (200.0, 200.0));
assert_eq!(n.fills[0].solid_color(), board_color("blue"));
assert!(n.layout.is_some() && !n.effects.is_empty());
assert_eq!(n.children.len(), 2, "the note and its author");
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0);
// A click on the note picks the sticky whole.
let doc = s.doc().unwrap();
assert_eq!(figcraft_doc::geometry::select_target(doc, note, &[], false), st);
// A long note takes a smaller size; a very long one grows the sticky past its square.
let long = "word ".repeat(120);
s.execute("node.set", &json!({"id": note, "props": {"characters": long}})).unwrap();
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, sticky_font_size(&long));
assert!(sticky_font_size(&long) < 16.0);
assert!(node(&s, st).width == 200.0 && node(&s, st).height >= 200.0);
// Undo puts the short note and its size back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0);
}
#[test]
fn next_sticky_goes_beside_in_the_same_colour_and_emptied_notes_stay() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "color": "pink"})).unwrap(), "id");
let r = s.execute("board.nextSticky", &json!({})).unwrap();
let b = id(&r, "id");
let (na, nb) = (node(&s, a), node(&s, b));
assert_eq!(nb.transform.translation().x, na.width + 24.0);
assert_eq!(nb.fills, na.fills);
assert_eq!(s.selection(), vec![b]);
// Typing in it and emptying it again keeps its note; ending selects the sticky.
let note = id(&r, "text");
crate::board::edit_text(&mut s, b).unwrap();
crate::textedit::insert(&mut s, "x").unwrap();
crate::textedit::key(&mut s, "Backspace", false, false, false).unwrap();
crate::textedit::end(&mut s).unwrap();
assert!(s.doc().unwrap().contains(note));
assert_eq!(s.selection(), vec![b]);
}
#[test]
fn shapes_carry_text_and_change_outline() {
let mut s = board();
let r = s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "text": "Decide", "color": "green"})).unwrap();
let sh = id(&r, "id");
let n = node(&s, sh);
assert_eq!(n.board, Some(BoardItem::Shape { shape: BoardShape::Diamond }));
assert_eq!(n.children.len(), 2);
assert!(matches!(node(&s, n.children[0]).kind, NodeKind::Vector { .. }));
s.execute("board.setStyle", &json!({"ids": [sh], "shape": "speechBubble", "fontSize": 24})).unwrap();
assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: BoardShape::SpeechBubble }));
assert_eq!(node(&s, id(&r, "text")).kind.text().unwrap().style.font_size, 24.0);
s.execute("board.setColor", &json!({"ids": [sh], "color": "#FF0000"})).unwrap();
assert_eq!(node(&s, n.children[0]).fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into()));
assert!(s.execute("board.shape", &json!({"shape": "blob", "x": 0, "y": 0})).is_err());
}
#[test]
fn connectors_follow_what_they_join_and_undo_with_it() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "then"})).unwrap(), "id");
let pts = drawn(&s, c);
assert!(near(pts[0], Point::new(200.0, 100.0)) && near(pts[1], Point::new(400.0, 100.0)), "{pts:?}");
assert_eq!(node(&s, c).stroke.cap_end, StrokeCap::TriangleArrow);
// Its label sits at the middle.
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
let lb = s.doc().unwrap().world_bounds(label).unwrap();
assert!(near(lb.center(), Point::new(300.0, 100.0)), "{lb:?}");
// Move one end: the connector re-routes (elbow now, out of the facing sides).
s.execute("board.setStyle", &json!({"ids": [c], "style": "elbow"})).unwrap();
s.execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 300})).unwrap();
let pts = drawn(&s, c);
assert!(near(pts[0], Point::new(200.0, 100.0)) && near(*pts.last().unwrap(), Point::new(400.0, 400.0)), "{pts:?}");
assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles: {pts:?}");
// Undo takes the move back and the route with it.
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0)));
// A deleted end stays where it was, as a point.
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
assert!(matches!(connector(&s, c).to, End::Point { .. }));
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0)));
// A connector moved on its own takes its free end along; the attached one stays.
s.execute("transform.move", &json!({"ids": [c], "dx": 0, "dy": 50})).unwrap();
assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 400.0 && y == 150.0), "{:?}", connector(&s, c));
assert!(near(drawn(&s, c)[0], Point::new(200.0, 100.0)));
// Deleting the connector takes its label.
s.execute("edit.delete", &json!({"ids": [c]})).unwrap();
assert!(!s.doc().unwrap().contains(label));
assert!(s.execute("board.connector", &json!({"from": a, "to": a})).is_err());
}
#[test]
fn routes_are_the_same_for_the_same_document() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0})).unwrap(), "id");
let c = Connector { from: End::Node { node: a, side: Side::Bottom }, to: End::Point { x: 300.0, y: 500.0 }, routing: Routing::Elbow };
let doc = s.doc().unwrap();
let r = route(doc, &c).unwrap();
assert_eq!(r, route(&doc.clone(), &c).unwrap());
// Down out of the bottom, across halfway, down into the point.
assert_eq!(r, vec![Point::new(80.0, 160.0), Point::new(80.0, 330.0), Point::new(300.0, 330.0), Point::new(300.0, 500.0)]);
}
#[test]
fn stamps_and_ink_and_the_eraser() {
let mut s = board();
s.prefs.user_name = "Grace".into();
let st = id(&s.execute("board.stamp", &json!({"x": 100, "y": 100, "stamp": "heart"})).unwrap(), "id");
let n = node(&s, st);
assert_eq!(n.board, Some(BoardItem::Stamp { stamp: Stamp::Heart, author: "Grace".into() }));
assert_eq!(s.doc().unwrap().world_bounds(st).unwrap().center(), Point::new(100.0, 100.0));
assert!(!n.children.is_empty());
// Marker and highlighter strokes through the tools; the eraser takes what it touches.
let draw = |s: &mut Session, tool: &str, y: f64| {
s.execute("tool.set", &json!({"tool": tool})).unwrap();
for (k, x) in [(PointerKind::Down, 0.0), (PointerKind::Move, 50.0), (PointerKind::Move, 100.0), (PointerKind::Up, 150.0)] {
s.pointer(&PointerEvent::at(k, x, y, 1.0)).unwrap();
}
};
draw(&mut s, "marker", 300.0);
draw(&mut s, "highlighter", 400.0);
let inks: Vec<Node> = s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).cloned().collect();
assert_eq!(inks.len(), 2);
assert!(inks.iter().any(|n| n.opacity < 1.0 && n.stroke.weight > 10.0), "a highlighter stroke");
let undo = s.undo_depth().0;
s.execute("tool.eraser", &json!({})).unwrap();
for (k, x) in [(PointerKind::Down, 20.0), (PointerKind::Move, 60.0), (PointerKind::Up, 80.0)] {
s.pointer(&PointerEvent::at(k, x, 300.0, 1.0)).unwrap();
}
assert_eq!(s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).count(), 1, "the marker stroke is gone");
assert_eq!(s.undo_depth().0, undo + 1, "one step");
assert_eq!(s.tool.tool, ToolId::Eraser);
}
#[test]
fn the_sticky_and_connector_tools() {
let mut s = board();
s.execute("tool.sticky", &json!({"color": "orange"})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, 1.0)).unwrap();
let te = s.ds().unwrap().text_edit.clone().expect("typing in the new sticky");
let a = crate::board::owner_of_text(s.doc().unwrap(), te.id).unwrap();
assert_eq!(node(&s, a).fills[0].solid_color(), board_color("orange"));
crate::textedit::insert(&mut s, "First").unwrap();
crate::textedit::end(&mut s).unwrap();
let b = id(&s.execute("board.sticky", &json!({"x": 500, "y": 0})).unwrap(), "id");
s.execute("tool.connector", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 300.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 100.0, 1.0)).unwrap();
let c = s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).map(|n| n.id).unwrap();
let k = connector(&s, c);
assert_eq!((k.from.node(), k.to.node()), (Some(a), Some(b)));
}
#[test]
fn boards_sync_in_live_sessions() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "A"})).unwrap(), "id");
let mut room = Room::new(s.doc().unwrap().clone(), 2, 7);
assert_eq!(room.s(1).doc().unwrap().kind, FileKind::Board, "joiners get a board");
let b = id(&room.s(1).execute("board.sticky", &json!({"x": 400, "y": 0, "text": "B"})).unwrap(), "id");
room.settle();
let c = id(&room.s(0).execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id");
room.s(1).execute("board.stamp", &json!({"x": 50, "y": 50, "stamp": "star"})).unwrap();
room.settle();
room.check();
// Person 1 moves the sticky person 0 connected: both see the connector follow it.
room.s(1).execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 250})).unwrap();
room.s(0).execute("board.setColor", &json!({"ids": [a], "color": "violet"})).unwrap();
room.settle();
room.check();
let doc = room.server.document();
let NodeKind::Vector { network } = &doc.node(c).unwrap().kind else { panic!("not a vector") };
let end = doc.world_transform(c) * network.vertices.last().unwrap().point();
assert_eq!(end, Point::new(400.0, 350.0), "to the moved sticky's facing side");
assert_eq!(doc.node(a).unwrap().fills[0].solid_color(), board_color("violet"));
}
#[test]
fn board_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!([1]),
json!({"x": "NaN", "y": 1e308, "text": 5, "color": [1], "author": {"x": 1}, "parent": "p"}),
json!({"x": -1e308, "y": 0, "color": "neon", "shape": 7, "width": -5, "height": 1e308, "stamp": "skull"}),
json!({"from": {"x": "a"}, "to": 99_999, "fromSide": "inside", "style": 3, "startCap": "spike", "endCap": 7, "label": [1]}),
json!({"from": {"x": 1e308, "y": -1e308}, "to": {"x": 0, "y": 0}, "style": "elbow", "label": "\u{0}"}),
json!({"ids": ["x", -1, 99_999], "color": "#GGG", "fontSize": -1, "side": "up", "edit": "yes"}),
json!({"id": 2, "text": "", "path": "M0 0 L", "points": [[1]], "highlighter": "yes"}),
json!({"path": "M0 0 L 1e308 1e308", "points": [[0, 0], [1e308, 1]]}),
];
let ids: Vec<&str> = crate::cmd::all_commands()
.iter()
.map(|c| c.id)
.filter(|i| {
i.starts_with("board.")
|| ["tool.sticky", "tool.shape", "tool.connector", "tool.marker", "tool.highlighter", "tool.eraser", "tool.stamp", "file.newBoard"]
.contains(i)
})
.collect();
assert!(ids.len() >= 17, "{ids:?}");
for cmd in ids {
for j in &junk {
let mut s = board();
let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone();
let b = s.execute("board.shape", &json!({"x": 300, "y": 0})).unwrap()["id"].clone();
let _ = s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
// Every other command on a board with objects on it.
for spec in crate::cmd::all_commands() {
if spec.id.starts_with("file.") && spec.id != "file.newBoard" || spec.id == "text.rescanFonts" || spec.id == "image.place" {
continue;
}
for j in &junk[..3] {
let mut s = board();
let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone();
let b = s.execute("board.stamp", &json!({"x": 300, "y": 0})).unwrap()["id"].clone();
let _ = s.execute("board.connector", &json!({"from": a, "to": b}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}
#[test]
fn a_press_on_a_stickys_background_picks_and_drags_it() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "Hi"})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id");
s.execute("select.none", &json!({})).unwrap();
// Below the note, on the sticky's own fill (a top-level frame's background is the canvas).
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 150.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 250.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 450.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 450.0, 1.0)).unwrap();
assert_eq!(s.selection(), vec![a]);
assert_eq!(node(&s, a).transform.translation().y, 300.0);
assert!(near(drawn(&s, c)[0], Point::new(200.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c));
}
#[test]
fn a_copy_of_joined_objects_joins_the_copies() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"})).unwrap(), "id");
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
s.execute("edit.duplicate", &json!({"ids": [a, b, c, label]})).unwrap();
let copies = s.selection();
assert_eq!(copies.len(), 4, "{copies:?}");
let k = copies.iter().find_map(|i| match node(&s, *i).board {
Some(BoardItem::Connector(k)) => Some(k),
_ => None,
});
let k = k.unwrap();
assert!(copies.contains(&k.from.node().unwrap()) && copies.contains(&k.to.node().unwrap()), "{k:?} {copies:?}");
// Copy and paste too.
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let pasted = s.selection();
let k = pasted.iter().find_map(|i| match node(&s, *i).board {
Some(BoardItem::Connector(k)) => Some(k),
_ => None,
});
let k = k.unwrap();
assert!(pasted.contains(&k.from.node().unwrap()) && pasted.contains(&k.to.node().unwrap()), "{k:?} {pasted:?}");
}
+277
View File
@@ -0,0 +1,277 @@
//! Boards, continued: copies and pastes (within a file and from another), routes with named sides
//! and free points, connectors inside moving sections, styles and labels with their refusals, and
//! the shape, stamp, highlighter and connector tools with double-clicks.
use figcraft_doc::{BoardItem, BoardShape, End, NodeId, Side, Stamp, StrokeCap, board_color};
use figcraft_geom::Point;
use serde_json::json;
use crate::Session;
use crate::board::route;
use crate::board_tests::{board, connector, drawn, id, near, node};
use crate::tools::{PointerEvent, PointerKind, ToolId};
/// The connector among `ids`, if any.
fn connector_in(s: &Session, ids: &[NodeId]) -> Option<figcraft_doc::Connector> {
let doc = s.doc().ok()?;
ids.iter().find_map(|i| match &doc.node(*i)?.board {
Some(BoardItem::Connector(k)) => Some(*k),
_ => None,
})
}
#[test]
fn pasting_into_another_board_detaches_what_was_left_behind() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let r = s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "x"})).unwrap();
let (c, label) = (id(&r, "id"), id(&r, "label"));
let first = s.active;
// A second board whose own ids are the same small numbers as the first's.
let second = s.execute("file.newBoard", &json!({})).unwrap()["index"].clone();
for x in [0, 300, 600, 900] {
s.execute("board.sticky", &json!({"x": x, "y": 500})).unwrap();
}
let others: Vec<NodeId> = s.doc().unwrap().nodes().map(|n| n.id).collect();
s.execute("doc.switch", &json!({"index": first})).unwrap();
s.execute("select.set", &json!({"ids": [a, c, label]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("doc.switch", &json!({"index": second})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let doc = s.doc().unwrap();
let pasted = doc.nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_))) && !others.contains(&n.id)).unwrap().clone();
let k = connector(&s, pasted.id);
let from = k.from.node().unwrap();
assert!(!others.contains(&from) && matches!(doc.node(from).unwrap().board, Some(BoardItem::Sticky { .. })), "joins the pasted sticky");
let to = drawn(&s, pasted.id).last().copied().unwrap();
assert!(matches!(k.to, End::Point { x, y } if near(Point::new(x, y), to)), "the end left behind stays where it is drawn: {k:?}");
let pasted_label = crate::board::label_of(doc, pasted.id).expect("the label came with its connector");
assert!(!others.contains(&pasted_label));
// A label copied without its connector doesn't come.
s.execute("doc.switch", &json!({"index": first})).unwrap();
s.execute("select.set", &json!({"ids": [label]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("doc.switch", &json!({"index": second})).unwrap();
let labels = |s: &Session| s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Label { .. }))).count();
let before = labels(&s);
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(labels(&s), before);
// Within its own file, a connector pasted alone stays joined to the same layers.
s.execute("doc.switch", &json!({"index": first})).unwrap();
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let k = connector_in(&s, &s.selection()).unwrap();
assert_eq!((k.from.node(), k.to.node()), (Some(a), Some(b)));
}
#[test]
fn duplicating_and_alt_dragging_keep_connections_inside_copied_sections() {
let mut s = board();
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": -50, "y": -50, "width": 400, "height": 400})).unwrap(), "id");
let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 600, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id");
s.execute("edit.duplicate", &json!({"ids": [sec, c]})).unwrap();
let copies = s.selection();
let from = connector_in(&s, &copies).unwrap().from.node().unwrap();
let doc = s.doc().unwrap();
assert!(from != a && copies.iter().any(|r| doc.is_ancestor(*r, from)), "the copy joins the sticky inside the copied section");
// Alt-drag copies the same way.
s.execute("select.set", &json!({"ids": [b, c]})).unwrap();
let press = |k, x| PointerEvent { alt: true, ..PointerEvent::at(k, x, 100.0, 1.0) };
for (k, x) in [(PointerKind::Down, 700.0), (PointerKind::Move, 760.0), (PointerKind::Move, 800.0), (PointerKind::Up, 800.0)] {
s.pointer(&press(k, x)).unwrap();
}
let copies = s.selection();
assert_eq!(copies.len(), 2);
let k = connector_in(&s, &copies).unwrap();
assert!(copies.contains(&k.to.node().unwrap()), "{k:?}");
}
#[test]
fn routes_take_named_sides_and_free_points() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(), "id");
let b = id(&s.execute("board.shape", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(), "id");
let doc = s.doc().unwrap();
let elbow = |from, to| route(doc, &figcraft_doc::Connector { from, to, routing: figcraft_doc::Routing::Elbow }).unwrap();
let side = |n, side| End::Node { node: n, side };
let p = |x, y| End::Point { x, y };
let pt = Point::new;
// Side to side: out, across halfway, in.
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(100.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(300.0, 350.0)]);
// A side and a top or bottom: one turn.
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(100.0, 50.0), pt(350.0, 50.0), pt(350.0, 300.0)]);
assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 100.0), pt(50.0, 350.0), pt(300.0, 350.0)]);
// Two free points: the longer way first; in a line, no turns.
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 40.0)), vec![pt(0.0, 0.0), pt(50.0, 0.0), pt(50.0, 40.0), pt(100.0, 40.0)]);
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 0.0)), vec![pt(0.0, 0.0), pt(100.0, 0.0)]);
// An automatic side faces the other end.
let straight = route(doc, &figcraft_doc::Connector { from: side(a, Side::Auto), to: p(50.0, 500.0), routing: figcraft_doc::Routing::Straight });
assert_eq!(straight.unwrap(), vec![pt(50.0, 100.0), pt(50.0, 500.0)]);
assert_eq!(Side::from_id("TOP"), Some(Side::Top));
assert_eq!(Side::from_id("inside"), None);
// The same place twice is refused, as are a side that doesn't exist and a point without numbers.
assert!(s.execute("board.connector", &json!({"from": {"x": 5, "y": 5}, "to": {"x": 5, "y": 5}})).is_err());
assert!(s.execute("board.connector", &json!({"from": a, "to": b, "fromSide": "inside"})).is_err());
assert!(s.execute("board.connector", &json!({"from": a, "to": {"x": "a"}})).is_err());
assert!(s.execute("board.connector", &json!({"from": a})).is_err());
}
#[test]
fn a_connector_follows_a_layer_whose_section_moves_and_ignores_unrelated_edits() {
let mut s = board();
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 300})).unwrap(), "id");
let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id");
let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id");
assert!(near(drawn(&s, c)[0], Point::new(250.0, 150.0)));
// Only the section is edited; the sticky inside it moves with it, and so does the connector.
s.execute("transform.move", &json!({"ids": [sec], "dx": 0, "dy": 100})).unwrap();
assert!(near(drawn(&s, c)[0], Point::new(250.0, 250.0)), "{:?}", drawn(&s, c));
// An edit that touches neither end leaves the connector as it was.
let before = s.doc().unwrap().node_arc(c).cloned().unwrap();
s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap();
assert!(std::sync::Arc::ptr_eq(&before, s.doc().unwrap().node_arc(c).unwrap()));
}
#[test]
fn styles_labels_and_their_refusals() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "author": false})).unwrap(), "id");
assert_eq!(node(&s, a).children.len(), 1, "no author line");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0, "author": " Lin "})).unwrap(), "id");
assert!(matches!(node(&s, b).board, Some(BoardItem::Sticky { ref author }) if author == "Lin"));
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "startCap": "circleFilled", "endCap": "none"})).unwrap(), "id");
assert_eq!((node(&s, c).stroke.cap_start, node(&s, c).stroke.cap_end), (StrokeCap::CircleFilled, StrokeCap::None));
// Labels: set, change, size, remove; named by the label itself too.
let l = id(&s.execute("board.label", &json!({"id": c, "text": "first"})).unwrap(), "label");
assert_eq!(id(&s.execute("board.label", &json!({"id": l, "text": "second"})).unwrap(), "label"), l, "the same label, new text");
let text = crate::board::text_of(s.doc().unwrap(), l).unwrap();
assert_eq!(node(&s, text).kind.text().unwrap().characters, "second");
s.execute("board.setStyle", &json!({"ids": [l], "fontSize": 36})).unwrap();
assert_eq!(node(&s, text).kind.text().unwrap().style.font_size, 36.0);
assert!(s.execute("board.label", &json!({"id": c, "text": ""})).unwrap()["label"].is_null());
assert!(!s.doc().unwrap().contains(l));
// What doesn't take a style or colour says so.
assert!(s.execute("board.setStyle", &json!({"ids": [a], "fontSize": 30})).is_err(), "stickies size their own text");
assert!(s.execute("board.setStyle", &json!({"ids": [c], "fontSize": 0})).is_err());
let st = id(&s.execute("board.stamp", &json!({"x": 0, "y": 0, "stamp": "check"})).unwrap(), "id");
assert!(s.execute("board.setColor", &json!({"ids": [st], "color": "blue"})).is_err());
assert!(s.execute("board.setColor", &json!({"ids": [a]})).is_err());
assert!(s.execute("board.label", &json!({"id": a, "text": "x"})).is_err());
// A palette colour on a line draws in its deeper shade; a hex colour as given.
s.execute("board.setColor", &json!({"ids": [c], "color": "blue"})).unwrap();
assert!(node(&s, c).strokes[0].solid_color().is_some() && node(&s, c).strokes[0].solid_color() != board_color("blue"));
s.execute("board.setColor", &json!({"ids": [c], "color": "#123456"})).unwrap();
assert_eq!(node(&s, c).strokes[0].solid_color().map(|c| c.to_hex()), Some("123456".into()));
// Next stickies on each side, and refusals.
for (side, dx, dy) in [("below", 0.0, 224.0), ("left", -224.0, 0.0), ("above", 0.0, -224.0)] {
let n = id(&s.execute("board.nextSticky", &json!({"id": a, "side": side})).unwrap(), "id");
let t = node(&s, n).transform.translation();
assert_eq!((t.x, t.y), (dx, dy), "{side}");
}
assert!(s.execute("board.nextSticky", &json!({"id": a, "side": "diagonal"})).is_err());
assert!(s.execute("board.nextSticky", &json!({"id": c})).is_err());
assert!(s.execute("board.ink", &json!({})).is_err());
assert!(s.execute("board.sticky", &json!({"x": 0, "y": 0, "parent": c})).is_err(), "a connector holds no layers");
}
#[test]
fn the_shape_stamp_highlighter_and_connector_tools_and_double_clicks() {
let mut s = board();
let click = |s: &mut Session, x, y| {
s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap();
};
s.execute("tool.shape", &json!({"shape": "roundedRectangle"})).unwrap();
click(&mut s, 100.0, 100.0);
let sh = s.selection()[0];
assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: BoardShape::RoundedRectangle }));
assert_eq!(s.tool.tool, ToolId::Move, "back to Move after placing");
s.execute("tool.stamp", &json!({"stamp": "question"})).unwrap();
click(&mut s, 400.0, 100.0);
click(&mut s, 450.0, 100.0);
let stamps = s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Stamp { stamp: Stamp::Question, .. }))).count();
assert_eq!(stamps, 2);
assert_eq!(s.tool.tool, ToolId::Stamp, "stamps go on one after another");
assert!(s.execute("tool.stamp", &json!({"stamp": "skull"})).is_err());
s.execute("tool.highlighter", &json!({"color": "pink"})).unwrap();
assert_eq!(Some(s.tool.board.highlighter), board_color("pink"));
s.execute("tool.marker", &json!({"color": "#336699"})).unwrap();
assert_eq!(s.tool.board.marker.to_hex(), "336699");
// A connector drag that is only a click, or from an object to itself, makes nothing.
s.execute("tool.connector", &json!({"style": "straight"})).unwrap();
click(&mut s, 100.0, 100.0);
s.pointer(&PointerEvent::at(PointerKind::Down, 60.0, 120.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 140.0, 1.0)).unwrap();
assert!(s.tool.overlay.rubber.is_some(), "the rubber band shows while dragging");
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 140.0, 1.0)).unwrap();
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))));
// From the shape out to empty canvas: a connector to a free point.
s.pointer(&PointerEvent::at(PointerKind::Down, 80.0, 140.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 80.0, 600.0, 1.0)).unwrap();
let c = s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).map(|n| n.id).unwrap();
assert!(matches!(connector(&s, c).to, End::Point { .. }));
// Double-clicks: on the shape its text, on the connector a new label, both typed into.
s.execute("tool.move", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 80.0, 140.0, 1.0)).unwrap();
let te = s.ds().unwrap().text_edit.clone().expect("typing in the shape");
assert_eq!(crate::board::owner_of_text(s.doc().unwrap(), te.id), Some(sh));
crate::textedit::end(&mut s).unwrap();
let line = drawn(&s, c);
let m = line[0].midpoint(line[1]);
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, m.x, m.y, 1.0)).unwrap();
let label = crate::board::label_of(s.doc().unwrap(), c).expect("a label was added");
let te = s.ds().unwrap().text_edit.clone().expect("typing in the label");
assert_eq!(crate::board::owner_of_text(s.doc().unwrap(), te.id), Some(label));
}
#[test]
fn an_ink_stroke_ends_where_the_button_comes_up() {
let mut s = board();
s.execute("tool.marker", &json!({})).unwrap();
// Down and up with no move between: the stroke still runs from the press to the release.
s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 150.0, 0.0, 1.0)).unwrap();
let doc = s.doc().unwrap();
let ink = doc.nodes().find(|n| matches!(n.board, Some(BoardItem::Ink))).map(|n| n.id).expect("a stroke");
let b = doc.world_bounds(ink).unwrap();
assert!(b.x0.abs() < 6.0 && (b.x1 - 150.0).abs() < 6.0, "from 0 to 150 (stroke width aside): {b:?}");
}
#[test]
fn a_second_sticky_ends_the_first_ones_typing_and_undoes_alone() {
let mut s = board();
s.execute("tool.sticky", &json!({})).unwrap();
s.prefs.keep_tool = true;
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, 1.0)).unwrap();
let text = s.ds().unwrap().text_edit.as_ref().expect("typing in the new sticky").id;
crate::textedit::insert(&mut s, "first note").unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 500.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 500.0, 100.0, 1.0)).unwrap();
let stickies = |s: &Session| s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Sticky { .. }))).count();
let typed = |s: &Session| s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters.clone();
assert_eq!(stickies(&s), 2);
crate::textedit::end(&mut s).unwrap();
s.undo().unwrap();
assert_eq!(stickies(&s), 1, "the second sticky is its own undo step");
assert_eq!(typed(&s), "first note", "the first sticky keeps what was typed in it");
}
#[test]
fn a_board_tool_left_on_presses_as_move_in_a_design_file() {
let mut s = board();
s.execute("tool.sticky", &json!({})).unwrap();
s.execute("file.new", &json!({})).unwrap();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(), "id");
s.execute("select.none", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 50.0, 50.0, 1.0)).unwrap();
assert_eq!(s.tool.tool, ToolId::Move);
assert_eq!(s.selection(), vec![r], "the press selected as Move does");
}
+33
View File
@@ -129,3 +129,36 @@ fn code_gives_a_translucent_stroke_its_alpha_once() {
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
}
#[test]
fn binding_keeps_an_opacity_set_on_the_paint() {
// A 15% tint whose colour comes from a variable (#923): the bind replaces the colour only.
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light"]}));
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "red", "type": "color", "value": "#FF0000"}));
let props = json!({"fills": [{"type": "solid", "color": "#000000", "opacity": 0.15}]});
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "props": props})).unwrap();
let r = NodeId(r["id"].as_u64().unwrap());
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "red"}));
let p = s.doc().unwrap().node(r).unwrap().fills[0].clone();
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0xFF, 0, 0) });
assert!(near(p.opacity, 0.15), "{}", p.opacity);
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.15));
// An opacity set on a `#RRGGBBAA` fill is the user's too, and stays.
let (mut s, r) = setup("#FF0000", "#FF0000");
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.25}));
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r));
}
#[test]
fn an_unbind_and_rebind_keeps_the_opacity_set_on_the_paint() {
// A paint at 50% bound to a half-transparent colour shows a quarter, unbound or bound again.
let (mut s, r) = setup("#FF000080", "#FF000080");
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
for variable in [json!("tint"), json!(null), json!("tint"), json!(null)] {
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": variable}));
assert!(near(alpha(&s, r), HALF / 2.0), "{variable}: {}", alpha(&s, r));
}
}
+4 -3
View File
@@ -21,8 +21,9 @@ pub const SEP: &str = "-";
pub static MENU: &[MenuItem] = &[
// File
m!("File", "New design file", "file.new"),
// FigCraft's own: starter templates (not counted by `parity`, like the other items of its own
// listed in `OWN`).
m!("File", "New board", "file.newBoard"),
// FigCraft's own: boards and starter templates (not counted by `parity`, like the other items
// of its own listed in `OWN`).
m!("File", "New from template…", "file.newFromTemplate"),
m!("File", "New window", "window.new"),
m!("File", "Open…", "file.open"),
@@ -289,7 +290,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"];
const OWN: [&str; 4] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
+35 -25
View File
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"component.resetOverride",
"Reset override",
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}",
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}",
true,
reset_override
),
@@ -786,6 +786,38 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layers": pushed}))
}
fn restore_prop_defs(doc: &Document, props: &BTreeMap<String, Value>) -> BTreeMap<String, PropDef> {
let mut defs = BTreeMap::new();
for (k, v) in props {
let def = match v {
Value::Bool(b) => PropDef::Boolean { default: *b },
Value::String(t) => PropDef::Text { default: t.clone() },
other => match id_of(other) {
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
_ => continue,
},
};
defs.insert(k.clone(), def);
}
defs
}
fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap<NodeId, NodeId> {
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
let mut used: BTreeSet<NodeId> = BTreeSet::new();
map.insert(i, component);
used.insert(component);
for d in sub.iter().filter(|d| **d != i) {
let src = doc.node(*d).and_then(|n| n.source);
let nid = match src {
Some(s) if !doc.contains(s) && used.insert(s) => s,
_ => doc.alloc_id(),
};
map.insert(*d, nid);
}
map
}
fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.restoreMain";
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
@@ -804,35 +836,13 @@ fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
// The main comes back with its old id (other instances point to it) and its layers with the
// ids their copies mirror, so every instance re-links by source.
let sub = doc.subtree(i);
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
let mut used: BTreeSet<NodeId> = BTreeSet::new();
map.insert(i, component);
used.insert(component);
for d in sub.iter().filter(|d| **d != i) {
let src = doc.node(*d).and_then(|n| n.source);
let nid = match src {
Some(s) if !doc.contains(s) && used.insert(s) => s,
_ => doc.alloc_id(),
};
map.insert(*d, nid);
}
let map = restore_id_map(doc, i, component, &sub);
let mut nodes = Vec::new();
let mut nested = Vec::new();
for d in &sub {
let Some(mut n) = doc.node(*d).cloned() else { continue };
if *d == i {
let mut defs = BTreeMap::new();
for (k, v) in &props {
let def = match v {
Value::Bool(b) => PropDef::Boolean { default: *b },
Value::String(t) => PropDef::Text { default: t.clone() },
other => match id_of(other) {
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
_ => continue,
},
};
defs.insert(k.clone(), def);
}
let defs = restore_prop_defs(doc, &props);
n.kind = NodeKind::Component { description: String::new(), props: defs };
n.source = None;
n.overrides.clear();
+2 -1
View File
@@ -111,7 +111,8 @@ pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) ->
return None;
}
let n = doc.node(id)?;
if !n.visible || n.locked {
// Board objects (stickies, shapes with text) never take layers in.
if !n.visible || n.locked || n.is_board_atom() {
return None;
}
let is_container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
+13 -1
View File
@@ -494,6 +494,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
}
out.push(nid);
}
// Board connectors copied with what they join join the copies.
let map = crate::board::copy_map(doc, &ids, &out);
crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false);
s.set_selection(out.clone());
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
Ok(json!({"ids": out}))
@@ -675,6 +678,10 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?);
all.extend(map);
}
// Board connectors pasted with what they join join the pasted copies.
let originals: Vec<(NodeId, figcraft_doc::BoardItem)> =
clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect();
crate::board::rejoin(doc, &originals, &all, !copy_local);
if let Some(src) = foreign {
crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?;
}
@@ -691,7 +698,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let target = id(p, "parent").or_else(|| {
sel.first()
.copied()
.filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
.filter(|i| {
sel.len() == 1
&& doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom())
})
.filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i)))
});
let parent = target.unwrap_or(page);
@@ -1365,10 +1375,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) };
if c.a < 1.0 {
slot.opacity = c.a;
slot.color_alpha = c.a;
}
}
if let Some(o) = num(p, "opacity") {
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
slot.color_alpha = 1.0;
}
if let Some(v) = boolean(p, "visible") {
slot.visible = v;
+34 -19
View File
@@ -11,22 +11,34 @@ use super::{CommandSpec, ok};
use crate::params::*;
use crate::{DocState, EngineError, Result, Session, bad, spec};
/// A new file of `kind` in a tab of its own.
fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result<Value> {
let name = string(p, "name").unwrap_or_else(|| if kind == FileKind::Board { "Untitled board" } else { "Untitled" }.into());
let mut doc = Document::new(name);
doc.kind = kind;
// A property of the page from here on: a later change of theme leaves it alone. Boards are
// light whatever the theme.
let background = if kind == FileKind::Board { BOARD_PAGE } else { s.new_page_background() };
for page in doc.pages.clone() {
if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
*b = background;
}
}
s.docs.push(DocState::new(doc, None));
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active, "kind": kind.id()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("file.new", "New design file", "{name?}", false, |s, p| {
let name = string(p, "name").unwrap_or_else(|| "Untitled".into());
let mut doc = Document::new(name);
// A property of the page from here on: a later change of theme leaves it alone.
let background = s.new_page_background();
for page in doc.pages.clone() {
if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
*b = background;
}
}
s.docs.push(DocState::new(doc, None));
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}),
spec!("file.new", "New design file", "{name?}", false, |s, p| new_file(s, p, FileKind::Design)),
spec!(
"file.newBoard",
"New board",
"{name?} a board: sticky notes, shapes with text, connectors, marker and stamps on a light page",
false,
|s, p| new_file(s, p, FileKind::Board)
),
spec!(
"file.open",
"Open",
@@ -366,7 +378,7 @@ fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result<usize> {
if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) }
}
fn read_version(v: &Version) -> Result<Arc<Document>> {
pub(crate) fn read_version(v: &Version) -> Result<Arc<Document>> {
v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name)))
}
@@ -476,11 +488,11 @@ pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> {
}
fn ext(path: &str) -> String {
std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
}
fn file_stem(path: &str) -> String {
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string()
}
/// The counts the import report shows for a freshly loaded document.
@@ -1169,8 +1181,11 @@ fn export_path(name: &str) -> String {
if parts.is_empty() { "Untitled".into() } else { parts.join("/") }
}
/// `name` without path separators and characters file systems reject.
fn safe_file_name(name: &str) -> String {
/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and
/// control characters become `_`); everything else is kept, up to the first 200 characters.
/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before
/// the number and extension it adds).
pub fn safe_file_name(name: &str) -> String {
name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect()
}
+1
View File
@@ -109,6 +109,7 @@ fn registry() -> &'static Registry {
list.extend(crate::online::specs());
list.extend(crate::invite_links::specs());
list.extend(crate::templates::specs());
list.extend(crate::board::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
if index.insert(s.id, i).is_some() {
+28 -9
View File
@@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
@@ -372,14 +372,21 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
}
}
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
/// bindings last so paints set in the same call don't detach them.
/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does.
fn paint_none(v: &Value) -> bool {
v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
}
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
/// links after the values so a link wins over values its style sets, variable bindings last so
/// paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
match k {
"width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"boundVariables" => 4,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
"boundVariables" => 5,
_ => 1,
}
}
@@ -462,13 +469,21 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
}
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
"fill" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
if paint_none(v) {
set_paints(doc.get_mut(id)?, true, vec![]);
} else {
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?;
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
}
}
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
"stroke" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
if paint_none(v) {
set_paints(doc.get_mut(id)?, false, vec![]);
} else {
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?;
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
}
}
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
@@ -533,8 +548,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
} else {
v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s)))
};
let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
if !st.is_none() && !crate::devinspect::has_status(doc, id) {
return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one"));
}
let n = doc.get_mut(id)?;
n.dev_status = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
n.dev_status = st;
// Marking a layer (again) starts from its current contents.
n.dev_changed = false;
}
+22 -6
View File
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"dev.compare",
"Compare changes",
"{id? (default the first selected layer), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked (`snapshot` is false when that isn't known: a file from before snapshots were kept)",
"{id? (default the first selected layer), version? (a version id from version.list: compare with that saved version instead; any top-level frame or section), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, version: {id, name, time} | null, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked, or since the version (`snapshot` is false when that isn't known: a file from before snapshots were kept)",
false,
compare
),
@@ -104,14 +104,30 @@ fn resolve_change(s: &mut Session, p: &Value) -> Result<Value> {
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "dev.compare";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame or section or pass `id`"))?;
let doc = s.doc()?;
let version = match get(p, "version") {
None | Some(Value::Null) => None,
Some(Value::String(v)) => Some(v.clone()),
Some(_) => return Err(bad(cmd, "version: a version id (version.list)")),
};
let ds = s.ds()?;
let doc = &ds.doc;
let n = doc.node(id).ok_or_else(|| bad(cmd, "no such layer"))?;
if !has_status(doc, id) || n.dev_status != DevStatus::ReadyForDev {
return Err(bad(cmd, "compare a frame or section marked ready for dev"));
if !has_status(doc, id) {
return Err(bad(cmd, "compare a top-level frame or a section"));
}
let v = serde_json::to_value(crate::devcompare::compare(doc, id)).unwrap_or(Value::Null);
let (cmp, against) = match &version {
Some(v) => {
let ver = ds.versions.iter().find(|x| x.id == *v).ok_or_else(|| bad(cmd, format!("no version `{v}`")))?;
let before = crate::cmd::file::read_version(ver)?;
(crate::devcompare::compare_with(&before, doc, id), json!({"id": ver.id, "name": ver.name, "time": ver.time}))
}
None if n.dev_status != DevStatus::ReadyForDev => return Err(bad(cmd, "compare a frame or section marked ready for dev, or pass `version`")),
None => (crate::devcompare::compare(doc, id), Value::Null),
};
let mut v = serde_json::to_value(cmp).unwrap_or(Value::Null);
v["version"] = against;
if boolean(p, "show") == Some(true) {
s.request_ui("view.devCompare", json!({"id": id}));
s.request_ui("view.devCompare", json!({"id": id, "version": version}));
}
Ok(v)
}
+157
View File
@@ -107,6 +107,20 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!(
"vector.fillRule",
"Set fill rule",
"{ids? | id?, region? (a filled area of a vector; every area when left out), rule: nonZero|evenOdd}",
true,
fill_rule
),
spec!(
"vector.reverseLoop",
"Reverse loop",
"{ids? | id?, region, loop} (a loop of a filled area; its direction flips, which changes how the area fills)",
true,
reverse_loop
),
spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify),
spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path),
spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| {
@@ -729,3 +743,146 @@ fn resize_to_fit(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(json!({"ids": fitted}))
}
/// The vector network of `id`, when it is a vector layer.
fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> {
match &mut doc.get_mut(id).ok()?.kind {
NodeKind::Vector { network } => Some(network),
_ => None,
}
}
/// The id a fill rule is written as.
fn rule_id(rule: WindingRule) -> &'static str {
match rule {
WindingRule::NonZero => "nonZero",
WindingRule::EvenOdd => "evenOdd",
}
}
/// `region` as an index: absent (or null) means every area.
fn area_index(cmd: &str, p: &Value) -> Result<Option<usize>> {
match get(p, "region") {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let k = f64_of(v).ok_or_else(|| bad(cmd, "`region` must be a number"))?;
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
return Err(bad(cmd, "`region` out of range"));
}
Ok(Some(k as usize))
}
}
}
/// Segments another one- or two-segment loop lists: a short loop takes its direction from the ends
/// its segments are stored with, so re-storing one of those would turn that loop round too. A loop
/// of three or more segments is left out: its ring is walked in list order, which the stored ends
/// don't decide.
fn short_loop_segments(net: &VectorNetwork, skip: (usize, usize)) -> std::collections::HashSet<usize> {
let mut out = std::collections::HashSet::new();
for (r, region) in net.regions.iter().enumerate() {
for (l, lp) in region.loops.iter().enumerate() {
if (r, l) != skip && lp.len() <= 2 {
out.extend(lp.iter().copied());
}
}
}
out
}
fn fill_rule(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.fillRule", s, p)?;
let rule = match string(p, "rule").as_deref().map(str::trim) {
Some("evenOdd" | "evenodd" | "EVENODD") => WindingRule::EvenOdd,
Some("nonZero" | "nonzero" | "NONZERO") => WindingRule::NonZero,
_ => return Err(bad("vector.fillRule", "`rule` is nonZero or evenOdd")),
};
let only = area_index("vector.fillRule", p)?;
let doc = s.doc_mut()?;
let mut count = 0usize;
for id in ids {
let Some(net) = network_mut(doc, id) else { continue };
match only {
Some(r) => {
if let Some(region) = net.regions.get_mut(r) {
region.winding = rule;
count += 1;
}
}
None => {
for region in &mut net.regions {
region.winding = rule;
count += 1;
}
}
}
}
if count == 0 {
return Err(EngineError::Disabled("vector.fillRule".into(), "no filled area to set (select a vector)".into()));
}
Ok(json!({"regions": count, "rule": rule_id(rule)}))
}
/// One loop of a vector, worked out but not yet written back: its segments in the new order and,
/// for a loop of one or two segments, the one to store the other way.
struct LoopFlip {
id: NodeId,
turned: Vec<usize>,
store: Option<usize>,
}
/// Turn one loop of a filled area round: its segments are walked the other way, so the area it
/// bounds counts the other way in the winding (which turns a hole into a filled part and back).
///
/// How a loop is walked depends on how many segments it has:
/// - three or more: the order they are listed in, each segment turned to continue from the point
/// reached. Reversing the list is therefore enough, and the stored ends are left alone (a shared
/// segment's ends belong to its neighbour too);
/// - two: the same ring either way round, so one of the two has to be stored the other way instead;
/// - one: only its stored ends say which way it runs, so they are swapped.
///
/// A short loop can only turn round by re-storing a segment, which is refused when another short
/// loop lists that segment too: it would turn that area round as well.
fn reverse_loop(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "vector.reverseLoop";
let ids = req_targets(cmd, s, p)?;
let region = req_index(cmd, p, "region")?;
let index = req_index(cmd, p, "loop")?;
let doc = s.doc_mut()?;
// Work every loop out before touching the document: one that can't turn round refuses the call
// and the others stay as they were.
let mut flips: Vec<LoopFlip> = Vec::new();
for id in ids {
let Some(NodeKind::Vector { network }) = doc.node(id).map(|n| &n.kind) else { continue };
let Some(loop_) = network.regions.get(region).and_then(|r| r.loops.get(index)) else { continue };
if loop_.is_empty() {
continue;
}
let mut store = None;
if loop_.len() <= 2 {
let busy = short_loop_segments(network, (region, index));
let Some(pick) = loop_.iter().copied().filter(|s| !busy.contains(s)).min() else {
return Err(EngineError::Disabled(
cmd.into(),
"the loop shares its segments with another one- or two-segment loop, so turning it round would turn that one round too".into(),
));
};
store = Some(pick);
}
flips.push(LoopFlip { id, turned: loop_.iter().rev().copied().collect(), store });
}
if flips.is_empty() {
return Err(EngineError::Disabled(cmd.into(), "no such loop (select a vector with filled areas)".into()));
}
for LoopFlip { id, turned, store } in flips {
let Some(net) = network_mut(doc, id) else { continue };
if let Some(seg) = store.and_then(|i| net.segments.get_mut(i)) {
std::mem::swap(&mut seg.a, &mut seg.b);
std::mem::swap(&mut seg.ta, &mut seg.tb);
}
if let Some(l) = net.regions.get_mut(region).and_then(|r| r.loops.get_mut(index)) {
*l = turned;
}
}
Ok(json!({"reversed": true}))
}
+12 -5
View File
@@ -804,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the
/// alpha a typed colour had (see `Paint::solid`) out of the opacity, so the variable's alpha applies
/// once; an opacity set on its own, before or while bound, multiplies on top and stays. Unbinding
/// moves the colour's alpha back into the opacity, where a typed colour keeps it.
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
use super::lists::{BoundList, list_binding};
let Some((list, i, "color")) = list_binding(field) else { return };
@@ -819,7 +819,14 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie
if let Some(p) = paints.get_mut(i)
&& let PaintKind::Solid { color } = &mut p.kind
{
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
if bind {
// What's left is the opacity set on its own (unknown under a colour alpha of 0: 1).
p.opacity = if p.color_alpha > 0.0 { (p.opacity / p.color_alpha).clamp(0.0, 1.0) } else { 1.0 };
p.color_alpha = 1.0;
} else {
p.opacity *= color.a;
p.color_alpha *= color.a;
}
color.a = 1.0;
}
}
+13 -3
View File
@@ -200,7 +200,7 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re
Ok(json!({"on": *slot}))
}
fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
// A file you can't edit now (view only, or on its way to a server): moving around only.
if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) {
return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into()));
@@ -465,11 +465,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"tool.set",
"Choose tool",
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder}",
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
false,
|s, p| {
let t = req_string("tool.set", p, "tool")?;
let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?;
if t.on_boards() {
crate::board::require(s, &format!("tool.{}", t.id()))?;
}
tool(s, t)
}
),
@@ -534,7 +537,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)),
// `apply: false` is for the UI colour picker: the eyedropper only samples (into
// `ToolState::picked`) and the picker writes the colour to the paint it edits.
spec!("edit.pickColor", "Pick color", "{apply?}", false, |s, p| {
let r = tool(s, ToolId::Eyedropper)?;
s.tool.pick_only = !boolean(p, "apply").unwrap_or(true);
s.tool.picked = None;
Ok(r)
}),
]
}
+107 -12
View File
@@ -14,9 +14,9 @@ use figcraft_doc::*;
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::CodeLang;
use crate::codegen::{CodeLang, Ctx};
use crate::component_codegen::{Component, Param, ParamKind, describe, instance_values, js_string, main_of, value_expr};
use crate::components::variant_set;
use crate::components::{slots_of, variant_set};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
@@ -70,14 +70,106 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
}
}
/// Layer `id`'s code with `link`: the import line and the snippet (an instance's values, else the
/// defaults). `None` when `id` isn't a component, set or instance.
/// Layer `id`'s code with `link`: the import lines (its own and those of connected components its
/// instance swaps place) and the snippet (an instance's values, else the defaults). `None` when
/// `id` isn't a component, set or instance.
pub fn connected_code(doc: &Document, id: NodeId, lang: CodeLang, link: &CodeLink) -> Option<String> {
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
let values = instance_values(doc, id, &c).unwrap_or_else(|| c.params.iter().map(|p| (p.prop.clone(), p.default.clone())).collect());
let snippet = render(doc, &c, &values, link, lang, "");
let import = import_line(link, lang);
Some(if import.is_empty() { snippet } else { format!("{import}\n\n{snippet}") })
let mut imports = Vec::new();
snippet_imports(doc, &c, &values, link, lang, &mut imports);
Some(with_imports(&imports, render(doc, &c, &values, link, lang, "")))
}
/// `code` with `imports` at the top: joined to the import block it starts with, else apart.
pub fn with_imports(imports: &[String], code: String) -> String {
match imports.join("\n") {
i if i.is_empty() => code,
i if code.starts_with("import ") || code.starts_with("use ") => format!("{i}\n{code}"),
i => format!("{i}\n\n{code}"),
}
}
/// Adds `line` to `out` unless it's empty or already there.
fn add(out: &mut Vec<String>, line: String) {
if !line.is_empty() && !out.contains(&line) {
out.push(line);
}
}
/// The import lines a connected snippet needs: its own and those of the connected components its
/// instance swaps place (the ones its template shows).
fn snippet_imports(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, out: &mut Vec<String>) {
add(out, import_line(link, lang));
for piece in pieces(&link.template) {
if let Piece::Value { name, .. } = piece
&& let Some((p, v)) = find(c, values, name)
&& p.kind == ParamKind::InstanceSwap
&& let Some(l) = id_of(v).and_then(|i| self::link(doc, i, lang))
{
add(out, import_line(l, lang));
}
}
}
/// The import lines of the connected components the generated code of layer `id` calls
/// (instances in its tree, in a component's definition and in the slots an instance fills), once
/// each in tree order. A connected `id` shows its snippet ([`connected_code`]) instead.
pub fn tree_imports(doc: &Document, id: NodeId, lang: CodeLang) -> Vec<String> {
let mut out = Vec::new();
let Some(n) = doc.node(id) else { return out };
let c = main_of(doc, id).and_then(|m| describe(doc, m));
match (&n.kind, &c) {
// Its call (the slots it fills), then its component's definition.
(NodeKind::Instance { .. }, Some(c)) => {
call_imports(doc, id, lang, 0, &mut out);
tree(&Ctx { doc, root: c.comp, comp: Some(c), base: 0 }, c.comp, lang, 0, &mut out);
}
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => {
tree(&Ctx { doc, root: c.comp, comp: Some(c), base: 0 }, c.comp, lang, 0, &mut out)
}
_ => tree(&Ctx::new(doc, id), id, lang, 0, &mut out),
}
out
}
/// Imports under layer `id` as the code walk sees it: its visible children, instances as calls
/// (one an instance swap parameter places is that parameter). shortcut: the depth cap counts from
/// the tree's root, not from each slot's; align them if a deep slot tree shows a stray import.
fn tree(ctx: &Ctx, id: NodeId, lang: CodeLang, depth: usize, out: &mut Vec<String>) {
let Some(n) = ctx.doc.node(id) else { return };
if ctx.too_deep(depth) {
return;
}
for k in ctx.kids(n) {
let Some(kn) = ctx.doc.node(k) else { continue };
let call = matches!(kn.kind, NodeKind::Instance { .. }) && main_of(ctx.doc, k).is_some();
match (call, ctx.param(kn, "mainComponent")) {
(true, Some(_)) => {}
(true, None) => call_imports(ctx.doc, k, lang, depth + 1, out),
(false, _) => tree(ctx, k, lang, depth + 1, out),
}
}
}
/// The imports instance `id` placed as a call brings: a connected one's snippet's, else those of
/// the content it puts in its slots.
fn call_imports(doc: &Document, id: NodeId, lang: CodeLang, depth: usize, out: &mut Vec<String>) {
let Some(c) = main_of(doc, id).and_then(|m| describe(doc, m)) else { return };
let Some(values) = instance_values(doc, id, &c) else { return };
if let Some(l) = link(doc, id, lang) {
return snippet_imports(doc, &c, &values, l, lang, out);
}
let slots = slots_of(doc, id);
for (p, (_, v)) in c.params.iter().zip(&values) {
if p.kind == ParamKind::Slot
&& v.as_bool() == Some(true)
&& let Some((slot, _)) = slots.iter().find(|(_, name)| *name == p.prop)
{
// The same depth base as the slot's generated content, so its connected children count.
tree(&Ctx { doc, root: *slot, comp: None, base: depth }, *slot, lang, depth + 1, out);
}
}
}
// ------------------------------------------------------------------ templates
@@ -180,14 +272,17 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
}
}
/// Template `t` as pieces. One that doesn't parse, or that skipped `check` (a hand-edited file)
/// and is far too long, stays as written: parsing rescans after each literal brace.
fn pieces(t: &str) -> Vec<Piece<'_>> {
let checkable = t.len() <= MAX_TEMPLATE * 4;
checkable.then(|| parse(t).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(t)])
}
/// `link`'s template with `values` (one per parameter of `c`), every line prefixed by `pad`.
pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, pad: &str) -> String {
// A template that skipped `check` (a hand-edited file) and is far too long stays as written:
// parsing rescans after each literal brace.
let checkable = link.template.len() <= MAX_TEMPLATE * 4;
let pieces = checkable.then(|| parse(&link.template).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(&link.template)]);
let mut out = String::new();
for piece in pieces {
for piece in pieces(&link.template) {
match piece {
Piece::Text(t) => out.push_str(t),
Piece::Value { name, inner } => match find(c, values, name) {
+1 -1
View File
@@ -231,7 +231,7 @@ impl<'a> Ctx<'a> {
.collect()
}
fn too_deep(&self, depth: usize) -> bool {
pub(crate) fn too_deep(&self, depth: usize) -> bool {
depth > self.base + MAX_DEPTH
}
+5 -5
View File
@@ -109,14 +109,14 @@ pub fn specs() -> Vec<CommandSpec> {
/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's
/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it
/// in a function component).
/// in a function component), with the imports of the connected components it calls at the top.
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
let Some(n) = doc.node(id) else { return String::new() };
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
return code;
}
let comp = main_of(doc, id).and_then(|m| describe(doc, m));
match (&n.kind, comp) {
let code = match (&n.kind, comp) {
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
_ => match lang {
@@ -135,7 +135,8 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
out
}
},
}
};
crate::code_connect::with_imports(&crate::code_connect::tree_imports(doc, id, lang), code)
}
fn is_component(doc: &Document, id: NodeId) -> bool {
@@ -521,8 +522,7 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
let values = instance_values(doc, id, &c)?;
let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) };
// A connected component: its real snippet. shortcut: inside a layer tree it brings no import
// line; collect them at the top of the tree's code when people ask for it.
// A connected component: its real snippet (its import is at the top of the code: `code`).
if let Some(link) = crate::code_connect::link(doc, id, lang) {
return Some(crate::code_connect::render(doc, &c, &values, link, lang, &pad));
}
+127 -11
View File
@@ -12,9 +12,10 @@
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
//! inside an instance, `component` (a swapped nested instance), `props` (property values of a
//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own
//! export settings, interactions and resizing are its own, like its place. `position`
//! inside an instance, `modes` (the variable modes a layer inside an instance chooses),
//! `component` (a swapped nested instance), `props` (property values of a nested instance) and
//! `slot` (a slot holding the instance's own content). An instance's own export settings,
//! interactions, resizing and variable modes are its own, like its place. `position`
//! overrides of layers inside an instance are still read and kept, but new ones are refused
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
//!
@@ -60,6 +61,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[
"size",
"resizing",
"position",
"modes",
"component",
"props",
SLOT,
@@ -114,6 +116,23 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
enclosing_instance(doc, id).is_some()
}
/// Mark the `modes` override on layers inside instances whose variable modes differ from their
/// main layer's, for files from before `modes` was an override group. Copies follow their main
/// layer's modes, so a layer on other modes chose them itself.
pub fn mark_layer_modes(doc: &mut Document) {
let chose: Vec<NodeId> = doc
.nodes()
.filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
.map(|n| n.id)
.filter(|id| inside_instance(doc, *id))
.collect();
for id in chose {
if let Some(n) = doc.node_mut(id) {
n.overrides.insert("modes".into());
}
}
}
/// The slot property frame `id` stands for (its name and preferred components): a frame bound to
/// a slot property of its component, or an instance's copy of one (followed through its sources).
pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> {
@@ -335,6 +354,27 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) {
}
}
/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis)
/// that is still `was`'s follows `new`; what the instance set itself stays in `own`.
fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) {
if own.sizing_h == was.sizing_h {
own.sizing_h = new.sizing_h;
}
if own.sizing_v == was.sizing_v {
own.sizing_v = new.sizing_v;
}
for (o, w, n) in [
(&mut own.min_w, was.min_w, new.min_w),
(&mut own.max_w, was.max_w, new.max_w),
(&mut own.min_h, was.min_h, new.min_h),
(&mut own.max_h, was.max_h, new.max_h),
] {
if *o == w {
*o = n;
}
}
}
/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them.
pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) {
@@ -415,6 +455,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
}
"position" => to.transform = from.transform,
"modes" => to.var_modes = from.var_modes.clone(),
"component" => {
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
*b = *a;
@@ -779,6 +820,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) {
let differs = diff_groups(prev, &comp);
root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str()));
follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout);
}
carry_overrides(&root, &mut fresh, Carry::Root);
if let Some(r) = doc.node_mut(inst) {
@@ -830,9 +872,10 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
}
// Nested instances showing another main than the main's copy (swapped): rebuild from it,
// with the old layers below them this rebuild didn't reuse.
let unused: BTreeMap<NodeId, std::sync::Arc<Node>> = old.into_iter().filter(|(i, _)| !used.contains(i) && !doc.contains(*i)).collect();
let unused: BTreeMap<NodeId, std::sync::Arc<Node>> =
old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
for n in nested {
let below: BTreeMap<NodeId, std::sync::Arc<Node>> = unused
let mut below: BTreeMap<NodeId, std::sync::Arc<Node>> = unused
.iter()
.filter(|(_, x)| {
let mut cur = x.parent;
@@ -847,10 +890,77 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
})
.map(|(i, x)| (*i, x.clone()))
.collect();
take_placing_overrides(doc, n, &old, &mut below);
sync_instance_depth(doc, n, depth + 1, below);
}
}
/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the
/// copies just made of the outer main's, and `below`) takes the overrides held along the source
/// chain it had before this rebuild (`before`), through layers inside instances in the mains that
/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a
/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main.
/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value.
fn take_placing_overrides(
doc: &mut Document,
n: NodeId,
before: &BTreeMap<NodeId, std::sync::Arc<Node>>,
below: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
) {
let placing = placing_mains(doc, n);
for id in doc.subtree(n).into_iter().filter(|i| *i != n) {
let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default();
if let Some(layer) = doc.node_mut(id) {
take(layer, &held);
}
}
for layer in below.values_mut() {
let held = held_along(doc, layer, &placing);
if !held.is_empty() {
take(std::sync::Arc::make_mut(layer), &held);
}
}
}
/// The mains placing nested instance `n`: those of the layers it mirrors, outward.
fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet<NodeId> {
let mut out = BTreeSet::new();
let mut cur = doc.node(n).and_then(|x| x.source);
for _ in 0..=MAX_DEPTH {
let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break };
out.extend(owning_component(doc, s.id));
cur = s.source;
}
out
}
/// The overrides but geometry and slot content held along `layer`'s source chain while it runs
/// through layers inside instances in `placing` mains, each with the nearest layer holding it.
fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet<NodeId>) -> BTreeMap<String, std::sync::Arc<Node>> {
let mut out = BTreeMap::new();
let mut cur = layer.source;
for _ in 0..=MAX_DEPTH {
let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break };
if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) {
break;
}
for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) {
out.entry(g.clone()).or_insert_with(|| s.clone());
}
cur = s.source;
}
out
}
/// Give `layer` the `held` groups it has no override of its own for.
fn take(layer: &mut Node, held: &BTreeMap<String, std::sync::Arc<Node>>) {
for (g, from) in held {
if layer.overrides.insert(g.clone()) {
copy_group(from, layer, g);
}
}
}
/// Whether main components `a` and `b` are two variants of one set.
fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool {
a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s))
@@ -1049,6 +1159,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
if a.interactions != b.interactions {
out.push("interactions");
}
if a.var_modes != b.var_modes {
out.push("modes");
}
if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) {
if x != y {
out.push("component");
@@ -1210,10 +1323,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
}
let raw = diff_groups(b, n);
let mut groups = raw.clone();
// An instance's own place, resizing, export settings and interactions are its own, not
// overrides.
// An instance's own place, resizing, export settings, interactions and variable modes are
// its own, not overrides.
if top_root {
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions"));
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
}
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
@@ -2033,7 +2146,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
let mut groups: Vec<String> = value_groups(m, n)
.into_iter()
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
.filter(|g| placed_source.is_some() || g != "resizing")
.filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
.collect();
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
if named {
@@ -2057,8 +2170,11 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
}
if let Some(n) = doc.node_mut(inst) {
n.source = root_base;
// A top-level instance's resizing is its own (a nested one's overrides its main layer's).
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing"));
// A top-level instance's resizing and modes are its own (a nested one's override its main
// layer's).
n.overrides.retain(|g| {
!matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
});
n.overrides.extend(root_groups);
}
}
@@ -657,6 +657,64 @@ fn switching_variants_keeps_overrides_where_the_variants_agreed() {
healthy(&s);
}
/// A "Layout" set: Row (horizontal, hugging, a 40 wide "A") and Stacked (vertical, fixed 300 wide,
/// "A" filling it). Returns (set, row, stacked).
fn layout_set(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let mut variants = Vec::new();
for (k, (name, props, child)) in [
("Layout=Row", json!({"layout": "horizontal", "gap": 8, "sizingH": "hug", "sizingV": "hug", "fills": []}), json!({})),
(
"Layout=Stacked",
json!({"layout": "vertical", "gap": 8, "sizingH": "fixed", "width": 300, "sizingV": "hug", "fills": []}),
json!({"sizingH": "fill"}),
),
]
.into_iter()
.enumerate()
{
let c = idv(&ex(s, "node.create", json!({"type": "component", "name": name, "x": k as f64 * 400.0, "props": props})));
ex(s, "node.create", json!({"type": "rectangle", "parent": c, "name": "A", "width": 40, "height": 20, "props": child}));
variants.push(c);
}
let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()})));
(set, variants[0], variants[1])
}
#[test]
fn switching_variants_takes_the_new_variants_resizing_unless_the_instance_set_its_own() {
let mut s = Session::new();
let (set, row, stacked) = layout_set(&mut s);
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
assert_eq!(component_of(&s, i), row);
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Layout": "Stacked"}}));
assert_eq!(component_of(&s, i), stacked);
let direct = idv(&ex(&mut s, "instance.create", json!({"component": stacked, "x": 0, "y": 400})));
let (n, d) = (node(&s, i), node(&s, direct));
assert_eq!(n.child_layout.sizing_h, Sizing::Fixed, "Row's Hug was never the instance's own: it takes Stacked's Fixed");
assert_eq!((n.width, n.child_layout.sizing_h), (d.width, d.child_layout.sizing_h), "the same as an instance made from Stacked");
let a = layer(&s, i, "A").unwrap();
assert_eq!((node(&s, a).child_layout.sizing_h, node(&s, a).width), (Sizing::Fill, 300.0), "A fills the 300 wide instance");
// Filling an auto layout parent afterwards leaves A filling, with nothing turned into an override.
let col = idv(&ex(
&mut s,
"node.create",
json!({"type": "frame", "name": "Column", "x": 600, "y": 0, "width": 500, "props": {"layout": "vertical", "sizingH": "fixed", "sizingV": "hug", "fills": []}}),
));
ex(&mut s, "node.reparent", json!({"ids": [i], "parent": col}));
ex(&mut s, "node.set", json!({"ids": [i], "props": {"sizingH": "fill"}}));
let a = layer(&s, i, "A").unwrap();
assert_eq!(node(&s, a).child_layout.sizing_h, Sizing::Fill, "A still fills");
assert!(!node(&s, a).overrides.contains("resizing"), "A's resizing is still the main's");
assert_eq!(node(&s, a).width, node(&s, i).width);
// Resizing the instance set itself stays across a switch.
let own = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
ex(&mut s, "node.set", json!({"ids": [own], "props": {"minWidth": 120}}));
ex(&mut s, "instance.setProperties", json!({"ids": [own], "properties": {"Layout": "Stacked"}}));
assert_eq!(node(&s, own).child_layout.min_w, Some(120.0), "its own min width stays");
assert_eq!(node(&s, own).child_layout.sizing_h, Sizing::Fixed, "its sizing, still Row's, follows Stacked");
healthy(&s);
}
#[test]
fn variant_combinations_order_default_and_conflicts() {
let mut s = Session::new();
@@ -201,6 +201,20 @@ fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() {
assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0);
}
#[test]
fn rebase_marks_a_nested_roots_own_mode_but_not_a_top_level_instances() {
let mut s = scene();
let modes = BTreeMap::from([("Tone".to_string(), "B".to_string())]);
s.doc.node_mut(s.nested).unwrap().var_modes = modes.clone();
both(&mut s);
assert!(s.doc.node(s.nested).unwrap().overrides.contains("modes"), "its slot in Outer sets no mode");
let page = s.doc.first_page().unwrap();
let detached = mirror(&mut s.doc, s.nested, page);
rebase_instance(&mut s.doc, detached);
let n = s.doc.node(detached).unwrap();
assert_eq!((&n.var_modes, n.overrides.contains("modes")), (&modes, false), "a top-level instance's modes are its own");
}
#[test]
fn rebase_does_not_preserve_existing_foreign_or_stale_sources() {
let mut s = scene();
+31 -20
View File
@@ -1,6 +1,7 @@
//! Dev Mode "Compare changes": what changed in a ready-for-dev frame or section since it was
//! marked. The document keeps a snapshot of the layer from just before its first change
//! ([`figcraft_doc::handoff`]); this compares it with the layer as it is now, layer by layer
//! marked, or in any frame or section since a saved version. The document keeps a snapshot of a
//! ready-for-dev layer from just before its first change ([`figcraft_doc::handoff`]); this compares
//! it (or the layer in the version) with the layer as it is now, layer by layer
//! (matched by id): layers added and removed, and for the others their properties formatted the
//! way Inspect shows them (`width 120 → 136`, `fill #FF0000 → #E03030`).
@@ -93,20 +94,25 @@ pub struct Comparison {
pub truncated: bool,
}
/// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed).
pub fn compare(doc: &Document, id: NodeId) -> Comparison {
let n = doc.node(id);
let mut out = Comparison {
/// Nothing compared yet for layer `id` of `doc` (`snapshot` false: the before isn't known).
pub fn empty(doc: &Document, id: NodeId) -> Comparison {
Comparison {
id,
name: n.map(|n| n.name.clone()).unwrap_or_default(),
changed: n.is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed),
name: doc.node(id).map(|n| n.name.clone()).unwrap_or_default(),
changed: false,
snapshot: false,
changes: Vec::new(),
added: 0,
removed: 0,
modified: 0,
truncated: false,
};
}
}
/// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed).
pub fn compare(doc: &Document, id: NodeId) -> Comparison {
let mut out = empty(doc, id);
out.changed = doc.node(id).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed);
if !out.changed {
return out;
}
@@ -117,6 +123,21 @@ pub fn compare(doc: &Document, id: NodeId) -> Comparison {
out
}
/// The changes to layer `id` of `now` since `before` (a saved version of the same file: layers
/// match by id). A layer the version doesn't have was added as a whole.
pub fn compare_with(before: &Document, now: &Document, id: NodeId) -> Comparison {
let mut out = empty(now, id);
out.snapshot = true;
let mut w = Walk { before, now, now_ids: bounded_set(now, id), before_ids: bounded_set(before, id), out: &mut out };
if before.contains(id) {
w.layer(id, 0);
} else {
w.whole(now, id, 0, ChangeKind::Added);
}
out.changed = !out.changes.is_empty();
out
}
/// The before and after documents of layer `id` (the snapshot's copy and the document), for
/// rendering the two side by side.
pub fn before_doc(doc: &Document, id: NodeId) -> Option<Document> {
@@ -127,17 +148,7 @@ pub fn before_doc(doc: &Document, id: NodeId) -> Option<Document> {
/// Every layer that changed between two versions of a whole document, page by page (pages added
/// or removed as a whole), for reviewing a branch.
pub fn compare_docs(before: &Document, now: &Document) -> Comparison {
let mut out = Comparison {
id: NodeId(0),
name: now.name.clone(),
changed: false,
snapshot: true,
changes: Vec::new(),
added: 0,
removed: 0,
modified: 0,
truncated: false,
};
let mut out = Comparison { name: now.name.clone(), snapshot: true, ..empty(now, NodeId(0)) };
let mut w = Walk { before, now, now_ids: now.ids().collect(), before_ids: before.ids().collect(), out: &mut out };
for p in &now.pages {
if before.contains(*p) {
+3 -2
View File
@@ -99,8 +99,9 @@ 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"}}));
// node.set refuses a status on a nested frame, and one an older file already stores is ignored.
assert!(s.execute("node.set", &json!({"id": card, "props": {"devStatus": "readyForDev"}})).is_err());
s.doc_mut().unwrap().node_mut(card).unwrap().dev_status = 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"}}));
+26 -5
View File
@@ -214,6 +214,21 @@ pub struct DevItem {
pub name: String,
pub page: NodeId,
pub status: DevStatus,
/// Ready for dev and changed since it was marked.
pub changed: bool,
}
/// Whether Dev Mode runs command `id` from the menus and shortcuts: commands that don't edit the
/// document, and Dev Mode's own edits (annotations, statuses, dev resources, code connections,
/// comments). Choosing a tool other than moving around and commenting is refused too, and so is
/// Revert to saved, which isn't an undo step but throws the design's unsaved edits away.
pub fn dev_mode_allows(id: &str) -> bool {
const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."];
match id.strip_prefix("tool.") {
Some(t) => matches!(t, "move" | "hand" | "comment"),
None if id == "file.revert" => false,
None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history),
}
}
/// Most items listed.
@@ -244,7 +259,8 @@ pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>
}
let Some(n) = doc.node(id) else { continue };
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) {
out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status });
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 });
}
stack.extend(n.children.iter().rev().copied());
}
@@ -257,18 +273,21 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"inspect.readyForDev",
"Ready for dev",
"{status?: readyForDev|completed|any (default readyForDev), page? (page id; default every page)} → {items: [{id, name, page, status}]} frames and sections by handoff status (set it with `node.set {devStatus}`)",
"{status?: readyForDev|changed|completed|any (default readyForDev; changed: ready for dev and changed since marked), page? (page id; default every page)} → {items: [{id, name, page, status, changed}]} frames and sections by handoff status (set it with `node.set {devStatus}`)",
false,
|s, p| {
let cmd = "inspect.readyForDev";
let status = match string(p, "status").as_deref() {
let wanted = string(p, "status");
let changed_only = wanted.as_deref().is_some_and(|v| v.eq_ignore_ascii_case("changed"));
let status = match wanted.as_deref() {
None => Some(DevStatus::ReadyForDev),
_ if changed_only => Some(DevStatus::ReadyForDev),
Some(v) if v.eq_ignore_ascii_case("any") || v.eq_ignore_ascii_case("all") => None,
Some(v) => Some(
DevStatus::ALL
.into_iter()
.find(|d| !d.is_none() && d.id().eq_ignore_ascii_case(v))
.ok_or_else(|| crate::bad(cmd, "status: readyForDev|completed|any"))?,
.ok_or_else(|| crate::bad(cmd, "status: readyForDev|changed|completed|any"))?,
),
};
let doc = s.doc()?;
@@ -276,7 +295,9 @@ pub fn specs() -> Vec<CommandSpec> {
Some(v) => Some(id_of(v).filter(|pg| doc.pages.contains(pg)).ok_or_else(|| crate::bad(cmd, "page: a page id"))?),
None => None,
};
Ok(json!({"items": serde_json::to_value(dev_items(doc, status, page)).unwrap_or(Value::Null)}))
let mut items = dev_items(doc, status, page);
items.retain(|i| i.changed || !changed_only);
Ok(json!({"items": serde_json::to_value(items).unwrap_or(Value::Null)}))
}
),
spec!(
+1 -1
View File
@@ -79,7 +79,7 @@ fn redlines_measure_on_hover_without_alt() {
s.tool.overlay.guides.len()
};
assert_eq!(hover(&mut s), 0, "plain hover doesn't measure");
s.tool.redlines = true;
s.tool.dev_mode = true;
assert!(hover(&mut s) > 0, "Inspect measures on hover");
}
@@ -0,0 +1,224 @@
//! Dev Mode behaviour suite, written from the public documentation of handoff tools (in our own
//! words): the canvas inspects and never edits, what the menus and shortcuts may run there, the
//! list of what's ready for development, statuses (ready, changed, completed) and the focus view.
use crate::Session;
use crate::components::tests::{ex, idv};
use crate::devinspect::dev_mode_allows;
use crate::tools::{PointerEvent, PointerKind, ToolId};
use figcraft_doc::{DevStatus, NodeId};
use serde_json::{Value, json};
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::at(kind, x, y, 1.0)
}
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
s.pointer(&ev(PointerKind::Down, from.0, from.1)).unwrap();
for k in 1..=4 {
let t = f64::from(k) / 4.0;
s.pointer(&ev(PointerKind::Move, from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t)).unwrap();
}
s.pointer(&ev(PointerKind::Up, to.0, to.1)).unwrap();
}
fn frame(s: &mut Session, x: f64, name: &str) -> NodeId {
idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 200, "height": 200, "name": name})))
}
fn rect(s: &mut Session, parent: NodeId, x: f64) -> NodeId {
idv(&ex(s, "shape.rectangle", json!({"x": x, "y": 20, "width": 40, "height": 40, "parent": parent})))
}
fn names(v: &Value) -> Vec<String> {
v["items"].as_array().map(|a| a.iter().filter_map(|i| i["name"].as_str().map(String::from)).collect()).unwrap_or_default()
}
fn status(s: &mut Session, ids: &[NodeId], st: &str) {
ex(s, "node.set", json!({"ids": ids, "props": {"devStatus": st}}));
}
#[test]
fn the_dev_mode_canvas_selects_and_pans_but_never_edits() {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
let t = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 100, "y": 120, "parent": f})));
s.tool.dev_mode = true;
let before = s.doc().unwrap().clone();
let steps = s.undo_labels(100).len();
// A press selects; dragging it moves nothing.
drag(&mut s, (40.0, 40.0), (140.0, 90.0));
assert_eq!(s.selection(), vec![r]);
// Nor do its resize handles.
drag(&mut s, (60.0, 60.0), (120.0, 120.0));
// Drawing tools only select there.
for tool in [ToolId::Rectangle, ToolId::Frame, ToolId::Text, ToolId::Pen, ToolId::Pencil, ToolId::Scale] {
s.set_tool(tool);
drag(&mut s, (300.0, 300.0), (360.0, 360.0));
}
s.set_tool(ToolId::Move);
// A double-click on text doesn't start typing; on a frame's layer it selects into it.
ex(&mut s, "select.set", json!({"ids": [t]}));
s.pointer(&ev(PointerKind::DoubleClick, 110.0, 128.0)).unwrap();
assert!(s.ds().unwrap().text_edit.is_none(), "no text editing");
ex(&mut s, "select.set", json!({"ids": [r]}));
s.pointer(&ev(PointerKind::DoubleClick, 40.0, 40.0)).unwrap();
assert!(s.ds().unwrap().vector_edit.is_none(), "no vector editing");
assert!(*s.doc().unwrap() == before, "the design is untouched");
assert_eq!(s.undo_labels(100).len(), steps, "nothing to undo");
// Keys that edit with a tool (a selected guide's Delete) go to the commands instead.
assert!(!s.tool_key("Delete", false, false, false).unwrap());
// The hand still pans; comments still start.
s.set_tool(ToolId::Hand);
let cx = s.ds().unwrap().view.cx;
s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap();
s.pointer(&PointerEvent { sx: 550.0, ..ev(PointerKind::Move, 500.0, 500.0) }).unwrap();
s.pointer(&ev(PointerKind::Up, 500.0, 500.0)).unwrap();
assert_ne!(s.ds().unwrap().view.cx, cx, "panned");
s.set_tool(ToolId::Comment);
s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap();
assert!(s.ui_requests.iter().any(|u| u.kind == "comment.new"));
// Back in the design: the same drag moves.
s.tool.dev_mode = false;
s.set_tool(ToolId::Move);
drag(&mut s, (40.0, 40.0), (140.0, 90.0));
assert!(s.doc().unwrap().node(r).is_some_and(|n| n.transform.as_coeffs()[4] > 100.0), "moved");
}
#[test]
fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts() {
for id in [
"annotation.add",
"annotation.edit",
"dev.resolveChange",
"dev.focus",
"devResource.add",
"codeConnect.set",
"comment.add",
"edit.undo",
"edit.redo",
"edit.copy",
"edit.copyAsCss",
"select.all",
"view.zoomIn",
"view.devMode",
"file.export",
"file.save",
"tool.move",
"tool.hand",
"tool.comment",
"inspect.code",
] {
assert!(dev_mode_allows(id), "{id} runs in Dev Mode");
}
for id in [
"edit.delete",
"edit.paste",
"edit.cut",
"edit.duplicate",
"transform.nudge",
"object.group",
"tool.rectangle",
"tool.text",
"tool.place",
"file.import",
"file.revert",
] {
assert!(!dev_mode_allows(id), "{id} edits the design");
}
}
#[test]
fn the_ready_list_shows_frames_and_sections_with_a_status_across_pages() {
let mut s = Session::new();
let a = frame(&mut s, 0.0, "Login");
let b = frame(&mut s, 300.0, "Signup");
let nested = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 50, "height": 50, "parent": a, "name": "Card"})));
status(&mut s, &[a], "readyForDev");
status(&mut s, &[b], "completed");
// A layer inside a frame takes no status of its own (its frame's covers it).
assert!(s.execute("node.set", &json!({"id": nested, "props": {"devStatus": "readyForDev"}})).is_err());
assert!(s.execute("node.set", &json!({"ids": [a, nested], "props": {"devStatus": "completed"}})).is_err(), "all or nothing");
assert_eq!(s.doc().unwrap().node(a).map(|n| n.dev_status), Some(DevStatus::ReadyForDev));
// Clearing one is always fine.
ex(&mut s, "node.set", json!({"id": nested, "props": {"devStatus": null}}));
let pg2 = idv(&ex(&mut s, "page.add", json!({"name": "Settings"})));
ex(&mut s, "page.select", json!({"id": pg2}));
let c = frame(&mut s, 0.0, "Profile");
status(&mut s, &[c], "readyForDev");
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login", "Profile"]);
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]);
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))), vec!["Login", "Signup", "Profile"]);
// Statuses are undoable.
s.undo().unwrap();
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login"]);
}
#[test]
fn ready_frames_edited_after_marking_show_as_changed_until_resolved_or_completed() {
let mut s = Session::new();
let a = frame(&mut s, 0.0, "Login");
let b = frame(&mut s, 300.0, "Signup");
let r = rect(&mut s, a, 20.0);
let outside = idv(&ex(&mut s, "shape.rectangle", json!({"x": 900, "y": 0, "width": 10, "height": 10})));
status(&mut s, &[a, b], "readyForDev");
let changed = |s: &mut Session| names(&ex(s, "inspect.readyForDev", json!({"status": "changed"})));
assert!(changed(&mut s).is_empty(), "marking isn't a change");
// Edits elsewhere don't count; an edit inside does.
ex(&mut s, "node.set", json!({"id": outside, "props": {"width": 30}}));
assert!(changed(&mut s).is_empty());
ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}}));
assert_eq!(changed(&mut s), vec!["Login"]);
let items = ex(&mut s, "inspect.readyForDev", json!({}));
assert_eq!(items["items"][0]["changed"], true, "the list says which changed: {items}");
assert_eq!(items["items"][1]["changed"], false);
// Still ready for dev (the status itself stays).
assert_eq!(names(&items), vec!["Login", "Signup"]);
// Resolving goes back to plain ready; so does marking it again, or completing it.
ex(&mut s, "dev.resolveChange", json!({"ids": [a]}));
assert!(changed(&mut s).is_empty());
ex(&mut s, "node.set", json!({"id": r, "props": {"width": 70}}));
status(&mut s, &[a], "completed");
assert!(changed(&mut s).is_empty());
let all = ex(&mut s, "inspect.readyForDev", json!({"status": "any"}));
assert_eq!(all["items"][0]["changed"], false, "a completed frame isn't changed: {all}");
// Completed frames don't turn changed when edited.
ex(&mut s, "node.set", json!({"id": r, "props": {"width": 80}}));
assert!(!s.doc().unwrap().node(a).unwrap().dev_changed);
}
#[test]
fn the_focus_view_steps_through_what_is_ready_and_skips_completed_frames() {
let mut s = Session::new();
let a = frame(&mut s, 0.0, "Login");
let b = frame(&mut s, 300.0, "Signup");
let c = frame(&mut s, 600.0, "Profile");
let r = rect(&mut s, c, 620.0);
status(&mut s, &[a, c], "readyForDev");
status(&mut s, &[b], "completed");
ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}}));
let step = |s: &mut Session, d: &str| ex(s, "dev.focus", json!({"direction": d}));
let v = ex(&mut s, "dev.focus", json!({"id": a}));
assert_eq!((v["index"].clone(), v["count"].clone()), (json!(0), json!(2)));
assert_eq!(step(&mut s, "next")["id"], json!(c), "completed ones are skipped, changed ones aren't");
assert_eq!(step(&mut s, "previous")["id"], json!(a));
// Any top-level frame opens in it, even one that's completed.
assert_eq!(ex(&mut s, "dev.focus", json!({"id": b}))["index"], json!(null));
}
#[test]
fn a_drag_under_way_when_dev_mode_starts_moves_nothing() {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
let before = s.doc().unwrap().world_bounds(r);
s.pointer(&ev(PointerKind::Down, 40.0, 40.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 90.0, 40.0)).unwrap();
s.drop_gesture().unwrap();
s.tool.dev_mode = true;
s.pointer(&ev(PointerKind::Move, 120.0, 40.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 120.0, 40.0)).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(r), before, "the drag was dropped, not committed");
}
+163
View File
@@ -0,0 +1,163 @@
//! Dev Mode, the tail: import lines for the connected components a layer tree calls.
use crate::Session;
use crate::components::tests::{ex, idv};
use figcraft_doc::NodeId;
use serde_json::json;
fn code(s: &mut Session, id: NodeId, lang: &str) -> String {
ex(s, "inspect.code", json!({"id": id, "language": lang}))["code"].as_str().unwrap_or_default().to_string()
}
/// A component named `name` (an empty frame) at `x`.
fn component(s: &mut Session, name: &str, x: f64) -> NodeId {
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 1000, "width": 40, "height": 20, "name": name})));
idv(&ex(s, "component.create", json!({"ids": [f]})))
}
fn instance(s: &mut Session, c: NodeId, parent: NodeId) -> NodeId {
idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 0, "parent": parent})))
}
fn frame(s: &mut Session, name: &str, parent: Option<NodeId>) -> NodeId {
idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300, "name": name, "parent": parent})))
}
fn count(code: &str, line: &str) -> usize {
code.lines().filter(|l| *l == line).count()
}
#[test]
fn layer_trees_import_the_connected_components_they_call_once_at_the_top() {
let mut s = Session::new();
let badge = component(&mut s, "Badge", 0.0);
let button = component(&mut s, "Button", 100.0);
let toast = component(&mut s, "Toast", 200.0);
let divider = component(&mut s, "Divider", 300.0);
for (lang, path) in [("react", "@acme/ui"), ("swiftui", "AcmeUI"), ("leptos", "crate::ui"), ("compose", "com.acme.ui.Badge")] {
ex(&mut s, "codeConnect.set", json!({"id": badge, "language": lang, "importPath": path}));
}
ex(&mut s, "codeConnect.set", json!({"id": button, "importPath": "@acme/buttons"}));
ex(&mut s, "codeConnect.set", json!({"id": toast, "importPath": "@acme/toast"}));
let screen = frame(&mut s, "Screen", None);
let row = frame(&mut s, "Row", Some(screen));
for (c, parent) in [(badge, screen), (button, screen), (badge, screen), (divider, screen), (badge, row)] {
instance(&mut s, c, parent);
}
let hidden = instance(&mut s, toast, row);
ex(&mut s, "node.set", json!({"id": hidden, "props": {"visible": false}}));
let react = code(&mut s, screen, "react");
assert!(react.starts_with("import { Badge } from \"@acme/ui\";\nimport { Button } from \"@acme/buttons\";\nimport \"./"), "{react}");
assert_eq!(count(&react, "import { Badge } from \"@acme/ui\";"), 1, "once each: {react}");
assert!(!react.contains("@acme/toast"), "hidden layers aren't in the code: {react}");
let swift = code(&mut s, screen, "swiftui");
assert!(swift.starts_with("import AcmeUI\n\n") && count(&swift, "import AcmeUI") == 1, "per language: {swift}");
assert!(!swift.contains("acme/buttons"), "{swift}");
let leptos = code(&mut s, screen, "leptos");
assert!(leptos.starts_with("use crate::ui::Badge;\nuse leptos::prelude::*;\n"), "joined to the code's own imports: {leptos}");
assert!(code(&mut s, screen, "compose").starts_with("import com.acme.ui.Badge\n\n"));
assert!(!code(&mut s, screen, "css").contains("import"), "CSS has no imports");
// The Inspect panel shows inspect.code's text (the same function).
let doc = s.doc().unwrap();
assert_eq!(crate::codegen::code_for(doc, screen, crate::codegen::CodeLang::React), react);
// A connected layer itself: its import and snippet, unchanged.
assert_eq!(code(&mut s, badge, "react"), "import { Badge } from \"@acme/ui\";\n\n<Badge />\n");
// A component's definition, and an instance of it (its call, then the definition).
let card_frame = frame(&mut s, "Card", None);
instance(&mut s, badge, card_frame);
let card = idv(&ex(&mut s, "component.create", json!({"ids": [card_frame]})));
assert!(
code(&mut s, card, "react").starts_with("import { Badge } from \"@acme/ui\";\nimport \"./card.css\";"),
"{}",
code(&mut s, card, "react")
);
let page = frame(&mut s, "Page", None);
let card_i = instance(&mut s, card, page);
assert!(code(&mut s, card_i, "react").starts_with("import { Badge } from \"@acme/ui\";\n\n<Card />"), "{}", code(&mut s, card_i, "react"));
assert!(code(&mut s, page, "react").starts_with("import \"./"), "an unconnected call's insides aren't in the code");
}
#[test]
fn slots_and_instance_swaps_bring_their_connected_imports() {
let mut s = Session::new();
let badge = component(&mut s, "Badge", 0.0);
ex(&mut s, "codeConnect.set", json!({"id": badge, "importPath": "@acme/ui"}));
// A panel whose slot an instance fills with a badge.
let f = frame(&mut s, "Panel", None);
let content = frame(&mut s, "Content", Some(f));
let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.makeSlot", json!({"id": content}));
let screen = frame(&mut s, "Screen", None);
let p = instance(&mut s, panel, screen);
assert!(!code(&mut s, screen, "react").contains("@acme/ui"), "an empty slot");
ex(&mut s, "instance.slotInsert", json!({"id": p, "slot": "Content", "component": badge, "replace": true}));
assert!(code(&mut s, screen, "react").starts_with("import { Badge } from \"@acme/ui\";\n"), "{}", code(&mut s, screen, "react"));
assert!(code(&mut s, p, "react").starts_with("import { Badge } from \"@acme/ui\";\n\n<Panel content={("), "{}", code(&mut s, p, "react"));
// A connected chip whose template places its icon: the swapped-in connected component's import.
let heart = component(&mut s, "Heart", 300.0);
let chip_frame = frame(&mut s, "Chip", None);
let nested = instance(&mut s, heart, chip_frame);
let chip = idv(&ex(&mut s, "component.create", json!({"ids": [chip_frame]})));
ex(&mut s, "component.addProperty", json!({"id": chip, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested}));
ex(&mut s, "codeConnect.set", json!({"id": chip, "importPath": "@acme/chip", "template": "<Chip icon={Icon} />"}));
let c = instance(&mut s, chip, screen);
ex(&mut s, "instance.setProperties", json!({"ids": [c], "properties": {"Icon": badge}}));
assert_eq!(code(&mut s, c, "react"), "import { Chip } from \"@acme/chip\";\nimport { Badge } from \"@acme/ui\";\n\n<Chip icon={<Badge />} />\n");
let tree = code(&mut s, screen, "react");
assert!(tree.starts_with("import { Badge } from \"@acme/ui\";\nimport { Chip } from \"@acme/chip\";\nimport \"./"), "{tree}");
// An unconnected swap target brings nothing; the default (Heart) isn't connected.
ex(&mut s, "instance.setProperties", json!({"ids": [c], "properties": {"Icon": heart}}));
assert_eq!(code(&mut s, c, "react"), "import { Chip } from \"@acme/chip\";\n\n<Chip icon={<Heart />} />\n");
}
#[test]
fn compare_changes_takes_any_saved_version_as_the_before() {
let mut s = Session::new();
let screen = frame(&mut s, "Screen", None);
let button =
idv(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 120, "height": 40, "parent": screen, "props": {"name": "Button"}})));
let old = idv(&ex(&mut s, "shape.ellipse", json!({"x": 200, "y": 20, "width": 20, "height": 20, "parent": screen, "props": {"name": "Dot"}})));
let v1 = ex(&mut s, "file.saveVersion", json!({"name": "Kickoff"}))["id"].as_str().unwrap().to_string();
ex(&mut s, "node.set", json!({"id": button, "props": {"width": 136}}));
ex(&mut s, "edit.delete", json!({"ids": [old]}));
let added = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 20, "y": 100, "parent": screen, "props": {"name": "Note"}})));
let v2 = ex(&mut s, "file.saveVersion", json!({"name": "Review"}))["id"].as_str().unwrap().to_string();
ex(&mut s, "node.set", json!({"id": button, "props": {"width": 150}}));
// Any frame, ready for dev or not.
let c = ex(&mut s, "dev.compare", json!({"id": screen, "version": v1}));
assert_eq!(c["version"]["name"], "Kickoff");
assert_eq!(
(c["changed"].as_bool(), c["added"].as_u64(), c["removed"].as_u64(), c["modified"].as_u64()),
(Some(true), Some(1), Some(1), Some(1)),
"{c}"
);
let change =
|c: &serde_json::Value, id: NodeId| c["changes"].as_array().unwrap().iter().find(|l| l["id"] == json!(id)).cloned().unwrap_or_default();
assert_eq!(change(&c, button)["properties"][0]["text"], "width 120 → 150");
assert_eq!(change(&c, old)["change"], "removed");
assert_eq!(change(&c, added)["change"], "added");
// A later version: only what changed since then.
let c = ex(&mut s, "dev.compare", json!({"id": screen, "version": v2}));
assert_eq!(c["changes"].as_array().map(Vec::len), Some(1), "{c}");
assert_eq!(change(&c, button)["properties"][0]["text"], "width 136 → 150");
// A frame the version doesn't have was added as a whole.
let fresh = frame(&mut s, "Settings", None);
let c = ex(&mut s, "dev.compare", json!({"id": fresh, "version": v1}));
assert_eq!(c["changes"][0]["change"], "added");
// The snapshot from when it was marked stays the default, for ready-for-dev layers only.
assert!(s.execute("dev.compare", &json!({"id": screen})).is_err(), "not ready for dev");
ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}}));
let c = ex(&mut s, "dev.compare", json!({"id": screen}));
assert_eq!((c["changed"].as_bool(), c["version"].is_null()), (Some(false), true), "{c}");
for bad in [json!({"id": screen, "version": "nope"}), json!({"id": screen, "version": 5}), json!({"id": button, "version": v1})] {
assert!(s.execute("dev.compare", &bad).is_err(), "{bad}");
}
// The app opens on the version asked for.
ex(&mut s, "dev.compare", json!({"id": screen, "version": v2, "show": true}));
assert!(s.ui_requests.iter().any(|u| u.kind == "view.devCompare" && u.data["version"] == json!(v2)));
}
+88
View File
@@ -0,0 +1,88 @@
//! `props.fill` and `props.stroke` accept `"none"` (either case) and `null` as no paint, the
//! same as an empty `fills`/`strokes` list (#1036): an outline-only shape in one call.
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
fn id(v: Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn create_rect(s: &mut Session, fill: Value) -> NodeId {
id(ex(
s,
"node.create",
json!({
"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10,
"props": {"fill": fill, "stroke": "#000000", "strokeWeight": 1}
}),
))
}
#[test]
fn fill_none_or_null_makes_an_outline_only_shape_in_one_call() {
for none in [json!("none"), json!("None"), json!("NONE"), json!(null)] {
let mut s = Session::new();
let r = create_rect(&mut s, none.clone());
let n = s.doc().unwrap().node(r).unwrap().clone();
assert!(n.fills.is_empty(), "{none:?}: fills not empty");
assert_eq!(n.strokes.len(), 1, "{none:?}: stroke kept");
}
}
#[test]
fn fill_none_clears_an_existing_fill_as_one_undo_step() {
let mut s = Session::new();
let r = create_rect(&mut s, json!("#FF0000"));
assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1);
ex(&mut s, "node.set", json!({"id": r, "props": {"fill": "none"}}));
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
ex(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1);
}
#[test]
fn stroke_none_or_null_clears_the_strokes() {
for none in [json!("none"), json!(null)] {
let mut s = Session::new();
let r = create_rect(&mut s, json!("#FF0000"));
ex(&mut s, "node.set", json!({"id": r, "props": {"stroke": none}}));
assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty(), "{none:?}");
assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1, "{none:?}");
}
}
#[test]
fn text_with_fill_none_gets_no_automatic_colour() {
let mut s = Session::new();
let t = id(ex(
&mut s,
"node.create",
json!({
"type": "text", "x": 0, "y": 0, "text": "Hi", "props": {"fill": "none"}
}),
));
assert!(s.doc().unwrap().node(t).unwrap().fills.is_empty());
}
#[test]
fn an_unknown_string_still_names_the_key() {
let mut s = Session::new();
let e = s
.execute(
"node.create",
&json!({
"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10,
"props": {"fill": "nope"}
}),
)
.unwrap_err();
let msg = e.to_string();
assert!(msg.contains("`fill`") && msg.contains("none"), "{msg}");
}
+23
View File
@@ -53,6 +53,29 @@ fn the_eyedropper_picks_the_colour_seen_and_fills_the_selection() {
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into()));
}
#[test]
fn the_eyedropper_of_a_colour_picker_samples_without_filling() {
// Opened from a colour picker (`apply: false`), the eyedropper hands the colour to the picker,
// which writes it to its own paint (a stroke, an effect...): the fill stays as it was.
let mut s = Session::new();
s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap();
rect(&mut s, None, (0.0, 0.0, 100.0, 100.0), "#FF0000");
let b = rect(&mut s, None, (200.0, 0.0, 50.0, 50.0), "#00FF00");
s.execute("select.set", &json!({"ids": [b]})).unwrap();
let undo = s.undo_depth().0;
s.execute("edit.pickColor", &json!({"apply": false})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into()));
assert_eq!(s.undo_depth().0, undo, "nothing to undo");
assert_eq!(s.tool.picked.take().map(|c| c.to_hex()), Some("FF0000".into()));
assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper");
// The I key's eyedropper fills the selection again.
s.execute("edit.pickColor", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into()));
assert_eq!(s.tool.picked, None);
}
#[test]
fn blend_modes_and_effects_through_commands() {
let mut s = Session::new();
+1 -1
View File
@@ -9,7 +9,7 @@ pub fn run<T>(cmd: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
Err(payload) => {
let msg = payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.map(ToString::to_string)
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".to_string());
log::error!("command `{cmd}` panicked: {msg}");
+4
View File
@@ -135,6 +135,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
if !n.exports.is_empty() {
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
}
if let Some(b) = &n.board {
o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
}
if !n.annotations.is_empty() {
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
}
@@ -312,6 +315,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect();
json!({
"name": doc.name,
"kind": doc.kind.id(),
"path": ds.path,
"dirty": ds.is_dirty(),
"currentPage": ds.page,
+68
View File
@@ -126,3 +126,71 @@ fn detaching_keeps_the_mode_it_took_from_its_main() {
assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B");
assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1);
}
/// A "Button" holding an instance of a swatch "Icon" on mode A, and a "Modal" holding a Button
/// instance whose icon copy was set to mode B: (the Button main, its icon layer, the copy in Modal).
fn modal(s: &mut Session) -> (NodeId, NodeId, NodeId) {
tone(s);
let icon = swatch(s, "Icon", None);
let frame = id(run(s, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"})));
let button_icon = id(run(s, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": frame})));
set_mode(s, button_icon, json!("A"));
let button = id(run(s, "component.create", json!({"ids": [frame]})));
let outer = id(run(s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"})));
let close = id(run(s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": outer})));
let nested = s.doc().unwrap().node(close).unwrap().children[0];
set_mode(s, nested, json!("B"));
run(s, "component.create", json!({"ids": [outer]}));
(button, button_icon, nested)
}
#[test]
fn a_mode_set_on_a_layer_inside_an_instance_is_an_override() {
let mut s = Session::new();
let (button, button_icon, nested) = modal(&mut s);
assert_eq!(fill(&s, nested), "0000FF");
// An edit the copies can't take as they are rebuilds the Button instance.
run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"}));
assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps the mode set on the copy");
// The main's layer choosing another mode doesn't reach it either.
set_mode(&mut s, button_icon, json!("B"));
set_mode(&mut s, button_icon, Value::Null);
assert_eq!(fill(&s, nested), "0000FF");
run(&mut s, "component.resetOverride", json!({"id": nested, "group": "modes"}));
assert_eq!(fill(&s, nested), "FF0000", "reset: the main's layer is on Auto (mode A)");
assert!(s.doc().unwrap().node(nested).unwrap().var_modes.is_empty());
}
#[test]
fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() {
let mut s = Session::new();
let (button, _, _) = modal(&mut s);
// A Button placed on the page, its icon recoloured through the icon's mode.
let placed = instance(&mut s, button);
let icon = s.doc().unwrap().node(placed).unwrap().children[0];
set_mode(&mut s, icon, json!("B"));
assert!(s.doc().unwrap().node(icon).unwrap().overrides.contains("modes"), "it shows as an override");
run(&mut s, "node.set", json!({"ids": [button], "layout": "vertical"}));
assert_eq!(fill(&s, icon), "0000FF", "a rebuild of the Button keeps it");
run(&mut s, "component.resetInstance", json!({"ids": [placed]}));
assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode");
}
#[test]
fn a_file_from_before_modes_were_overrides_keeps_the_modes_chosen_inside_its_instances() {
let mut s = Session::new();
let (button, _, nested) = modal(&mut s);
// Saved before `modes` was an override group: the copy has its mode but no marker.
s.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes");
let path = std::env::temp_dir().join(format!("figcraft-instance-modes-{}.figcraft", std::process::id()));
let p = path.to_string_lossy().into_owned();
run(&mut s, "file.saveAs", json!({"path": p}));
let mut s = Session::new();
run(&mut s, "file.open", json!({"path": p}));
let _ = std::fs::remove_file(&path);
let doc = s.doc().unwrap();
assert!(doc.node(nested).unwrap().overrides.contains("modes"), "opening it marks the mode the copy chose");
assert!(doc.nodes().filter(|n| n.id != nested).all(|n| !n.overrides.contains("modes")), "layers on their main's modes stay unmarked");
run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"}));
assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps it");
}
+13 -2
View File
@@ -193,6 +193,15 @@ pub static KEYS: &[Binding] = &[
k(Tools, "tool.text", &["T"]),
k(Tools, "tool.comment", &["C"]),
k(Tools, "tool.shapeBuilder", &["Shift+M"]),
// Board tools (on boards only), on keys nothing else uses.
k(Tools, "tool.sticky", &["Q"]),
k(Tools, "tool.shape", &["B"]),
k(Tools, "tool.connector", &["X"]),
k(Tools, "tool.marker", &["M"]),
k(Tools, "tool.highlighter", &["U"]),
k(Tools, "tool.eraser", &["E"]),
k(Tools, "tool.stamp", &["W"]),
k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one").both(),
// View
// The documentation gives both keys for the pixel grid in different places.
k(View, "view.pixelGrid", &["Shift+'", "Cmd+'"]),
@@ -220,8 +229,10 @@ pub static KEYS: &[Binding] = &[
k(View, "view.prevPage", &["PageUp"]),
k(View, "view.nextPage", &["PageDown"]),
// Zoom
k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+="]),
k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-"]),
// Each zoom-in key has its zoom-out twin on the key beside it: `-` alone zooms out, so `=`
// alone (the `+` key without Shift) zooms in (#973).
k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+=", "="]),
k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-", "Shift+Cmd+-"]),
k(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"]),
k(Zoom, "view.zoomFit", &["Shift+1"]),
k(Zoom, "view.zoomSelection", &["Shift+2"]),
+19
View File
@@ -168,6 +168,25 @@ fn display_per_platform() {
assert_eq!(primary("polygon.none", mac), None);
}
/// Each way of zooming in has its zoom-out twin on the key beside it, as the keys arrive from a US
/// keyboard (#973: `-` alone zoomed out but `=` alone did nothing, so zooming back in was stuck).
#[test]
fn zoom_keys_come_in_pairs() {
let none = Mods::default();
let shift = mods(true, false, false, false);
let cmd = mods(false, false, true, false);
let shift_cmd = mods(true, false, true, false);
for p in PLATFORMS {
for (zoom_in, zoom_out, m) in [("Equals", "Minus", none), ("Equals", "Minus", cmd), ("Plus", "Minus", shift_cmd), ("Plus", "Minus", shift)] {
assert_eq!(id_for(p, Context::Canvas, zoom_in, m), Some("view.zoomIn"), "{zoom_in} {m:?} on {p:?}");
assert_eq!(id_for(p, Context::Canvas, zoom_out, m), Some("view.zoomOut"), "{zoom_out} {m:?} on {p:?}");
}
// The menus still show Cmd/Ctrl and `+`.
assert_eq!(primary("view.zoomIn", p), Some("Cmd+="));
assert_eq!(primary("view.zoomOut", p), Some("Cmd+-"));
}
}
#[test]
fn tool_keys_come_from_the_keymap() {
assert_eq!(ToolId::Move.shortcut(), "V");
+21 -1
View File
@@ -13,6 +13,7 @@ pub mod al_keys;
pub mod annotations;
pub mod arc_handles;
pub mod background;
pub mod board;
pub mod booleans;
pub mod branches;
pub mod catalog;
@@ -413,6 +414,7 @@ impl DocState {
}
doc.link_layer_text_styles();
cmd::styles::apply_paint_bindings_on_load(&mut doc);
components::mark_layer_modes(&mut doc);
// Initial import settlement belongs to the saved baseline, not the user's first edit.
let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0));
let doc = Arc::new(doc);
@@ -936,6 +938,7 @@ impl Session {
/// as it was.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
board::refuse_on_board(self, id)?;
// Revert replaces the document's uid: refuse before ending previews or syncing branches.
if id == "file.revert" && self.live().doc_uid.is_some() {
return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));
@@ -1394,7 +1397,9 @@ pub fn normalize(doc: &mut Document, before: &Document) {
figcraft_layout::coherent_sizing(doc, id);
}
touched.extend(doc.take_dirty());
// 1. Text auto-size.
// 1. Text auto-size (a sticky's note first takes the size its length calls for).
board::settle_text(doc, before, &touched);
touched.extend(doc.take_dirty());
for id in live(doc, &touched) {
let Some(n) = doc.node(id) else { continue };
if let Some((w, h)) = figcraft_text::auto_size(n)
@@ -1475,6 +1480,9 @@ pub fn normalize(doc: &mut Document, before: &Document) {
}
}
touched.extend(doc.take_dirty());
// 4b. Board connectors follow the layers they join; their labels sit at their middles.
board::settle_routes(doc, before, &touched);
touched.extend(doc.take_dirty());
// 5. Ready-for-dev layers whose contents changed show "Changed".
mark_dev_changes(doc, before, &touched);
doc.take_dirty();
@@ -1607,6 +1615,10 @@ mod batch_step_ref_tests;
#[cfg(test)]
mod binding_lists_tests;
#[cfg(test)]
mod board_tests;
#[cfg(test)]
mod board_tools_tests;
#[cfg(test)]
mod booleans_tests;
#[cfg(test)]
mod bound_alpha_tests;
@@ -1653,6 +1665,10 @@ mod devfocus_tests;
#[cfg(test)]
mod devinspect_tests;
#[cfg(test)]
mod devmode_behaviour_tests;
#[cfg(test)]
mod devmode_tail_tests;
#[cfg(test)]
mod doc_urls_tests;
#[cfg(test)]
mod double_click_tests;
@@ -1675,6 +1691,8 @@ mod fig_export_tests;
#[cfg(test)]
mod fig_import_tests;
#[cfg(test)]
mod fill_none_tests;
#[cfg(test)]
mod finite_geometry_tests;
#[cfg(test)]
mod frame_guides_tests;
@@ -1887,6 +1905,8 @@ mod variants_discovery_tests;
#[cfg(test)]
mod vector_behaviour_tests;
#[cfg(test)]
mod vector_fill_rule_tests;
#[cfg(test)]
mod vector_multi_behaviour_tests;
#[cfg(test)]
mod vector_offset_tests;
+14 -2
View File
@@ -339,7 +339,7 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection,
.map(|v| v.id.as_str())
.collect();
let (bound, mode_colls) = used_variables(doc, &units);
let var_ids = alias_closure(&doc.variables, offered_vars.iter().map(|v| v.to_string()).chain(bound));
let var_ids = alias_closure(&doc.variables, offered_vars.iter().map(ToString::to_string).chain(bound));
snap.variables.variables = doc.variables.variables.iter().filter(|v| var_ids.contains(&v.id)).cloned().collect();
let coll_ids: BTreeSet<&str> = snap
.variables
@@ -491,6 +491,11 @@ impl Cache {
None => Err(EngineError::Other(crate::library_server::NOT_FETCHED.into())),
};
}
// The path comes from a document: a network share would be contacted (and on Windows get
// the opener's credentials) by the stat below.
if !craft_docpath::is_local(path) {
return Err(EngineError::Other(format!("{path}: a library is opened only from this computer")));
}
let stamp = stat(path);
// Checked before the cache: the limit is the caller's.
if let Ok(st) = &stamp
@@ -553,11 +558,18 @@ fn stat(_path: &str) -> std::result::Result<Stamp, String> {
Err(NO_FILES.into())
}
/// A library read from a file or a server, with the modes chosen inside its instances marked when
/// it was published before they were overrides ([`crate::components::mark_layer_modes`]).
pub(crate) fn settled(mut lib: PublishedLibrary) -> PublishedLibrary {
crate::components::mark_layer_modes(Arc::make_mut(&mut lib.doc));
lib
}
#[cfg(not(target_arch = "wasm32"))]
fn read_file(path: &str) -> std::result::Result<PublishedLibrary, String> {
let f = crate::file_access::open(path).map_err(|e| e.to_string())?;
match figcraft_format::read_library(std::io::BufReader::new(f)) {
Ok(Some(lib)) => Ok(lib),
Ok(Some(lib)) => Ok(settled(lib)),
Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")),
Err(e) => Err(format!("{path}: {e}")),
}
+9
View File
@@ -28,3 +28,12 @@ fn a_local_library_path_is_still_followed() {
let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist");
assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}");
}
#[test]
fn the_library_cache_refuses_a_path_off_this_computer() {
let mut cache = crate::library::Cache::default();
for path in [r"\\attacker.example\share\lib.figcraft", "//attacker.example/share/lib.figcraft"] {
let e = cache.load(path).expect_err(&format!("{path} must be refused"));
assert!(e.to_string().contains("only from this computer"), "{path}: {e}");
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ pub fn archive(lib: &PublishedLibrary) -> Result<Vec<u8>> {
/// The library in an archive from a server (untrusted: read with the library file's limits).
pub fn read_archive(bytes: &[u8]) -> std::result::Result<PublishedLibrary, String> {
match figcraft_format::read_library(Cursor::new(bytes)) {
Ok(Some(lib)) => Ok(lib),
Ok(Some(lib)) => Ok(crate::library::settled(lib)),
Ok(None) => Err("the server sent a file without a library".into()),
Err(e) => Err(format!("the server sent a library that can't be read ({e})")),
}
@@ -344,3 +344,67 @@ fn library_style_bindings_ignore_unrelated_local_variables() {
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
/// A library "Modal" holding a Button instance whose icon copy is on mode Dark: (the Button main,
/// the icon copy in Modal).
fn nested_mode_kit(lib: &mut Session) -> (NodeId, NodeId) {
theme(lib);
let f = idv(&ex(lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"})));
ex(lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"}));
let icon = idv(&ex(lib, "component.create", json!({"ids": [f]})));
let b = idv(&ex(lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"})));
ex(lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b}));
let button = idv(&ex(lib, "component.create", json!({"ids": [b]})));
let m = idv(&ex(lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"})));
let close = idv(&ex(lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m})));
let nested = lib.doc().unwrap().node(close).unwrap().children[0];
ex(lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"}));
ex(lib, "component.create", json!({"ids": [m]}));
(button, nested)
}
/// The icon inside the Button inside library Modal instance `inst`.
fn modal_icon(s: &Session, inst: NodeId) -> NodeId {
let doc = s.doc().unwrap();
doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0]
}
#[test]
fn modes_set_inside_nested_instances_come_along() {
let d = dir("nested-modes");
let lib_path = path_str(&d.join("kit.figcraft"));
let mut lib = Session::new();
let (button, _) = nested_mode_kit(&mut lib);
let lid = publish_to(&mut lib, &lib_path);
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": lib_path}));
let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0})));
let copy = |s: &Session| modal_icon(s, inst);
assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along");
// An update that rebuilds the Button copies keeps it too.
ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"}));
republish(&mut lib);
ex(&mut s, "library.acceptUpdates", json!({}));
assert_eq!(fill(&s, copy(&s)), "FFFFFF");
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_nested_instances() {
let d = dir("nested-modes-old");
let lib_path = path_str(&d.join("kit.figcraft"));
let mut lib = Session::new();
let (_, nested) = nested_mode_kit(&mut lib);
// Published before `modes` was an override group: the copy has its mode but no marker.
lib.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes");
let lid = publish_to(&mut lib, &lib_path);
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": lib_path}));
let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0})));
assert_eq!(fill(&s, modal_icon(&s, inst)), "FFFFFF", "the Dark mode comes along");
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
+176
View File
@@ -198,3 +198,179 @@ fn copied_instances_keep_their_overrides() {
assert!(node(&s, t).source.is_none() && node(&s, t).overrides.is_empty());
healthy(&s);
}
/// `button()` with the main's copy of the icon's Shape filled red, and an instance-swap property
/// `Icon` on the nested icon: (icon, shape, button, nested).
fn button_with_icon_override(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
let (icon_main, shape, button, nested) = button(s);
let main_shape = copy_of(s, nested, shape);
ex(s, "node.set", json!({"id": main_shape, "props": {"fill": "#ff0000"}}));
ex(s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": icon_main, "layer": nested}));
(icon_main, shape, button, nested)
}
/// Swap instance `i`'s icon to `to` by its instance-swap property `Icon`.
fn swap_icon(s: &mut Session, i: NodeId, to: NodeId) {
ex(s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": to}}));
healthy(s);
}
#[test]
fn a_main_override_carries_onto_a_swapped_nested_instance() {
// Documented: overrides survive a swap when layer names match, and a swapped icon takes the
// colour the main component set (forum thread with staff replies). Rows: by the instance-swap
// property and by instance.swap, onto a matching and an unmatched name. The main's size
// override is geometry and stays out; the instance's own override wins over the main's.
let mut s = Session::new();
let (_icon, shape, button, nested) = button_with_icon_override(&mut s);
let main_shape = copy_of(&s, nested, shape);
ex(&mut s, "node.set", json!({"id": main_shape, "props": {"width": 10}}));
let other = icon(&mut s, 200.0, "Other", "Shape");
let unmatched = icon(&mut s, 300.0, "Unmatched", "Glyph");
for (by_property, to, want) in [(true, other, "FF0000"), (false, other, "FF0000"), (true, unmatched, "111111"), (false, unmatched, "111111")] {
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
if by_property {
swap_icon(&mut s, i, to);
} else {
let inested = copy_of(&s, i, nested);
ex(&mut s, "instance.swap", json!({"ids": [inested], "component": to}));
healthy(&s);
}
let glyph = kids(&s, copy_of(&s, i, nested))[0];
assert_eq!(fill(&s, glyph), want, "by property {by_property}, to {to}");
assert_eq!(node(&s, glyph).width, 20.0, "the main's size override stays out");
}
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 600})));
let glyph = kids(&s, copy_of(&s, i, nested))[0];
ex(&mut s, "node.set", json!({"id": glyph, "props": {"fill": "#0000ff"}}));
swap_icon(&mut s, i, other);
assert_eq!(fill(&s, kids(&s, copy_of(&s, i, nested))[0]), "0000FF", "the instance's own override wins");
}
#[test]
fn a_main_override_carries_onto_a_swap_one_level_deeper() {
// As above for a Button instance inside a Toolbar main, swapped in a Toolbar instance.
let mut s = Session::new();
let (_icon, _shape, button, nested) = button_with_icon_override(&mut s);
let other = icon(&mut s, 200.0, "Other", "Shape");
let bar = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 60, "name": "Toolbar"})));
let inner = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": bar})));
let toolbar = idv(&ex(&mut s, "component.create", json!({"ids": [bar]})));
let t = idv(&ex(&mut s, "instance.create", json!({"component": toolbar, "x": 0, "y": 800})));
let icon_in_t = copy_of(&s, t, copy_of(&s, inner, nested));
ex(&mut s, "instance.swap", json!({"ids": [icon_in_t], "component": other}));
healthy(&s);
assert_eq!(fill(&s, kids(&s, icon_in_t)[0]), "FF0000");
}
#[test]
fn a_swapped_nested_instance_keeps_the_main_override_it_took() {
// Documented (staff reply): later edits of the main's icon colour don't reach instances that
// swapped, and do reach the rest. Undo and redo follow; resetting the override shows the icon
// main's own fill.
let mut s = Session::new();
let (_icon, shape, button, nested) = button_with_icon_override(&mut s);
let other = icon(&mut s, 200.0, "Other", "Shape");
let swapped = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
let kept = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 500})));
swap_icon(&mut s, swapped, other);
let glyph = |s: &Session, i: NodeId| fill(s, kids(s, copy_of(s, i, nested))[0]);
let main_shape = copy_of(&s, nested, shape);
ex(&mut s, "node.set", json!({"id": main_shape, "props": {"fill": "#00ff00"}}));
healthy(&s);
assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into()));
s.undo().unwrap();
assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "FF0000".into()));
s.redo().unwrap();
assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into()));
let swapped_glyph = kids(&s, copy_of(&s, swapped, nested))[0];
ex(&mut s, "component.resetOverride", json!({"id": swapped_glyph, "group": "fills"}));
healthy(&s);
assert_eq!(glyph(&s, swapped), "111111");
}
#[test]
fn a_swapped_in_icon_keeps_its_own_overrides_to_itself() {
// Docs silent; pinned: what an icon main overrides inside itself stays that main's. After a
// swap to it, its edits still reach the instance; a second swap doesn't carry them on.
let mut s = Session::new();
let (_icon, _shape, button, nested) = button_with_icon_override(&mut s);
let dot = icon(&mut s, 200.0, "Dot", "Shape");
let with_dot = |s: &mut Session, x: f64, name: &str, fill: Option<&str>| {
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 300, "width": 24, "height": 24, "name": name})));
let d = idv(&ex(s, "instance.create", json!({"component": dot, "x": 0, "y": 0, "parent": f})));
if let Some(c) = fill {
let shape = kids(s, d)[0];
ex(s, "node.set", json!({"id": shape, "props": {"fill": c}}));
}
(idv(&ex(s, "component.create", json!({"ids": [f]}))), d)
};
let (other2, dot2) = with_dot(&mut s, 300.0, "Other2", Some("#0000ff"));
let (other3, _) = with_dot(&mut s, 400.0, "Other3", None);
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
swap_icon(&mut s, i, other2);
let shape_in = |s: &Session| kids(s, kids(s, copy_of(s, i, nested))[0])[0];
assert_eq!(fill(&s, shape_in(&s)), "0000FF");
let shape2 = kids(&s, dot2)[0];
ex(&mut s, "node.set", json!({"id": shape2, "props": {"fill": "#00ffff"}}));
healthy(&s);
assert_eq!(fill(&s, shape_in(&s)), "00FFFF", "Other2's own edit reaches it");
swap_icon(&mut s, i, other3);
assert_eq!(fill(&s, shape_in(&s)), "111111", "Other2's override doesn't carry onto Other3");
}
#[test]
fn a_swapped_out_icons_own_overrides_stay_with_it() {
// Docs silent; pinned: what the Button's default icon main overrides inside itself isn't the
// Button main's to carry. Icon A colours its Base's Shape green; swapping to icon B, also built
// on Base, shows Base's own fill.
let mut s = Session::new();
let base = icon(&mut s, 0.0, "Base", "Shape");
let on_base = |s: &mut Session, x: f64, name: &str| {
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 500, "width": 24, "height": 24, "name": name})));
let b = idv(&ex(s, "instance.create", json!({"component": base, "x": 0, "y": 0, "parent": f})));
(idv(&ex(s, "component.create", json!({"ids": [f]}))), b)
};
let (a, a_base) = on_base(&mut s, 100.0, "A");
let (b, _) = on_base(&mut s, 200.0, "B");
let a_shape = kids(&s, a_base)[0];
ex(&mut s, "node.set", json!({"id": a_shape, "props": {"fill": "#00ff00"}}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 700, "width": 40, "height": 40, "name": "Button"})));
let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 4, "y": 4, "parent": f})));
let button = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": a, "layer": nested}));
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 0, "y": 800})));
swap_icon(&mut s, i, b);
let shape = kids(&s, kids(&s, copy_of(&s, i, nested))[0])[0];
assert_eq!(fill(&s, shape), "111111");
assert!(node(&s, shape).overrides.is_empty());
}
#[test]
fn a_slot_the_main_filled_shows_the_swapped_in_mains_content() {
// Docs silent on slot content across a swap; pinned: a Chip the Panel main put in its card's
// slot doesn't carry onto the swapped-in card, which shows its own default content.
let mut s = Session::new();
let card = |s: &mut Session, x: f64, name: &str| {
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 60, "name": name})));
let content = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 60, "parent": f, "name": "Content"})));
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": content, "props": {"name": format!("{name} default")}}));
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
ex(s, "component.makeSlot", json!({"id": content}));
c
};
let (a, b) = (card(&mut s, 0.0, "A"), card(&mut s, 200.0, "B"));
let chip = icon(&mut s, 400.0, "Chip", "Dot");
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 200, "height": 100, "name": "Panel"})));
let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 0, "parent": f})));
let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "instance.slotInsert", json!({"id": nested, "slot": "Content", "component": chip, "replace": true}));
ex(&mut s, "component.addProperty", json!({"id": panel, "name": "Card", "type": "instanceSwap", "default": a, "layer": nested}));
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 0, "y": 700})));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Card": b}}));
healthy(&s);
let content = kids(&s, copy_of(&s, i, nested))[0];
let names: Vec<String> = kids(&s, content).into_iter().map(|k| node(&s, k).name.clone()).collect();
assert_eq!(names, ["B default"]);
assert!(!node(&s, content).overrides.contains(SLOT));
}
+21
View File
@@ -63,3 +63,24 @@ fn finishing_the_path_clears_its_handles_and_band() {
let ov = &s.tool.overlay;
assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none());
}
#[test]
fn shift_keeps_the_band_and_the_next_point_at_45_degree_steps() {
// #970: with Shift the band shows where the point will land, at a 45° step from the last
// anchor (the same length as the pointer is away), not under the pointer.
let mut s = Session::new();
s.set_tool(ToolId::Pen);
at(&mut s, PointerKind::Down, 100.0, 100.0);
at(&mut s, PointerKind::Up, 100.0, 100.0);
let shifted = |kind, x, y| PointerEvent { shift: true, ..PointerEvent::at(kind, x, y, 1.0) };
s.pointer(&shifted(PointerKind::Hover, 300.0, 120.0)).unwrap();
let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap();
assert_eq!([x0, y0, y1], [100.0, 100.0, 100.0], "level with the last anchor");
assert!((x1 - (100.0 + 200f64.hypot(20.0))).abs() < 1e-9, "{x1}");
s.pointer(&shifted(PointerKind::Down, 300.0, 120.0)).unwrap();
s.pointer(&shifted(PointerKind::Up, 300.0, 120.0)).unwrap();
s.pointer(&shifted(PointerKind::Hover, 400.0, 230.0)).unwrap();
let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap();
assert_eq!(y0, 100.0, "the point landed on the band");
assert!((x1 - x0 - (y1 - y0)).abs() < 1e-9, "45° down to the right: {:?}", [x0, y0, x1, y1]);
}
+32 -1
View File
@@ -107,6 +107,8 @@ const DENIED: &[&str] = &[
"plugins.",
"libraries.",
"library.add",
// Uploads the open document to a server.
"library.publishToServer",
"edit.undo",
"edit.redo",
"edit.repeat",
@@ -132,6 +134,9 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
if command == "image.place" && get(params, "path").is_some() {
return Some("plugins can't read files: pass the image as `data`".into());
}
if command == "text.loadFont" && get(params, "path").is_some() {
return Some("plugins can't read files: pass the font as `data`".into());
}
// `path` here is a file on disk (elsewhere it is a vector path or an action's place).
if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() {
return Some("plugins can't write files: leave out `path` to get the result back".into());
@@ -155,7 +160,8 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
/// Whether `params` set layer data (`pluginData`, anywhere in them) under a widget's own keys.
fn writes_widget_data(params: &Value, depth: usize) -> bool {
let own = |data: &Value| data.as_object().is_some_and(|m| m.keys().any(|k| k.starts_with(crate::widgets::DATA_PREFIX)));
depth < 64
// serde_json refuses nesting past 128, so nothing deeper can reach here.
depth < 128
&& match params {
Value::Object(m) => m.iter().any(|(k, v)| (k == "pluginData" && own(v)) || writes_widget_data(v, depth + 1)),
Value::Array(a) => a.iter().any(|v| writes_widget_data(v, depth + 1)),
@@ -592,3 +598,28 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("plugins.manage", "Manage plugins…", "{} opens the plugins window; returns the list like `plugin.list`", false, manage),
]
}
#[cfg(test)]
mod refusal_tests {
use super::*;
#[test]
fn read_only_plugin_cannot_publish_to_server() {
let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm"}"#).unwrap();
let r = refusal(&m, "library.publishToServer", &json!({}));
assert!(r.is_some(), "library.publishToServer uploads the document and must be refused");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn widget_data_is_found_at_any_depth_serde_accepts() {
let depth = 100;
let text = format!("{}{{\"pluginData\":{{\"{}x\":1}}}}{}", "{\"a\":".repeat(depth), crate::widgets::DATA_PREFIX, "}".repeat(depth));
let v: Value = serde_json::from_str(&text).unwrap();
assert!(writes_widget_data(&v, 0));
}
}
+1
View File
@@ -164,6 +164,7 @@ fn files_dialogs_history_and_plugins_are_off_limits() {
("file.saveAs", json!({"path": target})),
("file.open", json!({"path": "C:/Windows/win.ini"})),
("image.place", json!({"path": "secret.png"})),
("text.loadFont", json!({"path": "/etc/hosts"})),
("library.add", json!({"path": "lib.figcraft"})),
("plugin.run", json!({"id": "org.test.deny"})),
("plugin.remove", json!({"id": "org.test.deny"})),
+5 -1
View File
@@ -15,8 +15,12 @@ struct SaveFiles {
impl SaveFiles {
fn new() -> Self {
// Tests run in parallel and the clock can read the same for two of them (macOS counts
// microseconds), so a counter keeps their folders apart.
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id()));
let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}-{n}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
Self { dir, files: Vec::new() }
}
@@ -324,6 +324,49 @@ fn shift_drag_moves_the_selection() {
assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out");
}
#[test]
fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() {
let mut s = Session::new();
let k = scene(&mut s);
for i in [k.r, k.c] {
ex(&mut s, "node.set", json!({"ids": [i], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
}
let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin();
// (selection before, drag from, selection after, the layers that move by (10, 10), derivation)
let rows: Vec<(Vec<NodeId>, (f64, f64), Vec<NodeId>, Vec<NodeId>, &str)> = vec![
(vec![k.r], (520.0, 20.0), vec![k.r], vec![k.r], "a selected unfilled shape drags from its inside, not only its stroke"),
(vec![k.c], (40.0, 140.0), vec![k.c], vec![k.c], "the same in a top-level frame, whose background is under the inside"),
(vec![k.r, k.empty], (570.0, 20.0), vec![k.r, k.empty], vec![k.r, k.empty], "a gap inside a multiple selection's box drags them all"),
(vec![], (520.0, 20.0), vec![k.r], vec![], "an unselected unfilled shape is hit on its stroke only: the drag is a marquee, which touches it"),
];
for (before, from, after, moved, why) in rows {
let home: Vec<Point> = [k.r, k.c, k.empty].iter().map(|i| at(&s, *i)).collect();
ex(&mut s, "select.set", json!({"ids": before}));
drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE);
assert_eq!(sorted(s.selection()), sorted(after), "{why}");
for (i, h) in [k.r, k.c, k.empty].iter().zip(&home) {
let d = if moved.contains(i) { 10.0 } else { 0.0 };
assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why}");
}
if !moved.is_empty() {
assert!(s.undo().unwrap(), "{why}: one undo step");
}
}
// A turned layer's box is turned with it: the corners of its page-aligned bounds are outside.
ex(&mut s, "node.set", json!({"ids": [k.r], "props": {"rotation": 45}}));
let b = s.doc().unwrap().world_bounds(k.r).unwrap();
ex(&mut s, "select.set", json!({"ids": [k.r]}));
drag(&mut s, (b.x0 + 2.0, b.y0 + 2.0), (b.x0 + 12.0, b.y0 + 12.0), NONE);
assert_eq!(at(&s, k.r), b.origin(), "a press outside a turned layer's box is a marquee: it doesn't move the layer");
ex(&mut s, "select.set", json!({"ids": [k.r]}));
let c = b.center();
drag(&mut s, (c.x, c.y), (c.x + 10.0, c.y + 10.0), NONE);
assert_eq!(s.selection(), vec![k.r], "inside the turned box it drags");
// Pixel snapping rounds the turned box's origin, so the move is about the pointer's.
let d = at(&s, k.r) - b.origin();
assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}");
}
#[test]
fn alt_shift_drag_on_a_selected_layer_duplicates_it() {
let mut s = Session::new();
+12 -3
View File
@@ -120,6 +120,15 @@ static ROWS: &[Row] = &[
pin(Tools, "tool.slice", &["S"], &[]),
pin(Tools, "file.placeImage", &["Shift+Cmd+K"], &[]),
pin(Tools, "tool.shapeBuilder", &["Shift+M"], &[]),
// Board tools (boards only), on keys no design-file shortcut uses.
pin(Tools, "tool.sticky", &["Q"], &[]),
pin(Tools, "tool.shape", &["B"], &[]),
pin(Tools, "tool.connector", &["X"], &[]),
pin(Tools, "tool.marker", &["M"], &[]),
pin(Tools, "tool.highlighter", &["U"], &[]),
pin(Tools, "tool.eraser", &["E"], &[]),
pin(Tools, "tool.stamp", &["W"], &[]),
pin(Tools, "board.nextSticky", &["Shift+Cmd+Enter"], &[]),
// Vector edit mode
doc(Selection, "Edit the selected shape's points", &["Enter"], &[], Bound("select.children"), "Enter on a shape edits it; on a frame or group it selects the children"),
doc(Shape, "Vector edit: move", &["V"], &[], VectorTool(VTool::Move), ""),
@@ -162,8 +171,8 @@ static ROWS: &[Row] = &[
doc(Zoom, "Zoom out", &["Cmd+-", "-"], &[], Bound("view.zoomOut"), ""),
doc(Zoom, "Zoom to fit", &["Shift+1"], &[], Bound("view.zoomFit"), ""),
doc(Zoom, "Zoom to the selection", &["Shift+2"], &[], Bound("view.zoomSelection"), ""),
pin(Zoom, "view.zoomIn", &["Shift+Cmd+="], &[]),
pin(Zoom, "view.zoomOut", &["Shift+-"], &[]),
pin(Zoom, "view.zoomIn", &["Shift+Cmd+=", "="], &[]),
pin(Zoom, "view.zoomOut", &["Shift+-", "Shift+Cmd+-"], &[]),
pin(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"], &[]),
pin(Zoom, "view.prevFrame", &["Shift+N", "Home"], &[]),
pin(Zoom, "view.nextFrame", &["N", "End"], &[]),
@@ -398,7 +407,7 @@ fn counts_match_the_docs() {
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
let pinned = ROWS.len() - documented.len();
// docs/ui-parity.md quotes these.
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 49));
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 57));
}
#[test]
@@ -80,6 +80,20 @@ fn link(s: &Session, n: NodeId, kind: &str) -> Option<String> {
}
}
/// The key linking a style of `kind` (a paint style links fills).
fn style_key(kind: &str) -> String {
if kind == "paint" { "fillStyle".into() } else { format!("{kind}Style") }
}
/// `node.create` params for an unselected layer that takes `kind` styles, with `props`.
fn new_layer(kind: &str, props: Value) -> Value {
if kind == "text" {
json!({"type": "text", "text": "Hej", "select": false, "props": props})
} else {
json!({"type": "frame", "select": false, "props": props})
}
}
#[test]
fn a_style_holds_the_whole_property_and_links_its_source() {
// Documented: colour styles can hold several fills (gradients, images); effect styles combine
@@ -234,6 +248,59 @@ fn editing_a_styled_property_detaches_that_style_only() {
let _ = st;
}
#[test]
fn a_style_link_wins_over_values_in_the_same_call() {
// Docs silent on one call setting both (a linked layer shows its style's values; editing one
// detaches it); pinned: the link wins whatever the keys are called, on node.create and
// node.set, and a deviation set in a later call unlinks as before.
let mut s = Session::new();
let rows = styled(&mut s);
let pairs: [(&str, &str, Value); 12] = [
("paint", "fill", json!("#123456")),
("paint", "fills", json!(["#123456"])),
("stroke", "stroke", json!("#123456")),
("stroke", "strokes", json!(["#123456"])),
("effect", "effects", json!([])),
("grid", "grids", json!([])),
("grid", "layoutGrids", json!([])),
("text", "fontStyle", json!("Bold")),
("text", "lineHeight", json!(40)),
("text", "textDecoration", json!("underline")),
("text", "letterSpacing", json!(5)),
("text", "variationSettings", json!({"wght": 700})),
];
for (kind, key, value) in pairs {
let (source, st, _) = rows.iter().find(|r| r.2 == kind).unwrap().clone();
let link_key = style_key(kind);
let props = json!({key: value.clone(), link_key.as_str(): st.clone()});
let made = id(run(&mut s, "node.create", new_layer(kind, props.clone())));
let set = if kind == "text" { text(&mut s, 400.0) } else { rect(&mut s, 400.0) };
run(&mut s, "node.set", json!({"id": set, "props": props}));
for (how, layer) in [("node.create", made), ("node.set", set)] {
assert_eq!(link(&s, layer, kind).as_deref(), Some(st.as_str()), "{how} `{key}` + `{link_key}`: linked");
assert_eq!(shown(&s, layer, kind), shown(&s, source, kind), "{how} `{key}` + `{link_key}`: the style's values");
}
run(&mut s, "node.set", json!({"id": set, "props": {key: value}}));
assert_eq!(link(&s, set, kind), None, "`{key}` in a later call unlinks");
assert_ne!(shown(&s, set, kind), shown(&s, source, kind), "`{key}` in a later call applies");
}
}
#[test]
fn a_style_link_leaves_values_its_style_does_not_set() {
// Docs silent; pinned: alignment given beside a text style applies, on node.create and node.set.
let mut s = Session::new();
let st = styled(&mut s).into_iter().find(|r| r.2 == "text").unwrap().1;
let props = json!({"textStyle": st, "textAlign": "center"});
let made = id(run(&mut s, "node.create", new_layer("text", props.clone())));
let set = text(&mut s, 400.0);
run(&mut s, "node.set", json!({"id": set, "props": props}));
for (how, layer) in [("node.create", made), ("node.set", set)] {
assert_eq!(link(&s, layer, "text").as_deref(), Some(st.as_str()), "{how}: linked");
assert_eq!(node(&s, layer).kind.text().unwrap().align_h, figcraft_doc::TextAlignH::Center, "{how}: `textAlign` applies");
}
}
#[test]
fn applying_a_style_replaces_variables_bound_to_that_property() {
// Documented: applying a style sets the property to the style's values. Docs silent on bound
+50 -35
View File
@@ -28,6 +28,53 @@ fn fill(doc: &mut Document, id: NodeId, c: Color) {
}
}
fn add_screen_header(doc: &mut Document, screen: NodeId, s: usize) -> DocResult<()> {
let header = doc.create(screen, NodeKind::Frame, "Header", 0.0, 0.0, SCREEN_W, 40.0)?;
if let Some(n) = doc.node_mut(header) {
n.layout = Some(AutoLayout { gap: 8.0, padding: [8.0; 4], ..AutoLayout::default() });
n.fills = vec![Paint::solid(Color::from_rgb8_const(0xF5, 0xF5, 0xF5))];
}
doc.create(header, text(&format!("Screen {}", s + 1)), "Title", 0.0, 0.0, 120.0, 16.0)?;
for k in 0..2 {
let icon = doc.create(header, ellipse(), &format!("Icon {}", k + 1), 0.0, 0.0, 20.0, 20.0)?;
fill(doc, icon, Color::from_rgb8_const(0x99, 0x99, 0x99));
}
Ok(())
}
fn add_card_group(doc: &mut Document, screen: NodeId) -> DocResult<()> {
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)?;
fill(doc, cbg, Color::from_rgb8_const(0xEE, 0xF4, 0xFF));
doc.create(card, ellipse(), "Avatar", 8.0, 8.0, 32.0, 32.0)?;
doc.create(card, text("Ada Lovelace"), "Name", 48.0, 8.0, 100.0, 16.0)?;
doc.create(card, text("Analyst"), "Role", 48.0, 28.0, 100.0, 16.0)?;
Ok(())
}
fn add_screen_grid(doc: &mut Document, screen: NodeId, s: usize, rest: usize, made: &mut usize) -> DocResult<()> {
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;
// 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 kind != 0 {
fill(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];
}
}
*made += 1;
}
Ok(())
}
/// A page of about `layers` layers (pages not counted), normalised like an opened file. Returns
/// the document and its page.
pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
@@ -53,28 +100,13 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
let budget = |made: usize| layers.saturating_sub(made);
// Auto layout header: title + two icons (4 layers).
if budget(made) >= 4 {
let header = doc.create(screen, NodeKind::Frame, "Header", 0.0, 0.0, SCREEN_W, 40.0)?;
if let Some(n) = doc.node_mut(header) {
n.layout = Some(AutoLayout { gap: 8.0, padding: [8.0; 4], ..AutoLayout::default() });
n.fills = vec![Paint::solid(Color::from_rgb8_const(0xF5, 0xF5, 0xF5))];
}
doc.create(header, text(&format!("Screen {}", s + 1)), "Title", 0.0, 0.0, 120.0, 16.0)?;
for k in 0..2 {
let icon = doc.create(header, ellipse(), &format!("Icon {}", k + 1), 0.0, 0.0, 20.0, 20.0)?;
fill(&mut doc, icon, Color::from_rgb8_const(0x99, 0x99, 0x99));
}
add_screen_header(&mut doc, screen, s)?;
made += 4;
}
// A card group (5 layers).
// 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)?;
fill(&mut doc, cbg, Color::from_rgb8_const(0xEE, 0xF4, 0xFF));
doc.create(card, ellipse(), "Avatar", 8.0, 8.0, 32.0, 32.0)?;
doc.create(card, text("Ada Lovelace"), "Name", 48.0, 8.0, 100.0, 16.0)?;
doc.create(card, text("Analyst"), "Role", 48.0, 28.0, 100.0, 16.0)?;
add_card_group(&mut doc, screen)?;
made += 5;
}
// Three button instances (3 layers each).
@@ -87,24 +119,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
}
// Fill the rest of the screen with a grid of shapes and text.
let rest = PER_SCREEN.saturating_sub(4 + 5 + 9 + 1).min(budget(made));
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;
// 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 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];
}
}
made += 1;
}
add_screen_grid(&mut doc, screen, s, rest, &mut made)?;
if budget(made) == 0 {
break;
}
+1 -1
View File
@@ -216,7 +216,7 @@ impl Kit {
pub fn variables(&mut self, collection: &str, modes: &[&str], vars: Vec<(&str, &str, Vec<Value>)>) -> Result<()> {
self.run("variable.createCollection", json!({"name": collection, "modes": modes}))?;
for (name, kind, values) in vars {
let values: Map<String, Value> = modes.iter().map(|m| m.to_string()).zip(values).collect();
let values: Map<String, Value> = modes.iter().map(ToString::to_string).zip(values).collect();
self.run("variable.create", json!({"collection": collection, "name": name, "type": kind, "values": values}))?;
}
Ok(())
+5 -6
View File
@@ -177,20 +177,19 @@ fn vertical_trim_through_node_set() {
#[test]
fn paragraph_indent_only_moves_left_aligned_text() {
figcraft_text::scan_system_fonts();
let mut s = Session::new();
let t = text(&mut s, "Indented paragraph", json!({"fontSize": 20, "paragraphIndent": 30}));
let first_x = |s: &Session| {
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
(l.glyphs.first().map_or(0.0, |g| g.x), l.width)
(l.glyphs.first().map(|g| g.x), l.width)
};
let (x, w) = first_x(&s);
if w <= 0.0 {
return;
}
// Without any font there are no glyphs, but the indent alone still gives the box a width.
let (Some(x), w) = first_x(&s) else { return };
assert!((x - 30.0).abs() < 1e-6, "left: indented");
s.execute("node.set", &json!({"id": t, "props": {"textAlign": "center"}})).unwrap();
let (x, w2) = first_x(&s);
assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it");
assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.is_some_and(|x| x.abs() < 1e-6), "centered auto-width text: no indent, the box shrinks by it");
}
/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding
+6 -1
View File
@@ -32,7 +32,9 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, created: bool) -
pub fn end(s: &mut Session) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
s.ds_mut()?.text_edit = None;
let empty = text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false);
// A board object's text (a sticky's note) stays when emptied; the object is selected again.
let owner = s.doc().ok().and_then(|d| crate::board::owner_of_text(d, te.id));
let empty = owner.is_none() && text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false);
if empty {
let doc = s.doc_mut()?;
let _ = doc.remove(te.id);
@@ -50,6 +52,9 @@ pub fn end(s: &mut Session) -> Result<()> {
}
s.commit_interaction()?;
let ds = s.ds_mut()?;
if let Some(o) = owner {
ds.selection = vec![o];
}
ds.revision += 1;
Ok(())
}
+160 -25
View File
@@ -39,10 +39,18 @@ pub enum ToolId {
Comment,
Eyedropper,
ShapeBuilder,
// Board tools.
Sticky,
BoardShape,
Connector,
Marker,
Highlighter,
Eraser,
Stamp,
}
impl ToolId {
pub const ALL: [ToolId; 18] = [
pub const ALL: [ToolId; 25] = [
ToolId::Move,
ToolId::Scale,
ToolId::Hand,
@@ -61,6 +69,13 @@ impl ToolId {
ToolId::Comment,
ToolId::Eyedropper,
ToolId::ShapeBuilder,
ToolId::Sticky,
ToolId::BoardShape,
ToolId::Connector,
ToolId::Marker,
ToolId::Highlighter,
ToolId::Eraser,
ToolId::Stamp,
];
pub fn id(self) -> &'static str {
match self {
@@ -82,6 +97,13 @@ impl ToolId {
ToolId::Comment => "comment",
ToolId::Eyedropper => "eyedropper",
ToolId::ShapeBuilder => "shapeBuilder",
ToolId::Sticky => "sticky",
ToolId::BoardShape => "shape",
ToolId::Connector => "connector",
ToolId::Marker => "marker",
ToolId::Highlighter => "highlighter",
ToolId::Eraser => "eraser",
ToolId::Stamp => "stamp",
}
}
pub fn from_id(s: &str) -> Option<ToolId> {
@@ -107,8 +129,23 @@ impl ToolId {
ToolId::Comment => "Comment",
ToolId::Eyedropper => "Pick color",
ToolId::ShapeBuilder => "Shape builder",
ToolId::Sticky => "Sticky note",
ToolId::BoardShape => "Shapes",
ToolId::Connector => "Connector",
ToolId::Marker => "Marker",
ToolId::Highlighter => "Highlighter",
ToolId::Eraser => "Eraser",
ToolId::Stamp => "Stamp",
}
}
/// The tools of a board's toolbar (a design file's toolbar has the others).
pub fn on_boards(self) -> bool {
matches!(
self,
ToolId::Sticky | ToolId::BoardShape | ToolId::Connector | ToolId::Marker | ToolId::Highlighter | ToolId::Eraser | ToolId::Stamp
)
}
/// The tool's key (from [`crate::keymap`]; empty when it has none).
pub fn shortcut(self) -> &'static str {
let id = self.id();
@@ -404,6 +441,15 @@ pub(crate) enum Gesture {
},
/// Dragging the rotation origin handle (see [`crate::rotation_origin`]).
Origin,
/// The connector tool: dragging from `from` (pressed at `start`).
Connect {
from: figcraft_doc::End,
start: Point,
},
/// The eraser: the strokes it took so far.
Erase {
gone: Vec<NodeId>,
},
/// Shape builder: regions collected so far (handled in `shapebuilder`).
ShapeBuild {
picked: Vec<usize>,
@@ -413,6 +459,13 @@ pub(crate) enum Gesture {
},
}
/// Where Shift puts the Pen's next point: as far from `last` as `p` is, at the nearest 45° step.
fn at_45_degrees(last: Point, p: Point) -> Point {
let v = p - last;
let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0;
last + Vec2::from_angle(ang) * v.hypot()
}
/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in
/// and out handles and the one before's out handle, those that aren't zero.
fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> {
@@ -447,8 +500,10 @@ pub struct ToolState {
/// removes; Alt merges).
pub shape_builder: Option<std::sync::Arc<crate::shapebuilder::Arrangement>>,
pub builder_remove: bool,
/// Show distances from the selection on hover without holding Alt (Dev mode inspection).
pub redlines: bool,
/// Dev Mode: the canvas inspects. Hovering shows distances from the selection without Alt, and
/// presses select, pan and comment but never edit the design (no moving, resizing, drawing,
/// or editing text or vectors).
pub dev_mode: bool,
/// Pen snap targets, kept while hovering (see `vectoredit::point_snap`).
pub(crate) pen_targets: Option<crate::vectoredit::point_snap::PenTargets>,
/// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the
@@ -462,6 +517,13 @@ pub struct ToolState {
pub labels: Vec<(NodeId, [Point; 4])>,
/// The brush the Pencil draws with (`tool.pencilBrush`; `None`: a plain stroke).
pub pencil_brush: Option<figcraft_doc::Brush>,
/// The board tools' colours and choices.
pub board: crate::board::BoardTool,
/// The eyedropper samples for a colour picker (`edit.pickColor` with `apply: false`): the
/// colour goes to `picked`, for the picker to write to the paint it edits, not to the fill.
pub pick_only: bool,
/// The colour the eyedropper sampled for a colour picker, until the picker takes it.
pub picked: Option<figcraft_color::Color>,
}
/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most
@@ -923,6 +985,7 @@ impl Session {
}
self.tool.tool = t;
self.tool.gesture = Gesture::None;
self.tool.pick_only = false;
// The tool's cursor shows before the pointer moves.
self.tool.overlay =
Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() };
@@ -940,6 +1003,21 @@ impl Session {
}
}
/// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects.
fn acting_tool(&self) -> ToolId {
match self.tool.tool {
t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move,
t => t,
}
}
/// Drop the gesture under way (a drag, a resize, a shape being drawn), restoring the document
/// as Escape does; the UI calls it when Dev Mode starts, where nothing may move.
pub fn drop_gesture(&mut self) -> Result<()> {
self.tool.gesture = Gesture::None;
self.cancel_interaction()
}
/// Feed one pointer event to the current tool.
pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> {
// A held button sends the same move every frame: running the gesture again would only
@@ -952,11 +1030,12 @@ impl Session {
}
self.tool.last_move = Some(ev.clone());
}
let tool = self.tool.tool;
if matches!(tool, ToolId::Move | ToolId::Scale) && self.origin_pointer(ev)? {
let tool = self.acting_tool();
let edits = matches!(tool, ToolId::Move | ToolId::Scale) && !self.tool.dev_mode;
if edits && self.origin_pointer(ev)? {
return Ok(());
}
if matches!(tool, ToolId::Move | ToolId::Scale) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? {
if edits && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? {
return Ok(());
}
if tool == ToolId::ShapeBuilder {
@@ -987,6 +1066,7 @@ impl Session {
Ok(())
}
ToolId::Eyedropper => self.eyedrop(ev),
t if t.on_boards() => self.board_down(ev),
t if t.draws_shape() => self.draw_down(ev),
_ => Ok(()),
},
@@ -1016,7 +1096,7 @@ impl Session {
}
fn hover(&mut self, ev: &PointerEvent) -> Result<()> {
let tool = self.tool.tool;
let tool = self.acting_tool();
let ds = self.ds()?;
let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
let mut cursor = tool.cursor();
@@ -1045,7 +1125,7 @@ impl Session {
if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited {
cursor = Cursor::Text;
} else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() {
if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) {
if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| !self.tool.dev_mode) {
cursor = resize_cursor(&doc, &sel, h);
} else {
let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0);
@@ -1054,10 +1134,10 @@ impl Session {
}
hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
// Alt over a selected layer: a drag from here duplicates it.
if ev.alt && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) {
if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) {
cursor = Cursor::Copy;
}
if ev.alt || self.tool.redlines {
if ev.alt || self.tool.dev_mode {
use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of};
// Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the
// selection to a ruler guide, the hovered layer or (over empty canvas) the parent.
@@ -1085,6 +1165,8 @@ impl Session {
guides = g;
self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom));
if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
// With Shift the band ends where a click puts the point (#970).
let to = if ev.shift { at_45_degrees(*last, to) } else { to };
self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]);
self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]);
}
@@ -1138,6 +1220,7 @@ impl Session {
textedit::end(self)?;
}
// Handles first: a selected ellipse's arc handles (drawn over the box), then the box's.
let edits = !self.tool.dev_mode;
if self.arc_handles_shown()
&& let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom)
&& let Some(arc) = crate::arc_handles::arc_of(&doc, id)
@@ -1157,7 +1240,7 @@ impl Session {
self.tool.overlay.shape = shape_hint(&doc, id, handle, ev.zoom);
return Ok(());
}
if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) {
if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| edits) {
let ids = roots(&doc, &sel);
if let Handle::Rotate(_) = h {
let Some(b) = selection_box(&doc, &ids) else { return Ok(()) };
@@ -1184,18 +1267,29 @@ impl Session {
}
// A frame's name label picks the frame itself. The background of a top-level frame with
// layers in it is like the canvas (a press starts a marquee inside it) unless the frame is
// selected; an empty frame (or a widget) is picked and dragged.
// selected; an empty frame (or a widget, or a board object such as a sticky) is picked and
// dragged.
let board_background = |leaf: NodeId| {
!sel.contains(&leaf)
&& doc
.node(leaf)
.is_some_and(|n| n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n))
&& doc.node(leaf).is_some_and(|n| {
n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom()
})
};
let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| {
let label = label_at(&doc, page, &self.tool.labels, ev.p());
let target = label.or_else(|| {
self.hit(&doc, page, ev.p(), ev.tol() * 2.0)
.filter(|leaf| ev.cmd || !board_background(*leaf))
.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))
});
// A press inside the selection's box that reaches nothing above it (an unfilled shape's
// inside, or the frame or canvas behind it) drags the selection.
let behind_sel = |t: Option<NodeId>| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s)));
let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) {
Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => {
Some(first)
}
_ => target,
};
match target {
Some(target) => {
// Clicking inside the current selection's box keeps it (drag them all).
@@ -1216,6 +1310,13 @@ impl Session {
vec![target]
};
self.set_selection(new_sel.clone());
// Dev Mode only selects (Shift-click still deselects): nothing moves.
if !edits {
if deselect.is_some() {
self.set_selection(without_target());
}
return Ok(());
}
// Locked layers selected in the layers panel stay put.
let ids: Vec<NodeId> = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect();
if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() {
@@ -1302,6 +1403,9 @@ impl Session {
let idx = doc.index_in_parent(*i).map(|x| x + 1);
copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?);
}
// Connectors copied with what they join join the copies.
let map = crate::board::copy_map(doc, &ids, &copies);
crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false);
moving = copies;
*sel = moving.clone();
}
@@ -1466,6 +1570,15 @@ impl Session {
Ok(())
}
Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()),
Gesture::Connect { from, start } => {
let (from, start) = (*from, *start);
self.connect_drag(ev, from, start);
return Ok(());
}
Gesture::Erase { gone } => {
self.tool.gesture = Gesture::Erase { gone: gone.clone() };
return self.erase_at(ev);
}
Gesture::PenDrag { anchor } => {
// A click's wobble pulls out no handle.
let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor };
@@ -1842,10 +1955,17 @@ impl Session {
self.tool.tool = t;
}
}
Gesture::Pencil { points, pressure } => {
Gesture::Pencil { mut points, mut pressure } => {
self.tool.overlay.pen.clear();
// The stroke ends where the button came up, even without a move to get there.
if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) {
points.push(ev.p());
pressure.push(ev.pressure);
}
self.pencil_finish(&points, &pressure)?;
}
Gesture::Connect { from, start } => self.connect_up(ev, from, start)?,
Gesture::Erase { .. } => self.erase_up()?,
Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {}
Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {}
Gesture::Vector(_) => self.commit_interaction()?,
@@ -1862,9 +1982,11 @@ impl Session {
if self.tool.tool == ToolId::Pen {
return self.pen_finish();
}
let edits = !self.tool.dev_mode;
// A frame's or section's name label: rename it there (the canvas puts a field over it).
// Labels come before widgets, as on a press; the second press began a move: undo it.
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
if edits
&& matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
&& !(ev.shift || ev.cmd || ev.alt)
&& let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p())
{
@@ -1877,7 +1999,8 @@ impl Session {
return Ok(());
}
// Quick clicks on a widget's clickable part are clicks, one by one.
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
if edits
&& matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
&& !(ev.shift || ev.cmd || ev.alt)
&& self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some()
{
@@ -1893,9 +2016,13 @@ impl Session {
self.cancel_interaction()?;
}
let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(&doc, leaf, &sel)? {
return Ok(());
}
let target = select_target(&doc, leaf, &sel, true);
// Text: edit at the click.
if doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
if edits
&& doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
&& (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target)
{
// Entering a text selects all of it; in the text being edited, the word.
@@ -1910,7 +2037,7 @@ impl Session {
return Ok(());
}
// Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active).
if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) {
if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) {
let mut ids = crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default();
ids.retain(|i| *i != target);
ids.insert(0, target);
@@ -2032,7 +2159,9 @@ impl Session {
255,
);
let sel = ds.selection.clone();
if !sel.is_empty() {
if self.tool.pick_only {
self.tool.picked = Some(c);
} else if !sel.is_empty() {
self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?;
}
self.recent_colors.retain(|x| *x != c);
@@ -2094,9 +2223,7 @@ impl Session {
if ev.shift
&& let Some((last, _, _)) = pen.points.last()
{
let v = p - *last;
let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0;
p = *last + Vec2::from_angle(ang) * v.hypot();
p = at_45_degrees(*last, p);
}
pen.points.push((p, Vec2::ZERO, Vec2::ZERO));
self.tool.gesture = Gesture::PenDrag { anchor: p };
@@ -2238,6 +2365,9 @@ impl Session {
}
let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default());
let d = fitted.to_svg();
if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) {
return self.ink_finish(&d);
}
let page = self.page()?;
let parent = frame_at(self.doc()?, page, points.first().copied().unwrap_or_default(), &[]).unwrap_or(page);
let pw = self.doc()?.world_transform(parent);
@@ -2263,6 +2393,10 @@ impl Session {
/// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point
/// keys, Enter to finish the shape builder). Returns whether the key was used.
pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result<bool> {
// Dev Mode edits nothing: keys go to the commands (which it refuses in the app).
if self.tool.dev_mode {
return Ok(false);
}
if self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
return crate::vectoredit::key(self, name, shift, alt, cmd);
}
@@ -2404,6 +2538,7 @@ impl Session {
fn arc_handles_shown(&self) -> bool {
let arc_drag = matches!(self.tool.gesture, Gesture::Arc { .. } | Gesture::Shape { .. });
self.tool.tool == ToolId::Move
&& !self.tool.dev_mode
&& (arc_drag || !self.tool.busy())
&& self.ds().is_ok_and(|ds| ds.vector_edit.is_none() && ds.text_edit.is_none())
}
+306
View File
@@ -0,0 +1,306 @@
//! Fill rules on vectors: `vector.fillRule` sets the rule a filled area fills by and
//! `vector.reverseLoop` turns one of its loops round — which is how a hole is made and unmade.
//! The numbers come from the two rules' definitions: non-zero fills where the winding count is not
//! zero (so a hole is a loop running against its outer loop), even-odd where it is odd (so the
//! direction of a loop doesn't matter).
use figcraft_doc::{NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
use figcraft_geom::{BezPath, Point};
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 net(s: &Session, id: NodeId) -> VectorNetwork {
match &s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Vector { network } => network.clone(),
other => panic!("not a vector: {other:?}"),
}
}
/// A 100 × 100 square with a 25…75 hole, both loops in one area: the outer one clockwise, the inner
/// one the same way round (so non-zero fills the hole too until it is turned round).
fn ring() -> VectorNetwork {
let mut path = BezPath::new();
for (x0, y0, x1, y1) in [(0.0, 0.0, 100.0, 100.0), (25.0, 25.0, 75.0, 75.0)] {
path.move_to((x0, y0));
path.line_to((x1, y0));
path.line_to((x1, y1));
path.line_to((x0, y1));
path.close_path();
}
let mut net = VectorNetwork::from_path(&path);
net.regions[0].winding = WindingRule::NonZero;
net
}
/// A selected vector layer holding `network`.
fn vector_layer(s: &mut Session, network: VectorNetwork) -> NodeId {
let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
let id = NodeId(id);
ex(s, "vector.edit.begin", json!({"id": id}));
ex(s, "vector.edit.end", json!({}));
{
let doc = s.doc_mut().unwrap();
match &mut doc.get_mut(id).unwrap().kind {
NodeKind::Vector { network: net } => *net = network,
other => panic!("not a vector: {other:?}"),
}
}
ex(s, "select.set", json!({"ids": [id]}));
id
}
/// A selected vector layer holding `ring()`.
fn ringed(s: &mut Session) -> NodeId {
vector_layer(s, ring())
}
/// Two 50 × 100 squares side by side share the vertical edge between them: one segment listed by
/// both areas' single loops. A segment shared between areas is the case `vector.reverseLoop` must
/// not flip, or a neighbour's fill or stroke would turn round with it.
fn two_squares() -> VectorNetwork {
// 0:A(0,0) 1:B(50,0) 2:C(100,0) 3:D(100,100) 4:E(50,100) 5:F(0,100)
// s0 A→B (left bottom), s1 B→E (the shared vertical edge), s2 E→F (left top),
// s3 F→A (left left), s4 B→C (right bottom), s5 C→D (right right), s6 D→E (right top);
// the right area's left edge is s1 walked the other way, so the squares share one segment.
// region 0: left square A→B→E→F→A = [s0, s1, s2, s3].
// region 1: right square B→C→D→E→B = [s4, s5, s6, s1] (s1 walked backwards).
VectorNetwork {
vertices: vec![
Vertex::new(0.0, 0.0),
Vertex::new(50.0, 0.0),
Vertex::new(100.0, 0.0),
Vertex::new(100.0, 100.0),
Vertex::new(50.0, 100.0),
Vertex::new(0.0, 100.0),
],
segments: vec![
Segment::line(0, 1),
Segment::line(1, 4),
Segment::line(4, 5),
Segment::line(5, 0),
Segment::line(1, 2),
Segment::line(2, 3),
Segment::line(3, 4),
],
regions: vec![
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero },
Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero },
],
}
}
/// A selected vector layer holding [`two_squares`].
fn squared(s: &mut Session) -> NodeId {
vector_layer(s, two_squares())
}
/// Two areas of two segments each over the same line: one curve above it and one below. Both areas
/// list the line's segment — the first area first (`[line, above]`), the second area last
/// (`[below, line]`) — and a two-segment ring reads the same whichever way its list is turned, so
/// these only turn round when a segment is stored the other way.
fn two_lenses() -> VectorNetwork {
// 0:A(0,0) 1:B(100,0)
// s0 A→B the straight line both areas use; s1 B→A curving above it; s2 A→B curving below it.
VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)],
segments: vec![
Segment::line(0, 1),
Segment { a: 1, b: 0, ta: (0.0, -50.0), tb: (0.0, -50.0) },
Segment { a: 0, b: 1, ta: (0.0, 50.0), tb: (0.0, 50.0) },
],
regions: vec![
Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero },
Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero },
],
}
}
/// A selected vector layer holding [`two_lenses`].
fn lensed(s: &mut Session) -> NodeId {
vector_layer(s, two_lenses())
}
/// Two areas whose only loop is the same closed segment (a curve the path closes with a straight
/// line): a one-segment loop's direction is its segment's stored ends, and both areas read them.
fn one_shared_segment() -> VectorNetwork {
VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)],
segments: vec![Segment { a: 0, b: 1, ta: (0.0, -60.0), tb: (0.0, -60.0) }],
regions: vec![Region { loops: vec![vec![0]], winding: WindingRule::NonZero }, Region { loops: vec![vec![0]], winding: WindingRule::NonZero }],
}
}
/// The point in the middle of the hole, and one on the square's left band.
const HOLE: Point = Point::new(50.0, 50.0);
const BAND: Point = Point::new(10.0, 50.0);
#[test]
fn a_loop_running_with_its_outer_loop_fills_the_hole_until_it_is_turned_round() {
let mut s = Session::new();
let id = ringed(&mut s);
let n = net(&s, id);
assert_eq!(n.regions.len(), 1);
assert_eq!(n.regions[0].loops.len(), 2, "an outer loop and the hole's");
assert!(n.region_contains(0, BAND), "the band fills");
assert!(n.region_contains(0, HOLE), "the hole fills while its loop runs the same way");
// Turning the inner loop round makes it a hole under non-zero.
let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 1}));
assert_eq!(r["reversed"], json!(true));
let n = net(&s, id);
assert!(n.region_contains(0, BAND), "the band still fills");
assert!(!n.region_contains(0, HOLE), "the hole is empty");
assert_eq!(n.region_winding(0, HOLE), Some(0), "its loops cancel out there");
// Undo turns it back.
s.undo().unwrap();
assert!(net(&s, id).region_contains(0, HOLE), "one undo step restores the loop");
}
#[test]
fn the_rule_switches_every_area_or_only_the_one_asked_for() {
let mut s = Session::new();
let id = ringed(&mut s);
// Even-odd leaves the hole empty whatever way its loop runs.
let r = ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"}));
assert_eq!((r["regions"].clone(), r["rule"].clone()), (json!(1), json!("evenOdd")));
let n = net(&s, id);
assert_eq!(n.regions[0].winding, WindingRule::EvenOdd);
assert!(!n.region_contains(0, HOLE), "even-odd counts the hole's loop as a second crossing");
// And back.
ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "nonZero"}));
assert!(net(&s, id).region_contains(0, HOLE), "non-zero fills it again");
// One area of several, by index.
let n = net(&s, id);
let mut two = n.clone();
two.regions.push(n.regions[0].clone());
{
let doc = s.doc_mut().unwrap();
match &mut doc.get_mut(id).unwrap().kind {
NodeKind::Vector { network } => *network = two,
other => panic!("not a vector: {other:?}"),
}
}
ex(&mut s, "vector.fillRule", json!({"id": id, "region": 1, "rule": "evenOdd"}));
let n = net(&s, id);
assert_eq!(n.regions[0].winding, WindingRule::NonZero, "the other area keeps its rule");
assert_eq!(n.regions[1].winding, WindingRule::EvenOdd);
// Every area at once.
ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"}));
assert!(net(&s, id).regions.iter().all(|r| r.winding == WindingRule::EvenOdd));
}
#[test]
fn junk_parameters_are_refused_and_change_nothing() {
let mut s = Session::new();
let id = ringed(&mut s);
let before = net(&s, id);
for p in [
json!({"id": id, "rule": "nope"}),
json!({"id": id}),
json!({"id": id, "rule": 7}),
json!({"id": id, "rule": "evenOdd", "region": -1}),
json!({"id": id, "rule": "evenOdd", "region": 1.5}),
json!({"id": id, "rule": "evenOdd", "region": "x"}),
json!({"id": id, "rule": "evenOdd", "region": 99}),
] {
assert!(s.execute("vector.fillRule", &p).is_err(), "{p} is refused");
}
for p in [
json!({"id": id}),
json!({"id": id, "region": 0}),
json!({"id": id, "region": 0, "loop": 99}),
json!({"id": id, "region": 99, "loop": 0}),
json!({"id": id, "region": -2, "loop": 0}),
] {
assert!(s.execute("vector.reverseLoop", &p).is_err(), "{p} is refused");
}
assert_eq!(net(&s, id), before, "nothing changed");
// A rectangle that was never a vector is refused too.
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
assert!(s.execute("vector.fillRule", &json!({"id": rect, "rule": "evenOdd"})).is_err());
assert!(s.execute("vector.reverseLoop", &json!({"id": rect, "region": 0, "loop": 0})).is_err());
}
#[test]
fn reversing_a_loop_shared_with_a_neighbour_leaves_its_segment_and_fill_alone() {
let mut s = Session::new();
let id = squared(&mut s);
let n = net(&s, id);
assert_eq!(n.regions.len(), 2, "two areas, side by side");
// Both areas fill under non-zero: a point in each.
assert!(n.region_contains(0, Point::new(25.0, 50.0)), "the left area fills");
assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the right area fills");
// The vertical edge between them is one segment listed by both areas' loops.
const SHARED: usize = 1;
assert!(n.regions[0].loops[0].contains(&SHARED) && n.regions[1].loops[0].contains(&SHARED), "one segment is shared");
let before = n.segments[SHARED];
let left_winding = n.region_winding(0, Point::new(25.0, 50.0)).unwrap();
assert_ne!(left_winding, 0, "the left area winds once");
// Reverse the left area's only loop.
let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0}));
assert_eq!(r["reversed"], json!(true));
let n = net(&s, id);
// The shared segment's stored ends are untouched: reversing the loop must not flip a neighbour's
// segment (reversing the list already turns this loop's traversal of it the other way).
assert_eq!(n.segments[SHARED], before, "the shared segment is left as it was");
// The neighbour's fill is unchanged: it still fills its interior.
assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the neighbour still fills");
// The reversed loop really turned round: its winding at a left-area point flipped sign.
assert_eq!(n.region_winding(0, Point::new(25.0, 50.0)), Some(-left_winding), "the left loop reversed");
// Undo restores the shared segment and the loop.
s.undo().unwrap();
assert_eq!(net(&s, id).segments[SHARED], before, "undo restores the shared segment too");
}
#[test]
fn a_two_segment_loop_turns_round_with_its_shared_segment_listed_first_or_last() {
let mut s = Session::new();
let id = lensed(&mut s);
const SHARED: usize = 0;
const ABOVE: Point = Point::new(50.0, -25.0);
const BELOW: Point = Point::new(50.0, 25.0);
let n = net(&s, id);
assert_eq!(n.regions.len(), 2, "two areas over the same line");
assert_eq!((n.regions[0].loops[0][0], n.regions[0].loops[0].len()), (SHARED, 2), "the shared segment is listed first");
assert_eq!((n.regions[1].loops[0][1], n.regions[1].loops[0].len()), (SHARED, 2), "and last");
assert!(n.region_contains(0, ABOVE), "the area above the line fills");
assert!(n.region_contains(1, BELOW), "the area below it fills");
let (above, below) = (n.region_winding(0, ABOVE).unwrap(), n.region_winding(1, BELOW).unwrap());
assert_ne!(above, 0, "the area above winds once");
assert_ne!(below, 0, "the area below winds once");
let shared = n.segments[SHARED];
// Turn the area that lists the shared segment first round: a two-segment ring reads the same
// either way, so this only works by storing one of its segments the other way.
assert_eq!(ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0}))["reversed"], json!(true));
let n = net(&s, id);
assert_eq!(n.segments[SHARED], shared, "the shared segment keeps its ends");
assert!(n.region_contains(0, ABOVE), "the area still fills");
assert_eq!(n.region_winding(0, ABOVE), Some(-above), "it really turned round");
assert_eq!(n.region_winding(1, BELOW), Some(below), "the neighbour did not");
// And the area that lists it last.
ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 1, "loop": 0}));
let n = net(&s, id);
assert_eq!(n.segments[SHARED], shared, "still untouched");
assert_eq!(n.region_winding(1, BELOW), Some(-below), "the second area really turned round");
assert_eq!(n.region_winding(0, ABOVE), Some(-above), "the first stays as it was");
// Undo turns each back.
s.undo().unwrap();
assert_eq!(net(&s, id).region_winding(1, BELOW), Some(below), "undo turns the second back");
s.undo().unwrap();
assert_eq!(net(&s, id).region_winding(0, ABOVE), Some(above), "and the first");
}
#[test]
fn a_one_segment_loop_shared_with_another_is_refused_instead_of_claiming_a_turn() {
let mut s = Session::new();
let id = vector_layer(&mut s, one_shared_segment());
let before = net(&s, id);
let err = s.execute("vector.reverseLoop", &json!({"id": id, "region": 0, "loop": 0})).unwrap_err();
let message = err.to_string();
assert!(message.contains("shares its segments"), "the error says why: {message}");
assert_eq!(net(&s, id), before, "nothing changed");
}
@@ -169,13 +169,19 @@ fn editing_several_vectors_at_once() {
assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer");
s.undo().unwrap();
assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer");
// Dragging one selected point drags them all.
let active = editing(&s).unwrap().id;
let grab = if active == a { (0.0, 0.0) } else { (200.0, 0.0) };
drag(&mut s, grab, (grab.0 + 10.0, grab.1 + 20.0), NONE);
assert!(near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), "dragging one selected point moves them all");
// Dragging one selected point drags them all (one on the transform box's edge: its corners
// scale, as with points of one layer).
let atr = point_at(&s, a, (100.0, 0.0));
ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true}));
drag(&mut s, (100.0, 0.0), (110.0, 20.0), NONE);
assert!(
near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, a, atr), (110.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)),
"dragging one selected point moves them all"
);
s.undo().unwrap();
assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step");
ex(&mut s, "vector.edit.select", json!({"vertices": [atl]}));
ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
// Corner radius, mirroring and deleting reach every layer's selected points.
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10}));
assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer");
+1 -1
View File
@@ -4,7 +4,7 @@
//! Entering edit mode on a non-vector shape converts it to a vector first (like the incumbent).
//! Vertex coordinates are node-local; after each edit the node is re-fitted so its box wraps the
//! network again. The network edits themselves live in [`ops`]; pointer gestures in `pointer.rs`,
//! the transform box around several points in [`transform`], point snapping in `point_snap.rs` and
//! the transform box around several points (in one layer or several) in [`transform`], point snapping in `point_snap.rs` and
//! hovering, hatching and selecting filled regions in [`regions`].
pub mod ops;
+38 -29
View File
@@ -262,7 +262,7 @@ impl Session {
}
/// Every edited layer's selected points (segment ends included) other than the active one's.
fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec<usize>)> {
pub(super) fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec<usize>)> {
ve.others
.iter()
.filter_map(|o| {
@@ -310,7 +310,7 @@ impl Session {
let Some(b) = transform::shown_box(&doc, ve) else { return Ok(()) };
match bh {
BoxHit::Inside => {
let g = self.points_gesture(ve.id, ev.p(), b.points, vec![], None, true)?;
let g = self.points_gesture(ve.id, ev.p(), b.points, b.others, None, true)?;
self.begin_interaction("Move points")?;
self.vgesture(g);
}
@@ -320,7 +320,7 @@ impl Session {
self.vgesture(VGesture::BoxRotate { b, start_angle, angle: 0.0 });
}
BoxHit::Handle(handle) => {
let targets = Arc::new(point_snap::targets(&doc, page, Some(ve.id), &b.points, &[]));
let targets = Arc::new(point_snap::targets_moving(&doc, page, Some(ve.id), &b.points, &[], &b.others));
self.begin_interaction("Scale points")?;
self.vgesture(VGesture::BoxScale { b, handle, targets, pick });
}
@@ -428,11 +428,11 @@ impl Session {
}
/// The region of the active layer a press at `ev` would take (Move tool, `h` the network's part
/// there). Inside the transform box around points selected in their own right, the box keeps
/// its drag unless the region is already selected.
/// there). Inside the transform box around points selected in their own right (in any edited
/// layer), the box keeps its drag unless the region is already selected.
fn region_under(doc: &Document, ve: &VectorEdit, h: Option<VHit>, ev: &PointerEvent) -> Option<usize> {
let r = region_hit(doc, ve, ev.p(), ev.zoom)?;
let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty();
let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty() || ve.others_selected();
let in_box = !ev.shift && own && transform::box_hit(doc, ve, h, ev.p(), ev.zoom) == Some(BoxHit::Inside);
(!in_box || ve.regions.contains(&r)).then_some(r)
}
@@ -883,7 +883,7 @@ impl Session {
let q = b.frame.inverse() * (ev.p() + c);
let (x0, y0, x1, y1) = crate::tools::resized_box(handle, q, b.width, b.height, ev.shift, ev.alt);
let m = b.onto(x0, y0, x1, y1);
self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?;
self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?;
}
VGesture::BoxRotate { b, start_angle, .. } => {
let c = b.center();
@@ -892,7 +892,7 @@ impl Session {
angle = (angle / 15.0).round() * 15.0;
}
let m = figcraft_geom::rotate_about(Affine::IDENTITY, c, -angle);
self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?;
self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?;
self.tool.overlay.cursor = Cursor::Rotate((ev.p() - c).atan2().to_degrees());
self.vgesture(VGesture::BoxRotate { b, start_angle, angle });
}
@@ -1007,10 +1007,7 @@ impl Session {
} else {
self.cancel_interaction()?;
if click_clears {
set_state(self, |e| {
e.vertices.clear();
e.segments.clear();
})?;
self.clear_points()?;
}
}
}
@@ -1037,23 +1034,7 @@ impl Session {
}
})?,
VGesture::Erase { .. } | VGesture::Paint { .. } => self.commit_interaction()?,
VGesture::BoxScale { pick, .. } => {
let ds = self.ds()?;
let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc));
match pick.filter(|_| clicked) {
Some(v) => {
self.cancel_interaction()?;
set_state(self, |e| {
if let Some(i) = e.vertices.iter().position(|x| *x == v) {
e.vertices.remove(i);
} else {
e.vertices.push(v);
}
})?;
}
None => self.commit_interaction()?,
}
}
VGesture::BoxScale { pick, .. } => self.box_scale_up(pick)?,
VGesture::Cut { start, hit } => self.cut_up(ve, start, hit, ev)?,
VGesture::Width(g) => self.width_up(g)?,
}
@@ -1063,6 +1044,34 @@ impl Session {
Ok(())
}
/// Clears the point selection of the active layer and of the other layers being edited.
fn clear_points(&mut self) -> Result<()> {
set_state(self, |e| {
e.vertices.clear();
e.segments.clear();
for o in &mut e.others {
o.vertices.clear();
o.segments.clear();
}
})
}
/// The transform box's corner or edge was let go: a Shift-click (no drag) on the corner's
/// point `pick` toggles it in the selection; anything else keeps the scale.
fn box_scale_up(&mut self, pick: Option<usize>) -> Result<()> {
let ds = self.ds()?;
let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc));
let Some(v) = pick.filter(|_| clicked) else { return self.commit_interaction() };
self.cancel_interaction()?;
set_state(self, |e| {
if let Some(i) = e.vertices.iter().position(|x| *x == v) {
e.vertices.remove(i);
} else {
e.vertices.push(v);
}
})
}
/// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its
/// points, merges into that point (in the same undo step as the move), which stays selected.
fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> {
+48 -12
View File
@@ -1,11 +1,11 @@
//! The transform box of vector edit mode. With two or more points selected (Move tool) a box in the
//! vector's own axes wraps them: its corners and edges scale them (Shift keeps proportions, Alt
//! scales from the centre), dragging just outside a corner rotates them about its centre (Shift:
//! 15° steps) and dragging inside it moves them; each drag is one undo step (the gestures live in
//! `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP,
//! vector's own axes wraps them (in the page's axes when they are in several layers): its corners
//! and edges scale them (Shift keeps proportions, Alt scales from the centre), dragging just
//! outside a corner rotates them about its centre (Shift: 15° steps) and dragging inside it moves
//! them; each drag is one undo step for every layer (the gestures live in `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP,
//! and while points are selected the flip and 90°/180° rotation commands act on them.
use figcraft_doc::Document;
use figcraft_doc::{Document, NodeId};
use figcraft_geom::{Affine, Point};
use serde_json::{Value, json};
@@ -19,16 +19,23 @@ use crate::{Result, Session, bad, spec};
const EPS: f64 = 1e-9;
/// The selected points' box: the page transform of its top-left corner (axes along the vector's
/// own), its size in those axes and the points in it.
/// own), its size in those axes and the points in it: the active layer's, and other edited
/// layers' in `others`.
#[derive(Clone, Debug, PartialEq)]
pub struct PointBox {
pub frame: Affine,
pub width: f64,
pub height: f64,
pub points: Vec<usize>,
pub others: Vec<(NodeId, Vec<usize>)>,
}
impl PointBox {
/// How many points the box wraps, in every layer.
pub fn point_count(&self) -> usize {
self.points.len() + self.others.iter().map(|(_, v)| v.len()).sum::<usize>()
}
/// Corners in page coordinates (tl, tr, br, bl).
pub fn corners(&self) -> [Point; 4] {
let (f, w, h) = (self.frame, self.width, self.height);
@@ -62,17 +69,37 @@ pub fn point_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) {
return None;
}
Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points })
Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points, others: vec![] })
}
/// The box of the selected points of every edited layer, in the page's axes (the layers' own may
/// differ).
fn layers_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
let points = selected_points(network(doc, ve.id)?, ve);
let others = Session::others_moving(doc, ve);
let page = |id: NodeId, verts: &[usize]| -> Vec<Point> {
let w = doc.world_transform(id);
network(doc, id).map(|n| verts.iter().filter_map(|v| n.vertices.get(*v)).map(|v| w * v.point()).collect()).unwrap_or_default()
};
let all = page(ve.id, &points).into_iter().chain(others.iter().flat_map(|(id, v)| page(*id, v)));
let (x0, y0, x1, y1) = all.fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |(a, b, c, d), p| {
(a.min(p.x), b.min(p.y), c.max(p.x), d.max(p.y))
});
let (width, height) = (x1 - x0, y1 - y0);
if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) {
return None;
}
Some(PointBox { frame: Affine::translate((x0, y0)), width, height, points, others })
}
/// The box drawn and dragged on the canvas: the Move tool, no open pen path, and two or more
/// points that aren't all in one place, all in the active layer.
/// shortcut: with points selected in several layers there is no box (drags, nudges and the Design panel still reach them all); wrap them all if asked for.
/// points that aren't all in one place, in any of the edited layers.
pub fn shown_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
if ve.tool != VTool::Move || ve.anchor.is_some() || ve.others_selected() {
if ve.tool != VTool::Move || ve.anchor.is_some() {
return None;
}
point_box(doc, ve).filter(|b| b.points.len() >= 2 && (b.width > EPS || b.height > EPS))
let b = if ve.others_selected() { layers_box(doc, ve) } else { point_box(doc, ve) };
b.filter(|b| b.point_count() >= 2 && (b.width > EPS || b.height > EPS))
}
/// What the transform box does under the pointer.
@@ -115,12 +142,21 @@ pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option<VHit>, p: Point, zoom:
/// Map points `verts` of vector `id` (and the handles at them) through page transform `m`, then
/// re-fit the vector.
pub fn transform_points(doc: &mut Document, id: figcraft_doc::NodeId, verts: &[usize], m: Affine) -> Result<()> {
pub fn transform_points(doc: &mut Document, id: NodeId, verts: &[usize], m: Affine) -> Result<()> {
let w = doc.world_transform(id);
let local = w.inverse() * m * w;
edit(doc, id, |net| ops::transform_vertices(net, verts, local))
}
/// Map the points of box `b` (`id`: the active layer) in every layer through page transform `m`.
pub fn transform_box(doc: &mut Document, id: NodeId, b: &PointBox, m: Affine) -> Result<()> {
transform_points(doc, id, &b.points, m)?;
for (o, verts) in &b.others {
transform_points(doc, *o, verts, m)?;
}
Ok(())
}
/// Transform the selected points by `f(box)` (page space) when editing with points selected and
/// the command names no layers (`p` has no `ids` or `id`); `None` otherwise (it then acts on
/// layers).
@@ -307,3 +307,122 @@ fn edit_mode_transform_commands_survive_junk() {
}
}
}
/// Two edited layers turned differently: `a` a 100×100 square at the origin turned -20°, `b` one
/// at (200, 0) turned 30° (the active layer), with two of `a`'s corners and all of `b`'s points
/// selected. Neither layer's axes are the page's. Snapping (the pixel grid too) is off so drags
/// land exactly.
fn two_layers(s: &mut Session) -> (NodeId, NodeId) {
let mut square = |x: f64| crate::params::id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
let (a, b) = (square(0.0), square(200.0));
ex(s, "node.set", json!({"id": a, "props": {"rotation": -20}}));
ex(s, "node.set", json!({"id": b, "props": {"rotation": 30}}));
ex(s, "view.snapGeometry", json!({"on": false}));
ex(s, "view.snapObjects", json!({"on": false}));
ex(s, "view.snapPixel", json!({"on": false}));
ex(s, "vector.edit.begin", json!({"ids": [a, b]}));
let n = net(s, a);
ex(s, "vector.edit.select", json!({"vertices": [vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 100.0)]}));
ex(s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3], "add": true}));
assert_eq!(ve(s).id, b, "the layer named last is the active one");
(a, b)
}
/// Every selected point's page position, layer by layer.
fn selected_page_points(s: &Session, a: NodeId, b: NodeId) -> Vec<(NodeId, usize, Point)> {
let e = ve(s);
let parts = e.parts();
[a, b]
.iter()
.flat_map(|id| {
let part = parts.iter().find(|p| p.id == *id).unwrap();
part.vertices.iter().map(|v| (*id, *v, page_pt(s, *id, *v))).collect::<Vec<_>>()
})
.collect()
}
#[test]
fn the_box_wraps_points_selected_in_several_layers() {
let mut s = Session::new();
let (a, b) = two_layers(&mut s);
let before = selected_page_points(&s, a, b);
assert_eq!(before.len(), 6);
// On the page's axes: `b` is turned, so neither layer's own axes fit both.
let (x0, y0) = before.iter().fold((f64::INFINITY, f64::INFINITY), |(x, y), (_, _, p)| (x.min(p.x), y.min(p.y)));
let (x1, y1) = before.iter().fold((f64::NEG_INFINITY, f64::NEG_INFINITY), |(x, y), (_, _, p)| (x.max(p.x), y.max(p.y)));
let c = s.vector_edit_box().expect("a box around the points of both layers");
assert!(near(c[0], pt(x0, y0)) && near(c[2], pt(x1, y1)), "{c:?} vs ({x0}, {y0})–({x1}, {y1})");
// The bottom-right corner dragged to twice the width scales every point in both layers.
drag(&mut s, (x1, y1), (x0 + 2.0 * (x1 - x0), y1));
for (id, v, p) in &before {
let q = page_pt(&s, *id, *v);
assert!(near(q, pt(x0 + 2.0 * (p.x - x0), p.y)), "point {v} of {id:?}: {p:?} → {q:?}");
}
s.undo().unwrap();
for (id, v, p) in &before {
assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}");
}
// From just outside the top-right corner, a quarter turn clockwise about the box's centre.
let ctr = pt((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let from = pt(x1 + 10.0, y0 - 10.0);
let d = from - ctr;
drag(&mut s, (from.x, from.y), (ctr.x - d.y, ctr.y + d.x));
for (id, v, p) in &before {
let r = *p - ctr;
let q = page_pt(&s, *id, *v);
assert!(near(q, pt(ctr.x - r.y, ctr.y + r.x)), "point {v} of {id:?}: {p:?} → {q:?}");
}
s.undo().unwrap();
for (id, v, p) in &before {
assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}");
}
}
#[test]
fn inside_the_box_of_several_layers_drags_and_a_click_clears_them_all() {
let mut s = Session::new();
let (a, b) = two_layers(&mut s);
let before = selected_page_points(&s, a, b);
// Clear of both networks: between the two squares, inside the box.
let doc = s.doc().unwrap().clone();
assert!(hit_any(&doc, &ve(&s), pt(150.0, 50.0), 6.0).is_none());
assert_eq!(box_hit(&doc, &ve(&s), None, pt(150.0, 50.0), 1.0), Some(BoxHit::Inside));
drag(&mut s, (150.0, 50.0), (170.0, 60.0));
for (id, v, p) in &before {
assert!(near(page_pt(&s, *id, *v), *p + Vec2::new(20.0, 10.0)), "point {v} of {id:?} moves along");
}
s.undo().unwrap();
click(&mut s, 150.0, 50.0);
assert!(ve(&s).parts().iter().all(|p| p.vertices.is_empty()), "a click inside clears every layer's points");
}
#[test]
fn inside_the_box_of_another_layer_drags_over_a_region_of_the_active_one() {
// `b`'s points selected, `a` (a filled 300 square around `b`) active with nothing selected:
// the press at the box's centre lands on `a`'s region too.
let mut s = Session::new();
let mut square = |x: f64, size: f64| {
crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": x, "y": x, "width": size, "height": size}))["id"]).unwrap()
};
let (a, b) = (square(0.0, 300.0), square(100.0, 50.0));
ex(&mut s, "view.snapGeometry", json!({"on": false}));
ex(&mut s, "view.snapObjects", json!({"on": false}));
ex(&mut s, "view.snapPixel", json!({"on": false}));
ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]}));
ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3]}));
ex(&mut s, "vector.edit.select", json!({"id": a, "add": true}));
let doc = s.doc().unwrap().clone();
let e = ve(&s);
assert!(e.id == a && e.vertices.is_empty() && e.others_selected());
assert!(hit_any(&doc, &e, pt(125.0, 125.0), 6.0).is_none());
assert!(regions::region_hit(&doc, &e, pt(125.0, 125.0), 1.0).is_some(), "a region of the active layer under the press");
assert_eq!(box_hit(&doc, &e, None, pt(125.0, 125.0), 1.0), Some(BoxHit::Inside));
let before: Vec<Point> = (0..4).map(|v| page_pt(&s, b, v)).collect();
drag(&mut s, (125.0, 125.0), (145.0, 135.0));
assert!(ve(&s).regions.is_empty(), "the region isn't selected");
for (v, p) in before.iter().enumerate() {
assert!(near(page_pt(&s, b, v), *p + Vec2::new(20.0, 10.0)), "point {v} of b moves along");
}
}