mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
593 lines
28 KiB
Rust
593 lines
28 KiB
Rust
//! Select › Start Global Edit (`select.globalEdit`, `select.globalEditOptions`): which objects
|
||
//! count as similar, and edits made to one of them reaching the others in proportion, as one undo
|
||
//! step. Behaviour: <https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html>.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::{Node, NodeKind};
|
||
use vectorcraft_geom::{Affine, PathData, Rect};
|
||
|
||
use crate::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap())
|
||
}
|
||
|
||
fn node(s: &Session, id: NodeId) -> Node {
|
||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||
}
|
||
|
||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||
node(s, id).geometric_bounds().unwrap()
|
||
}
|
||
|
||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||
}
|
||
|
||
fn ids(v: &Value) -> Vec<NodeId> {
|
||
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||
}
|
||
|
||
fn start(s: &mut Session, key: NodeId, p: Value) -> Vec<NodeId> {
|
||
sel(s, &[key]);
|
||
let r = s.execute("select.globalEdit", &p).unwrap();
|
||
assert_eq!(r["active"], true);
|
||
ids(&r)
|
||
}
|
||
|
||
fn fill(s: &Session, id: NodeId) -> String {
|
||
node(s, id).appearance.fill_paint().color().unwrap().to_hex()
|
||
}
|
||
|
||
fn stroke(s: &Session, id: NodeId) -> (String, f64) {
|
||
let n = node(s, id);
|
||
(n.appearance.stroke_paint().color().unwrap().to_hex(), n.appearance.stroke_width())
|
||
}
|
||
|
||
fn members(s: &Session) -> Option<Vec<NodeId>> {
|
||
s.doc().unwrap().global_edit.as_ref().map(|g| g.members.iter().map(|m| m.0).collect())
|
||
}
|
||
|
||
fn close(a: Rect, b: Rect) -> bool {
|
||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6)
|
||
}
|
||
|
||
/// `b`'s outline is `a`'s placed by `xf`, anchor for anchor.
|
||
fn placed(s: &Session, a: NodeId, b: NodeId, xf: Affine) -> bool {
|
||
let (pa, pb): (PathData, PathData) = (node(s, a).path_data().unwrap().transformed(xf), node(s, b).path_data().unwrap().clone());
|
||
pa.subpaths.len() == pb.subpaths.len()
|
||
&& pa.subpaths.iter().zip(&pb.subpaths).all(|(x, y)| {
|
||
x.closed == y.closed
|
||
&& x.anchors.len() == y.anchors.len()
|
||
&& x.anchors
|
||
.iter()
|
||
.zip(&y.anchors)
|
||
.all(|(m, n)| m.p.distance(n.p) < 1e-6 && m.h_in.distance(n.h_in) < 1e-6 && m.h_out.distance(n.h_out) < 1e-6)
|
||
})
|
||
}
|
||
|
||
/// A 100 pt square, a copy of it, a 20 pt square (the same shape, smaller), a 100 × 50 rectangle
|
||
/// and a 100 pt circle (other shapes).
|
||
fn squares(s: &mut Session) -> [NodeId; 5] {
|
||
[rect(s, 0.0, 0.0, 100.0, 100.0), rect(s, 300.0, 0.0, 100.0, 100.0), rect(s, 500.0, 0.0, 20.0, 20.0), rect(s, 0.0, 300.0, 100.0, 50.0), {
|
||
let r = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap();
|
||
NodeId(r["id"].as_u64().unwrap())
|
||
}]
|
||
}
|
||
|
||
#[test]
|
||
fn start_global_edit_takes_the_objects_of_the_same_shape_at_any_place_and_size() {
|
||
let mut s = session();
|
||
let [a, b, c, wide, circle] = squares(&mut s);
|
||
let members = start(&mut s, a, json!({}));
|
||
assert_eq!(members, [a, b, c], "the copy and the smaller square; not the rectangle {wide} nor the circle {circle}");
|
||
// The selected object stays the selection; the others are highlighted on the canvas.
|
||
assert_eq!(s.doc().unwrap().selection.objects, [a]);
|
||
assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2);
|
||
let r = s.execute("select.globalEdit", &json!({})).unwrap();
|
||
assert_eq!(r["active"], false, "the second run is Stop Global Edit");
|
||
assert!(s.doc().unwrap().global_edit.is_none() && crate::cmd::globaledit::highlights(&s).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn size_and_appearance_options_narrow_the_match() {
|
||
let mut s = session();
|
||
let [a, b, c, ..] = squares(&mut s);
|
||
assert_eq!(start(&mut s, a, json!({"size": true})), [a, b], "the smaller square has another size");
|
||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||
sel(&mut s, &[b]);
|
||
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
|
||
assert_eq!(start(&mut s, a, json!({"appearance": true})), [a, c], "the copy has another fill now");
|
||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||
// The options are kept for the session, and report how many objects they take.
|
||
sel(&mut s, &[a]);
|
||
let r = s.execute("select.globalEditOptions", &json!({"size": true, "appearance": true})).unwrap();
|
||
assert_eq!((r["size"].clone(), r["appearance"].clone(), r["count"].clone()), (json!(true), json!(true), json!(1)));
|
||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
|
||
assert!(e.contains("no similar objects"), "{e}");
|
||
s.execute("select.globalEditOptions", &json!({"size": false, "appearance": false})).unwrap();
|
||
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
|
||
}
|
||
|
||
#[test]
|
||
fn an_edit_reaches_every_member_in_proportion_as_one_undo_step() {
|
||
let mut s = session();
|
||
let [a, b, c, wide, circle] = squares(&mut s);
|
||
start(&mut s, a, json!({}));
|
||
let before = (*s.doc().unwrap().doc).clone();
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
let (wide_was, circle_was) = (node(&s, wide), node(&s, circle));
|
||
// Height 100 → 50 from the top left (the Transform panel): the 20 pt square goes to 10 pt.
|
||
s.execute("object.setBounds", &json!({"height": 50, "reference": 0})).unwrap();
|
||
assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 50.0)), "{:?}", bounds(&s, a));
|
||
assert!(close(bounds(&s, b), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, b));
|
||
assert!(close(bounds(&s, c), Rect::new(500.0, 0.0, 520.0, 10.0)), "{:?}", bounds(&s, c));
|
||
assert_eq!((node(&s, wide), node(&s, circle)), (wide_was, circle_was), "objects of other shapes are left alone");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||
crate::tests_large_edits::undo_gives_back(&mut s, &before);
|
||
}
|
||
|
||
#[test]
|
||
fn an_anchor_moved_on_one_member_moves_on_the_others() {
|
||
let mut s = session();
|
||
let [a, b, c, ..] = squares(&mut s);
|
||
start(&mut s, a, json!({}));
|
||
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
|
||
s.execute("path.moveAnchors", &json!({"dx": 10, "dy": 30})).unwrap();
|
||
assert!(placed(&s, a, b, Affine::translate((300.0, 0.0))));
|
||
assert!(placed(&s, a, c, Affine::translate((500.0, 0.0)) * Affine::scale(0.2)));
|
||
assert!(!placed(&s, a, c, Affine::translate((500.0, 0.0))), "the smaller square's anchor moves a fifth as far");
|
||
}
|
||
|
||
#[test]
|
||
fn a_drag_moves_the_members_with_each_preview_and_commits_one_step() {
|
||
let mut s = session();
|
||
let [a, b, c, ..] = squares(&mut s);
|
||
start(&mut s, a, json!({}));
|
||
let before = (*s.doc().unwrap().doc).clone();
|
||
s.begin_interaction("Move").unwrap();
|
||
s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
|
||
s.preview("object.move", &json!({"dx": 40, "dy": 0})).unwrap();
|
||
assert!(close(bounds(&s, b), Rect::new(340.0, 0.0, 440.0, 100.0)), "the second preview replaces the first: {:?}", bounds(&s, b));
|
||
s.commit_interaction().unwrap();
|
||
assert!(close(bounds(&s, c), Rect::new(508.0, 0.0, 528.0, 20.0)), "{:?}", bounds(&s, c));
|
||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Move");
|
||
crate::tests_large_edits::undo_gives_back(&mut s, &before);
|
||
}
|
||
|
||
#[test]
|
||
fn any_selected_member_leads_and_fills_reach_the_others_only_when_they_change() {
|
||
let mut s = session();
|
||
let [a, b, c, ..] = squares(&mut s);
|
||
sel(&mut s, &[c]);
|
||
s.execute("paint.setFill", &json!({"color": "#123456"})).unwrap();
|
||
start(&mut s, a, json!({}));
|
||
let c_fill = fill(&s, c);
|
||
// Moving the copy (selected during Global Edit) moves the others in proportion and keeps
|
||
// their own fills.
|
||
sel(&mut s, &[b]);
|
||
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
|
||
assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0)), "{:?}", bounds(&s, a));
|
||
assert!(close(bounds(&s, c), Rect::new(510.0, 0.0, 530.0, 20.0)), "{:?}", bounds(&s, c));
|
||
assert_eq!(fill(&s, c), c_fill);
|
||
// A new fill reaches all of them, and the stroke weight stays the model's, not scaled.
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
|
||
for id in [a, b, c] {
|
||
assert_eq!(fill(&s, id), "#ff0000");
|
||
assert_eq!(node(&s, id).appearance.stroke_width(), 4.0);
|
||
}
|
||
// Selection changes alone edit nothing; after Stop Global Edit an edit stays on its object.
|
||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||
assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn locked_and_hidden_objects_are_left_out() {
|
||
let mut s = session();
|
||
let [a, b, c, ..] = squares(&mut s);
|
||
let locked = rect(&mut s, 0.0, 450.0, 100.0, 100.0);
|
||
let hidden = rect(&mut s, 150.0, 450.0, 100.0, 100.0);
|
||
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
|
||
s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap();
|
||
assert_eq!(start(&mut s, a, json!({})), [a, b, c], "locked and hidden squares are not found");
|
||
// A member locked after the start is not edited either.
|
||
s.execute("object.lock", &json!({"ids": [b.0]})).unwrap();
|
||
let b_was = node(&s, b);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.move", &json!({"dx": 0, "dy": 10})).unwrap();
|
||
assert_eq!(node(&s, b), b_was);
|
||
assert!(close(bounds(&s, c), Rect::new(500.0, 2.0, 520.0, 22.0)), "{:?}", bounds(&s, c));
|
||
assert_eq!(bounds(&s, locked), Rect::new(0.0, 450.0, 100.0, 550.0));
|
||
assert_eq!(bounds(&s, hidden), Rect::new(150.0, 450.0, 250.0, 550.0));
|
||
}
|
||
|
||
#[test]
|
||
fn artboard_options_limit_where_similar_objects_are_found() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
|
||
let key = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
|
||
let same_board = rect(&mut s, 200.0, 10.0, 50.0, 50.0);
|
||
let second = rect(&mut s, 1100.0, 10.0, 50.0, 50.0);
|
||
let canvas = rect(&mut s, 2000.0, 2000.0, 50.0, 50.0);
|
||
assert_eq!(start(&mut s, key, json!({})), [key, same_board, second, canvas], "all artboards and the canvas by default");
|
||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||
assert_eq!(start(&mut s, key, json!({"includeCanvas": false})), [key, same_board, second]);
|
||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||
assert_eq!(start(&mut s, key, json!({"artboards": "2"})), [key, second, canvas]);
|
||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||
assert_eq!(start(&mut s, key, json!({"artboards": "2", "includeCanvas": false})), [key, second]);
|
||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||
let e = s.execute("select.globalEdit", &json!({"artboards": "3"})).unwrap_err().to_string();
|
||
assert!(e.contains("1 to 2"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn compound_paths_match_whole_and_keep_their_own_member_ids() {
|
||
let mut s = session();
|
||
let compound = |s: &mut Session, x: f64, k: f64| {
|
||
let (p, q) = (rect(s, x, 0.0, 100.0 * k, 100.0 * k), rect(s, x + 25.0 * k, 25.0 * k, 50.0 * k, 50.0 * k));
|
||
sel(s, &[p, q]);
|
||
NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap())
|
||
};
|
||
let (one, two) = (compound(&mut s, 0.0, 1.0), compound(&mut s, 300.0, 0.5));
|
||
let lone = rect(&mut s, 600.0, 0.0, 100.0, 100.0);
|
||
assert_eq!(start(&mut s, one, json!({})), [one, two], "not the square {lone} alone");
|
||
let kids = |s: &Session, id: NodeId| node(s, id).children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||
let two_kids = kids(&s, two);
|
||
s.execute("object.setBounds", &json!({"width": 200, "reference": 0})).unwrap();
|
||
assert!(close(bounds(&s, two), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, two));
|
||
assert_eq!(kids(&s, two), two_kids);
|
||
assert!(matches!(node(&s, two).kind, NodeKind::Compound { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn symbol_instances_of_one_symbol_match() {
|
||
let mut s = session();
|
||
let r = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
|
||
let made = s.execute("symbol.new", &json!({"ids": [r.0], "name": "Tile"})).unwrap();
|
||
let first = NodeId(made["id"].as_u64().unwrap());
|
||
let placed = s.execute("symbol.place", &json!({"name": "Tile", "x": 300, "y": 300})).unwrap();
|
||
let second = NodeId(placed["id"].as_u64().unwrap());
|
||
let other = rect(&mut s, 500.0, 500.0, 40.0, 40.0);
|
||
assert_eq!(start(&mut s, first, json!({})), [first, second], "not the plain square {other}");
|
||
let xf = |s: &Session, id: NodeId| match node(s, id).kind {
|
||
NodeKind::SymbolInstance { xf, .. } => xf,
|
||
_ => panic!("not an instance"),
|
||
};
|
||
let was = xf(&s, second);
|
||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||
let (now, want) = (xf(&s, second).as_coeffs(), (Affine::translate((10.0, 0.0)) * was).as_coeffs());
|
||
assert!(now.iter().zip(want).all(|(p, q)| (p - q).abs() < 1e-6), "{now:?} vs {want:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn global_edit_needs_one_supported_object() {
|
||
let mut s = session();
|
||
let [a, b, ..] = squares(&mut s);
|
||
sel(&mut s, &[a, b]);
|
||
assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "a selection of two");
|
||
let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||
sel(&mut s, &[t]);
|
||
assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "type");
|
||
let lone = rect(&mut s, 0.0, 500.0, 30.0, 70.0);
|
||
// A copy turned a quarter turn (70 × 30) is not taken: rotated copies are not matched, and the
|
||
// message says so.
|
||
let turned = rect(&mut s, 200.0, 500.0, 30.0, 70.0);
|
||
sel(&mut s, &[turned]);
|
||
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
|
||
sel(&mut s, &[lone]);
|
||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
|
||
assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}");
|
||
assert!(s.doc().unwrap().global_edit.is_none());
|
||
// A group holding type is refused, though another such group exists.
|
||
let group = |s: &mut Session, x: f64| {
|
||
let p = rect(s, x, 100.0, 10.0, 10.0);
|
||
let t = NodeId(s.execute("text.create", &json!({"x": x, "y": 150, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||
group_of(s, &[p, t])
|
||
};
|
||
let (g, _) = (group(&mut s, 600.0), group(&mut s, 700.0));
|
||
sel(&mut s, &[g]);
|
||
// The menu only checks that it is a group; starting refuses its contents.
|
||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err();
|
||
assert!(e.to_string().contains("symbol instances and groups of them"), "{e}");
|
||
assert!(s.doc().unwrap().global_edit.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn global_edit_is_not_saved() {
|
||
let mut s = session();
|
||
let [a, ..] = squares(&mut s);
|
||
start(&mut s, a, json!({}));
|
||
let saved = s.execute("document.serialize", &json!({})).unwrap().to_string();
|
||
assert!(!saved.to_ascii_lowercase().contains("globaledit"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_stroke_weight_change_leaves_each_members_own_fill_and_stroke_colour() {
|
||
let mut s = session();
|
||
let [a, b, c, ..] = squares(&mut s);
|
||
sel(&mut s, &[b]);
|
||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
|
||
start(&mut s, a, json!({}));
|
||
let (a_fill, c_fill) = (fill(&s, a), fill(&s, c));
|
||
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
|
||
assert_eq!(fill(&s, b), "#0000ff", "the copy keeps its own fill");
|
||
assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 5.0), "and its own stroke colour, with the new weight");
|
||
assert_eq!(stroke(&s, c).1, 5.0, "the weight is the model's, not scaled");
|
||
assert_eq!((fill(&s, a), fill(&s, c)), (a_fill, c_fill));
|
||
}
|
||
|
||
#[test]
|
||
fn a_fill_colour_change_reaches_the_fills_and_not_the_strokes() {
|
||
let mut s = session();
|
||
let [a, b, c, ..] = squares(&mut s);
|
||
sel(&mut s, &[b]);
|
||
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
|
||
s.execute("stroke.set", &json!({"weight": 3})).unwrap();
|
||
start(&mut s, a, json!({}));
|
||
let (a_stroke, c_stroke) = (stroke(&s, a), stroke(&s, c));
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
for id in [a, b, c] {
|
||
assert_eq!(fill(&s, id), "#ff0000");
|
||
}
|
||
assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 3.0), "the copy keeps its own stroke");
|
||
assert_eq!((stroke(&s, a), stroke(&s, c)), (a_stroke, c_stroke));
|
||
}
|
||
|
||
#[test]
|
||
fn a_fill_or_stroke_added_or_removed_reaches_the_members_that_keep_their_others() {
|
||
let mut s = session();
|
||
let [a, b, c, ..] = squares(&mut s);
|
||
sel(&mut s, &[b]);
|
||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||
// The small square has two fills: not the model's fills and strokes in order.
|
||
sel(&mut s, &[c]);
|
||
s.execute("appearance.addFill", &json!({})).unwrap();
|
||
start(&mut s, a, json!({}));
|
||
let kinds = |s: &Session, id| node(s, id).appearance.items.iter().map(|i| i.kind_name()).collect::<Vec<_>>();
|
||
s.execute("appearance.addStroke", &json!({})).unwrap();
|
||
assert_eq!(kinds(&s, a), ["fill", "stroke", "stroke"]);
|
||
assert_eq!(kinds(&s, b), kinds(&s, a), "the copy gains the stroke");
|
||
assert_eq!(fill(&s, b), "#0000ff", "and keeps its own fill");
|
||
assert_eq!(kinds(&s, c), kinds(&s, a), "a member with other fills and strokes takes the model's list");
|
||
s.execute("appearance.removeItem", &json!({"index": 0})).unwrap();
|
||
assert_eq!(kinds(&s, a), ["stroke", "stroke"]);
|
||
assert_eq!(kinds(&s, b), kinds(&s, a), "the copy loses its fill");
|
||
}
|
||
|
||
#[test]
|
||
fn undo_and_redo_drop_the_members_the_document_lacks() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
|
||
let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
|
||
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
|
||
// Undoing the making of the small square takes it out of the Global Edit, so a square made
|
||
// under its freed id is not edited with the others.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(members(&s), Some(vec![a, b]));
|
||
let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
|
||
assert_eq!(d, c, "the id is used again");
|
||
let d_was = node(&s, d);
|
||
sel(&mut s, &[a]);
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left");
|
||
assert_eq!(node(&s, d), d_was);
|
||
// Redoing the deletion of a member takes it out too; with one left, Global Edit ends.
|
||
sel(&mut s, &[b]);
|
||
s.execute("edit.clear", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(members(&s), Some(vec![a, b]), "every member is there again");
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(members(&s), None);
|
||
}
|
||
|
||
/// Two 100 pt squares and a 20 pt one made after `begin`, all three in a Global Edit; `put_back`
|
||
/// takes the document back to before the small one. The small square leaves the Global Edit, and
|
||
/// a square made under its freed id is not edited with the others.
|
||
fn put_back_drops_the_member<T>(begin: impl FnOnce(&mut Session) -> T, put_back: impl FnOnce(&mut Session, T)) {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
|
||
let t = begin(&mut s);
|
||
let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
|
||
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
|
||
put_back(&mut s, t);
|
||
assert_eq!(members(&s), Some(vec![a, b]));
|
||
let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
|
||
assert_eq!(d, c, "the id is used again");
|
||
let d_was = node(&s, d);
|
||
sel(&mut s, &[a]);
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left");
|
||
assert_eq!(node(&s, d), d_was);
|
||
}
|
||
|
||
#[test]
|
||
fn a_cancelled_drag_scrub_or_undo_scope_drops_the_members_it_takes_away() {
|
||
put_back_drops_the_member(|s| s.begin_interaction("Draw").unwrap(), |s, ()| s.cancel_interaction().unwrap());
|
||
put_back_drops_the_member(Session::begin_undo_group, |s, ()| s.end_undo_group(true));
|
||
put_back_drops_the_member(
|
||
|s| {
|
||
s.begin_undo_group();
|
||
s.begin_undo_scope().unwrap()
|
||
},
|
||
|s, scope| {
|
||
s.end_undo_scope(scope, true);
|
||
s.end_undo_group(false);
|
||
},
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_edit_that_takes_a_member_past_the_canvas_is_refused_whole() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 2.0, 2.0);
|
||
let big = rect(&mut s, 0.0, 0.0, 2000.0, 2000.0);
|
||
assert_eq!(start(&mut s, a, json!({})), [a, big]);
|
||
let before = (node(&s, a), node(&s, big));
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
// 4000 pt keeps the small square in range; the square 1000 times its size moves 1000 times as
|
||
// far, past `MAX_COORD`.
|
||
let e = s.execute("object.move", &json!({"dx": 4000, "dy": 0})).unwrap_err().to_string();
|
||
assert!(e.contains("out of range"), "{e}");
|
||
assert_eq!((node(&s, a), node(&s, big)), before, "nothing moved");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||
s.execute("object.move", &json!({"dx": 3000, "dy": 0})).unwrap();
|
||
assert!(close(bounds(&s, big), Rect::new(3.0e6, 0.0, 3.0e6 + 2000.0, 2000.0)), "{:?}", bounds(&s, big));
|
||
}
|
||
|
||
fn group_of(s: &mut Session, objects: &[NodeId]) -> NodeId {
|
||
sel(s, objects);
|
||
NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap())
|
||
}
|
||
|
||
/// A group of a square, a circle and a bar, `k` times the size, at `x` → the group and its objects.
|
||
fn card(s: &mut Session, x: f64, k: f64) -> (NodeId, [NodeId; 3]) {
|
||
let sq = rect(s, x, 0.0, 40.0 * k, 40.0 * k);
|
||
let dot = NodeId(
|
||
s.execute("shape.ellipse", &json!({"x": x + 50.0 * k, "y": 0, "width": 40.0 * k, "height": 40.0 * k})).unwrap()["id"].as_u64().unwrap(),
|
||
);
|
||
let bar = rect(s, x, 50.0 * k, 90.0 * k, 20.0 * k);
|
||
(group_of(s, &[sq, dot, bar]), [sq, dot, bar])
|
||
}
|
||
|
||
fn kids(s: &Session, g: NodeId) -> Vec<NodeId> {
|
||
node(s, g).children().unwrap().iter().map(|c| c.id).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn start_global_edit_on_a_group_takes_the_groups_like_it() {
|
||
let mut s = session();
|
||
let (g, [sq, ..]) = card(&mut s, 0.0, 1.0);
|
||
let (copy, _) = card(&mut s, 300.0, 1.0);
|
||
let (small, _) = card(&mut s, 500.0, 0.5);
|
||
// Two of the three objects; the same three with the bar moved; a lone square like the first.
|
||
let (p, q) = (rect(&mut s, 0.0, 300.0, 40.0, 40.0), rect(&mut s, 0.0, 400.0, 90.0, 20.0));
|
||
let pair = group_of(&mut s, &[p, q]);
|
||
let (moved, [.., bar]) = card(&mut s, 300.0, 1.0);
|
||
sel(&mut s, &[bar]);
|
||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||
rect(&mut s, 600.0, 300.0, 40.0, 40.0);
|
||
assert_eq!(start(&mut s, g, json!({})), [g, copy, small], "not the pair {pair} nor the group with the bar moved {moved}");
|
||
// A group alone among groups of other contents: nothing to edit with it.
|
||
sel(&mut s, &[pair]);
|
||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
|
||
assert!(e.contains("no similar objects"), "{e}");
|
||
// Size leaves out the smaller group; Appearance looks at each object in the groups.
|
||
assert_eq!(start(&mut s, g, json!({"size": true})), [g, copy]);
|
||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||
sel(&mut s, &[sq]);
|
||
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
|
||
sel(&mut s, &[g]);
|
||
let e = s.execute("select.globalEdit", &json!({"appearance": true})).unwrap_err().to_string();
|
||
assert!(e.contains("no similar objects"), "the square in the first group has another fill now: {e}");
|
||
}
|
||
|
||
#[test]
|
||
fn an_edit_inside_one_group_reaches_the_same_object_in_the_others_as_one_undo_step() {
|
||
let mut s = session();
|
||
let (g, [sq, dot, bar]) = card(&mut s, 0.0, 1.0);
|
||
let (copy, [csq, cdot, cbar]) = card(&mut s, 300.0, 1.0);
|
||
let (_, [ssq, sdot, _]) = card(&mut s, 500.0, 0.5);
|
||
start(&mut s, g, json!({}));
|
||
let before = (*s.doc().unwrap().doc).clone();
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
let (sq_fill, bar_fill) = (fill(&s, csq), fill(&s, cbar));
|
||
// The circle inside the first group, selected on its own.
|
||
sel(&mut s, &[dot]);
|
||
assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2, "the other groups are highlighted, not the one being edited");
|
||
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
|
||
assert_eq!((fill(&s, cdot), fill(&s, sdot)), ("#12ab34".to_string(), "#12ab34".to_string()));
|
||
assert_eq!((fill(&s, csq), fill(&s, cbar)), (sq_fill, bar_fill), "the other objects keep theirs");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||
// The square's height halved from the top: each copy's in proportion.
|
||
sel(&mut s, &[sq]);
|
||
s.execute("object.setBounds", &json!({"height": 20, "reference": 0})).unwrap();
|
||
assert!(close(bounds(&s, csq), Rect::new(300.0, 0.0, 340.0, 20.0)), "{:?}", bounds(&s, csq));
|
||
assert!(close(bounds(&s, ssq), Rect::new(500.0, 0.0, 520.0, 10.0)), "{:?}", bounds(&s, ssq));
|
||
assert!(placed(&s, bar, cbar, Affine::translate((300.0, 0.0))), "the bar is left alone");
|
||
assert_eq!(kids(&s, copy), [csq, cdot, cbar], "each object keeps its id");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
crate::tests_large_edits::undo_gives_back(&mut s, &before);
|
||
}
|
||
|
||
#[test]
|
||
fn an_object_added_to_the_group_is_added_to_the_others() {
|
||
let mut s = session();
|
||
let (g, [sq, ..]) = card(&mut s, 0.0, 1.0);
|
||
let (copy, [csq, ..]) = card(&mut s, 300.0, 1.0);
|
||
let (small, _) = card(&mut s, 500.0, 0.5);
|
||
start(&mut s, g, json!({}));
|
||
sel(&mut s, &[sq]);
|
||
let added = s
|
||
.edit("add", |d, _| {
|
||
let id = d.alloc_id();
|
||
let path = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 80.0, 10.0, 90.0));
|
||
d.insert(Some(g), usize::MAX, Node::path(id, path, vectorcraft_doc::Appearance::default_art()))
|
||
.map_err(|e| EngineError::Other(e.to_string()))
|
||
})
|
||
.unwrap();
|
||
let (theirs, smalls) = (kids(&s, copy), kids(&s, small));
|
||
assert_eq!((theirs.len(), smalls.len()), (4, 4));
|
||
assert_eq!(theirs.first(), Some(&csq), "the member keeps its own ids in order");
|
||
assert!(placed(&s, added, theirs[3], Affine::translate((300.0, 0.0))));
|
||
assert!(placed(&s, added, smalls[3], Affine::translate((500.0, 0.0)) * Affine::scale(0.5)));
|
||
}
|
||
|
||
#[test]
|
||
fn global_edit_takes_at_most_max_members() {
|
||
use crate::cmd::globaledit::MAX_MEMBERS;
|
||
let mut s = session();
|
||
let first = s
|
||
.edit("many", |d, _| {
|
||
let layer = d.default_layer();
|
||
let mut first = None;
|
||
for i in 0..MAX_MEMBERS + 5 {
|
||
let (x, y) = ((i % 70) as f64 * 10.0, (i / 70 % 50) as f64 * 10.0);
|
||
let id = d.alloc_id();
|
||
let path = vectorcraft_geom::shapes::rectangle(Rect::new(x, y, x + 8.0, y + 8.0));
|
||
d.insert(layer, usize::MAX, Node::path(id, path, vectorcraft_doc::Appearance::default_art()))
|
||
.map_err(|e| EngineError::Other(e.to_string()))?;
|
||
first.get_or_insert(id);
|
||
}
|
||
first.ok_or_else(|| EngineError::Other("none".into()))
|
||
})
|
||
.unwrap();
|
||
assert_eq!(start(&mut s, first, json!({})).len(), MAX_MEMBERS);
|
||
}
|
||
|
||
#[test]
|
||
fn groups_nested_as_deep_as_a_document_allows_are_edited_together() {
|
||
let mut s = session();
|
||
let nest = |s: &mut Session, x: f64| {
|
||
let inner = rect(s, x, 0.0, 10.0, 10.0);
|
||
let side = rect(s, x + 20.0, 0.0, 10.0, 10.0);
|
||
let mut top = group_of(s, &[inner, side]);
|
||
// Group until the document refuses to go deeper.
|
||
for _ in 0..2 * vectorcraft_doc::MAX_DEPTH {
|
||
sel(s, &[top]);
|
||
match s.execute("object.group", &json!({})) {
|
||
Ok(r) => top = NodeId(r["id"].as_u64().unwrap()),
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
(top, inner)
|
||
};
|
||
let ((g, inner), (other, other_inner)) = (nest(&mut s, 0.0), nest(&mut s, 300.0));
|
||
assert_eq!(start(&mut s, g, json!({})), [g, other]);
|
||
sel(&mut s, &[inner]);
|
||
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
|
||
assert_eq!(fill(&s, other_inner), "#12ab34");
|
||
}
|