mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
artboard.setProps and artboard.fitToSelection read an `index` that is negative, fractional, a string or null as 0 and edited the first artboard, and artboard.setProps edited the first artboard when the call named no `index`. artboard.duplicate, artboard.copy and artboard.cut took the Artboard tool's artboard for such an `index`. artboard.reorder failed for it with "missing index". These commands now read `index` as artboard.delete, artboard.move and artboard.fitToArt do, and fail with "no such artboard" when it names no artboard; the document and the clipboard stay as they were. artboard.setProps without `index` fails with "missing index". Without `index`, artboard.fitToSelection still fits the first artboard and the copy commands still take the Artboard tool's artboard. Docs: docs/mcp.md.
2480 lines
121 KiB
Rust
2480 lines
121 KiB
Rust
//! Menu long-tail commands (Object/Edit/Select/Type/View/File), driven through `Session::execute`.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, ColorMode, LiveShape, Node, NodeKind, TextKind};
|
||
use vectorcraft_geom::{Rect, shapes};
|
||
|
||
use super::tests_large_edits::{layer, run_soon, square, undo_gives_back};
|
||
use super::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn id_of(v: &Value) -> NodeId {
|
||
NodeId(v["id"].as_u64().unwrap())
|
||
}
|
||
|
||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||
}
|
||
|
||
fn text(s: &mut Session, t: &str) -> NodeId {
|
||
id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": t})).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 fill(s: &mut Session, id: NodeId, hex: &str) {
|
||
s.execute("paint.setFill", &json!({"ids": [id.0], "color": hex})).unwrap();
|
||
}
|
||
|
||
fn fill_of(s: &Session, id: NodeId) -> Color {
|
||
node(s, id).appearance.fill_paint().color().unwrap()
|
||
}
|
||
|
||
fn plain(s: &Session, id: NodeId) -> String {
|
||
match &node(s, id).kind {
|
||
NodeKind::Text(t) => t.plain_text(),
|
||
_ => panic!("not text"),
|
||
}
|
||
}
|
||
|
||
fn undo_len(s: &Session) -> usize {
|
||
s.doc().unwrap().history.undo.len()
|
||
}
|
||
|
||
fn selected(s: &Session) -> Vec<NodeId> {
|
||
s.doc().unwrap().selection.objects.clone()
|
||
}
|
||
|
||
fn close(a: f64, b: f64) -> bool {
|
||
(a - b).abs() < 1e-6
|
||
}
|
||
|
||
// ---------- Object → Lock / Hide ----------
|
||
|
||
#[test]
|
||
fn lock_all_artwork_above_only_overlapping_objects_above() {
|
||
let mut s = session();
|
||
let below = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
|
||
let over = rect(&mut s, 30.0, 30.0, 50.0, 50.0);
|
||
let far = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
|
||
sel(&mut s, &[a]);
|
||
let n = undo_len(&s);
|
||
let r = s.execute("object.lock.above", &json!({})).unwrap();
|
||
assert_eq!(r["count"], 1);
|
||
assert!(node(&s, over).locked);
|
||
assert!(!node(&s, below).locked && !node(&s, far).locked && !node(&s, a).locked);
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
}
|
||
|
||
#[test]
|
||
fn hide_all_artwork_above() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
|
||
let over = rect(&mut s, 30.0, 30.0, 50.0, 50.0);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.hide.above", &json!({})).unwrap();
|
||
assert!(!node(&s, over).visible);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(node(&s, over).visible);
|
||
}
|
||
|
||
fn key(s: &Session) -> Option<NodeId> {
|
||
s.doc().unwrap().selection.key
|
||
}
|
||
|
||
/// Lock › All Artwork Above deselects the selected objects it locks: here the key object, in a
|
||
/// group above the other selected object. Undo selects it again as the key object.
|
||
#[test]
|
||
fn lock_all_artwork_above_deselects_the_objects_it_locks() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
|
||
sel(&mut s, &[b]);
|
||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
sel(&mut s, &[a, b]);
|
||
s.execute("select.key", &json!({"id": b.0})).unwrap();
|
||
s.execute("object.lock.above", &json!({})).unwrap();
|
||
assert!(node(&s, g).locked);
|
||
assert_eq!((selected(&s), key(&s)), (vec![a], None));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(!node(&s, g).locked);
|
||
assert_eq!((selected(&s), key(&s)), (vec![a, b], Some(b)));
|
||
}
|
||
|
||
/// Hide › All Artwork Above deselects the selected objects it hides (here one in a sublayer above)
|
||
/// and selected objects that were hidden before. The one object left can't be the key object, so
|
||
/// the key goes too; undo brings both back.
|
||
#[test]
|
||
fn hide_all_artwork_above_deselects_the_objects_it_hides() {
|
||
let mut s = session();
|
||
let hidden = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
|
||
s.execute("layer.setProps", &json!({"id": hidden.0, "visible": false})).unwrap();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let sub = id_of(&s.execute("layer.newSublayer", &json!({})).unwrap());
|
||
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
|
||
sel(&mut s, &[hidden, a, b]);
|
||
s.execute("select.key", &json!({"id": a.0})).unwrap();
|
||
s.execute("object.hide.above", &json!({})).unwrap();
|
||
assert!(!node(&s, sub).visible);
|
||
assert_eq!((selected(&s), key(&s)), (vec![a], None));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!((selected(&s), key(&s)), (vec![hidden, a, b], Some(a)));
|
||
}
|
||
|
||
/// Lock › All Artwork Above and Hide › All Artwork Above take each object that overlaps a selected
|
||
/// object below it in the same layer, once, whether it is locked or hidden already or not. An object
|
||
/// that overlaps only a selected object above it, the group around a selected object, the selected
|
||
/// objects and an object that lies over a selected object of another layer stay as they are.
|
||
#[test]
|
||
fn lock_and_hide_all_artwork_above_take_what_overlaps_a_selected_object_below_in_its_layer() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
// `b` overlaps only `c`, which lies above it.
|
||
let b = rect(&mut s, 250.0, 0.0, 100.0, 100.0);
|
||
let c = rect(&mut s, 90.0, 0.0, 200.0, 100.0);
|
||
// `d` overlaps `a`, `e` overlaps `c`, `f` overlaps both, `g` overlaps neither, and `k` overlaps `a` and is locked.
|
||
let d = rect(&mut s, 20.0, 50.0, 30.0, 30.0);
|
||
let e = rect(&mut s, 200.0, 50.0, 30.0, 30.0);
|
||
let f = rect(&mut s, 60.0, 10.0, 50.0, 20.0);
|
||
let g = rect(&mut s, 500.0, 500.0, 20.0, 20.0);
|
||
let k = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||
s.execute("layer.setProps", &json!({"id": k.0, "locked": true})).unwrap();
|
||
// `m` is in a group, and `n` overlaps it.
|
||
let m = rect(&mut s, 800.0, 0.0, 50.0, 50.0);
|
||
sel(&mut s, &[m]);
|
||
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
let n = rect(&mut s, 820.0, 20.0, 50.0, 50.0);
|
||
// In another layer, `h` lies where `a` lies, and `j` overlaps `i`.
|
||
s.execute("layer.new", &json!({})).unwrap();
|
||
let h = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let i = rect(&mut s, 600.0, 0.0, 100.0, 100.0);
|
||
let j = rect(&mut s, 650.0, 50.0, 100.0, 100.0);
|
||
let (taken, kept) = ([d, e, f, k, n, j], [a, b, c, g, group, m, h, i]);
|
||
for (cmd, lock) in [("object.lock.above", true), ("object.hide.above", false)] {
|
||
let done = |s: &Session, id: NodeId| if lock { node(s, id).locked } else { !node(s, id).visible };
|
||
sel(&mut s, &[a, c, m, i]);
|
||
let steps = undo_len(&s);
|
||
assert_eq!(s.execute(cmd, &json!({})).unwrap(), json!({"count": 6}), "{cmd}");
|
||
assert!(taken.iter().all(|id| done(&s, *id)), "{cmd}");
|
||
assert!(!kept.iter().any(|id| done(&s, *id)), "{cmd}");
|
||
assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, c, m, i]), "{cmd}");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
}
|
||
}
|
||
|
||
/// A session with 1,000 tiles of 100 x 200 pt in rows of 40 under the 50,000 squares of [`square`],
|
||
/// 50 on each tile, all of it selected; and the ids of the tiles and of the squares.
|
||
fn tiles_under_squares() -> (Session, Vec<NodeId>, Vec<NodeId>) {
|
||
let tile = |t: usize| {
|
||
let (x, y) = ((t % 40) as f64 * 100.0, (t / 40) as f64 * 200.0);
|
||
Rect::new(x, y, x + 100.0, y + 200.0)
|
||
};
|
||
let s = super::tests_large_edits::session(51_000, |d, k| {
|
||
if k < 1000 { Node::path(d.alloc_id(), shapes::rectangle(tile(k)), Appearance::default_art()) } else { square(d, k - 1000) }
|
||
});
|
||
let mut tiles = layer(&s, 0);
|
||
let squares = tiles.split_off(1000);
|
||
(s, tiles, squares)
|
||
}
|
||
|
||
/// With 1,000 tiles selected under 50,000 squares, Lock › All Artwork Above locks every square in one
|
||
/// undo step and Hide › All Artwork Above hides every square, each within 20 s, and the tiles stay
|
||
/// selected.
|
||
#[test]
|
||
fn lock_and_hide_all_artwork_above_1000_tiles_under_50000_objects_finish_within_20_s() {
|
||
let (mut s, tiles, squares) = tiles_under_squares();
|
||
sel(&mut s, &tiles);
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let (mut s, r) = run_soon(s, "object.lock.above", json!({}));
|
||
assert_eq!(r, json!({"count": 50_000}));
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(squares.iter().all(|id| d.node(*id).is_some_and(|n| n.locked)), "every square is locked");
|
||
assert!(tiles.iter().all(|id| d.node(*id).is_some_and(|n| !n.locked)), "no tile is locked");
|
||
assert!(selected(&s) == tiles, "the tiles stay selected");
|
||
undo_gives_back(&mut s, &before);
|
||
let (s, r) = run_soon(s, "object.hide.above", json!({}));
|
||
assert_eq!(r, json!({"count": 50_000}));
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(squares.iter().all(|id| d.node(*id).is_some_and(|n| !n.visible)), "every square is hidden");
|
||
assert!(tiles.iter().all(|id| d.node(*id).is_some_and(|n| n.visible)), "no tile is hidden");
|
||
assert!(selected(&s) == tiles, "the tiles stay selected");
|
||
}
|
||
|
||
/// With all 51,000 objects of [`tiles_under_squares`] selected, Lock › All Artwork Above and Hide ›
|
||
/// All Artwork Above each find nothing to lock or hide within 20 s and record no undo step.
|
||
#[test]
|
||
fn lock_and_hide_all_artwork_above_with_51000_objects_selected_finish_within_20_s() {
|
||
let (s, tiles, squares) = tiles_under_squares();
|
||
let (steps, all) = (undo_len(&s), [tiles, squares].concat());
|
||
assert!(selected(&s) == all, "everything is selected");
|
||
let (s, r) = run_soon(s, "object.lock.above", json!({}));
|
||
assert_eq!(r, json!({"count": 0}));
|
||
let (s, r) = run_soon(s, "object.hide.above", json!({}));
|
||
assert_eq!(r, json!({"count": 0}));
|
||
assert_eq!(undo_len(&s), steps);
|
||
assert!(selected(&s) == all, "everything stays selected");
|
||
}
|
||
|
||
#[test]
|
||
fn lock_and_hide_other_layers() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let l2 = id_of(&s.execute("layer.new", &json!({})).unwrap_or(json!({"id": 0})));
|
||
let l1 = s.doc().unwrap().doc.layers[0].id;
|
||
assert_ne!(l1, l2);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.lock.otherLayers", &json!({})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(d.node(l2).unwrap().locked);
|
||
assert!(!d.node(l1).unwrap().locked);
|
||
s.execute("object.hide.otherLayers", &json!({})).unwrap();
|
||
assert!(!s.doc().unwrap().doc.node(l2).unwrap().visible);
|
||
}
|
||
|
||
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||
fn history(s: &Session) -> (usize, usize, bool) {
|
||
let st = s.doc().unwrap();
|
||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||
}
|
||
|
||
/// Select `ids` with a move of them left to redo, and mark the document saved.
|
||
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
|
||
sel(s, ids);
|
||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.doc_mut().unwrap().mark_saved();
|
||
}
|
||
|
||
/// Lock › All Artwork Above and Hide › All Artwork Above with no object above the selection, or with
|
||
/// every one locked (Lock) or hidden (Hide) already, and Lock › Other Layers and Hide › Other Layers
|
||
/// with no other layer, or with every one locked or hidden already, record no undo step: the step to
|
||
/// redo stays and the document stays unmodified. All Artwork Above still deselects a selected object
|
||
/// that is hidden.
|
||
#[test]
|
||
fn lock_and_hide_with_nothing_to_lock_or_hide_record_no_undo_step() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let over = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
|
||
// Nothing is above `over`, and the document has one layer.
|
||
select_with_a_step_to_redo(&mut s, &[over]);
|
||
let before = history(&s);
|
||
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
|
||
for c in ["object.lock.above", "object.hide.above", "object.lock.otherLayers", "object.hide.otherLayers"] {
|
||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 0}), "{c}");
|
||
assert_eq!(history(&s), before, "{c}");
|
||
assert_eq!(selected(&s), [over], "{c}");
|
||
}
|
||
// Lock › All Artwork Above locks `over` above `a` in one undo step, and then it has nothing left
|
||
// to lock. Hide › All Artwork Above then hides `over`, and then it has nothing left to hide.
|
||
for c in ["object.lock.above", "object.hide.above"] {
|
||
select_with_a_step_to_redo(&mut s, &[a]);
|
||
let undo = history(&s).0;
|
||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c}");
|
||
assert_eq!(history(&s), (undo + 1, 0, true), "{c}");
|
||
select_with_a_step_to_redo(&mut s, &[a]);
|
||
let before = history(&s);
|
||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c} again");
|
||
assert_eq!(history(&s), before, "{c} again");
|
||
}
|
||
// A hidden object selected beside `a` is deselected, still without an undo step.
|
||
let hidden = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
|
||
s.execute("layer.setProps", &json!({"id": hidden.0, "visible": false})).unwrap();
|
||
select_with_a_step_to_redo(&mut s, &[hidden, a]);
|
||
let before = history(&s);
|
||
assert_eq!(s.execute("object.hide.above", &json!({})).unwrap(), json!({"count": 1}));
|
||
assert_eq!((history(&s), selected(&s)), (before, vec![a]));
|
||
// Other Layers locks, then hides, a second layer in one undo step each, and then it has nothing
|
||
// left to lock or hide.
|
||
s.execute("layer.new", &json!({})).unwrap();
|
||
for c in ["object.lock.otherLayers", "object.hide.otherLayers"] {
|
||
select_with_a_step_to_redo(&mut s, &[a]);
|
||
let undo = history(&s).0;
|
||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c}");
|
||
assert_eq!(history(&s), (undo + 1, 0, true), "{c}");
|
||
select_with_a_step_to_redo(&mut s, &[a]);
|
||
let before = history(&s);
|
||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c} again");
|
||
assert_eq!(history(&s), before, "{c} again");
|
||
}
|
||
}
|
||
|
||
// ---------- Transform Each ----------
|
||
|
||
#[test]
|
||
fn transform_each_scales_each_about_its_own_center() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a, b]);
|
||
let n = undo_len(&s);
|
||
s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(25.0, 25.0, 75.0, 75.0));
|
||
assert_eq!(bounds(&s, b), Rect::new(225.0, 25.0, 275.0, 75.0));
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
}
|
||
|
||
#[test]
|
||
fn transform_each_move_reference_and_copy() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
// Top-left reference: scaling keeps the top-left corner.
|
||
let r = s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50, "moveH": 10, "moveV": 5, "reference": 0, "copy": true})).unwrap();
|
||
let copy = NodeId(r["ids"][0].as_u64().unwrap());
|
||
assert_ne!(copy, a);
|
||
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||
assert_eq!(bounds(&s, copy), Rect::new(10.0, 5.0, 60.0, 55.0));
|
||
}
|
||
|
||
#[test]
|
||
fn transform_each_random_is_deterministic_with_seed() {
|
||
let run = || {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.transformEach", &json!({"moveH": 50, "random": true, "seed": 7})).unwrap();
|
||
bounds(&s, a)
|
||
};
|
||
let (x, y) = (run(), run());
|
||
assert_eq!(x, y);
|
||
assert!(x.x0.abs() <= 50.0 && x.width() == 100.0);
|
||
}
|
||
|
||
/// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as
|
||
/// Effect › Distort › Transform and Illustrator's Transform Each checkboxes).
|
||
#[test]
|
||
fn transform_each_reflect_x_flips_left_right_reflect_y_flips_top_bottom() {
|
||
let mut s = session();
|
||
// A right-pointing triangle: tip at x=100, base on the left.
|
||
let id =
|
||
id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 50}, {"x": 0, "y": 100}], "closed": true})).unwrap());
|
||
sel(&mut s, &[id]);
|
||
s.execute("object.transformEach", &json!({"reflectX": true})).unwrap();
|
||
let tip_x = match &node(&s, id).kind {
|
||
// The tip is the only vertex at y=50; Reflect X about the centre maps (100, 50) → (0, 50).
|
||
NodeKind::Path { path, .. } => path.subpaths[0].anchors.iter().find(|a| (a.p.y - 50.0).abs() < 1e-6).map(|a| a.p.x).unwrap_or(f64::NAN),
|
||
_ => panic!("expected a path"),
|
||
};
|
||
assert!((tip_x - 0.0).abs() < 1e-6, "reflectX must move the tip from x=100 to x=0, got {tip_x}");
|
||
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
// A down-pointing tip at (50, 100); Reflect Y about the centre (50, 50) maps it to (50, 0).
|
||
let id2 =
|
||
id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 50, "y": 100}], "closed": true})).unwrap());
|
||
sel(&mut s, &[id2]);
|
||
s.execute("object.transformEach", &json!({"reflectY": true})).unwrap();
|
||
let tip_y = match &node(&s, id2).kind {
|
||
NodeKind::Path { path, .. } => path.subpaths[0].anchors.iter().find(|a| (a.p.x - 50.0).abs() < 1e-6).map(|a| a.p.y).unwrap_or(f64::NAN),
|
||
_ => panic!("expected a path"),
|
||
};
|
||
assert!((tip_y - 0.0).abs() < 1e-6, "reflectY must move the tip from y=100 to y=0, got {tip_y}");
|
||
}
|
||
|
||
/// Transform Each with its default values, or with any values that move nothing (both scales 100%,
|
||
/// no move, rotation or reflection, Random or not), records no undo step and replies with the
|
||
/// objects. With only a layer selected there is no object to transform: with Copy or without, it
|
||
/// records no undo step and keeps the selection. With Copy and objects selected, the default values
|
||
/// copy them in one undo step.
|
||
#[test]
|
||
fn transform_each_that_changes_nothing_records_no_undo_step() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0);
|
||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||
let before = history(&s);
|
||
let still = json!({"scaleH": 100, "scaleV": 100, "moveH": 0, "moveV": 0, "rotate": 0, "reflectX": false, "reflectY": false, "random": true});
|
||
for p in [json!({}), still, json!({"reference": 0, "strokes": true, "corners": true, "patterns": true})] {
|
||
assert_eq!(s.execute("object.transformEach", &p).unwrap(), json!({"ids": [a.0, b.0]}), "{p}");
|
||
assert_eq!(history(&s), before, "{p}");
|
||
assert_eq!(s.journal.last().unwrap().0, "object.transformEach");
|
||
}
|
||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||
sel(&mut s, &[layer]);
|
||
for p in [json!({"moveH": 10}), json!({"moveH": 10, "copy": true})] {
|
||
assert_eq!(s.execute("object.transformEach", &p).unwrap(), json!({"ids": []}), "{p}");
|
||
assert_eq!(history(&s), before, "{p}");
|
||
assert_eq!(selected(&s), [layer], "{p}");
|
||
}
|
||
sel(&mut s, &[a, b]);
|
||
let r = s.execute("object.transformEach", &json!({"copy": true})).unwrap();
|
||
assert_eq!(r["ids"].as_array().unwrap().len(), 2);
|
||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||
}
|
||
|
||
/// #1732: Transform Again (⌘D) repeats a Transform Each — the same transform on the objects
|
||
/// again, another copy when the Transform Each made copies — rather than an earlier transform.
|
||
#[test]
|
||
fn transform_again_repeats_transform_each() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
// An earlier Transform must not be what Transform Again repeats.
|
||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 5]})).unwrap();
|
||
s.execute("object.transformEach", &json!({"moveH": 50})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(55.0, 5.0, 155.0, 105.0));
|
||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(105.0, 5.0, 205.0, 105.0), "50 pt more, not 5");
|
||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(155.0, 5.0, 255.0, 105.0), "again and again");
|
||
// With Copy, every Transform Again makes another copy, further on each time.
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
let r = s.execute("object.transformEach", &json!({"moveH": 50, "copy": true})).unwrap();
|
||
let first = NodeId(r["ids"][0].as_u64().unwrap());
|
||
assert_eq!(bounds(&s, first), Rect::new(50.0, 0.0, 150.0, 100.0));
|
||
let r = s.execute("object.transformAgain", &json!({})).unwrap();
|
||
let second = NodeId(r["ids"][0].as_u64().unwrap());
|
||
assert_ne!(second, first);
|
||
assert_eq!(bounds(&s, second), Rect::new(100.0, 0.0, 200.0, 100.0), "another copy, 50 pt further");
|
||
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0), "the original stays");
|
||
assert_eq!(bounds(&s, first), Rect::new(50.0, 0.0, 150.0, 100.0), "and the first copy too");
|
||
}
|
||
|
||
/// #1732: the Transform Each dialog previews through an interaction; OK commits it, and
|
||
/// Transform Again repeats what the commit recorded. A preview that changed nothing doesn't take
|
||
/// the record over (it records no undo step either).
|
||
#[test]
|
||
fn transform_again_repeats_a_committed_transform_each_preview() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
s.begin_interaction("Transform Each").unwrap();
|
||
s.preview("object.transformEach", &json!({"moveH": 50})).unwrap();
|
||
s.commit_interaction().unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0));
|
||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(100.0, 0.0, 200.0, 100.0), "the dialog's Transform Each repeats");
|
||
s.begin_interaction("Transform Each").unwrap();
|
||
s.preview("object.transformEach", &json!({})).unwrap();
|
||
s.commit_interaction().unwrap();
|
||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(150.0, 0.0, 250.0, 100.0), "still the one that changed something");
|
||
}
|
||
|
||
#[test]
|
||
fn reset_bounding_box_is_a_noop() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a]);
|
||
let n = undo_len(&s);
|
||
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0);
|
||
assert_eq!(undo_len(&s), n);
|
||
}
|
||
|
||
// ---------- Expand / Rasterize / Crop / Trim marks ----------
|
||
|
||
#[test]
|
||
fn expand_live_shape_and_text_in_one_undo_step() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let t = text(&mut s, "Hi");
|
||
sel(&mut s, &[a, t]);
|
||
let n = undo_len(&s);
|
||
// Stroke off: the rectangle stays one path (its stroke isn't outlined).
|
||
s.execute("object.expand", &json!({"stroke": false})).unwrap();
|
||
assert!(matches!(node(&s, a).kind, NodeKind::Path { live: None, .. }));
|
||
assert!(s.doc().unwrap().doc.node(t).is_none(), "text became outlines");
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Expand");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(_), .. }));
|
||
assert!(s.doc().unwrap().doc.node(t).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn expand_nothing_errors() {
|
||
let mut s = session();
|
||
let p = id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 10, "y": 10}]})).unwrap());
|
||
sel(&mut s, &[p]);
|
||
// A plain path: nothing to expand for object/fill.
|
||
let r = s.execute("object.expand", &json!({"stroke": false}));
|
||
assert!(r.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn rasterize_replaces_selection_with_image() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||
fill(&mut s, a, "#ff0000");
|
||
sel(&mut s, &[a]);
|
||
let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap();
|
||
let r = s.execute("object.rasterize", &json!({"ppi": 144, "padding": 0})).unwrap();
|
||
let img = id_of(&r);
|
||
assert!(s.doc().unwrap().doc.node(a).is_none());
|
||
let n = node(&s, img);
|
||
let NodeKind::Image(im) = &n.kind else { panic!() };
|
||
// Visual bounds (stroke included) at 2 px/pt.
|
||
assert_eq!((im.width as f64, im.height as f64), ((vb.width() * 2.0).ceil(), (vb.height() * 2.0).ceil()));
|
||
assert!(s.doc().unwrap().doc.images.contains_key(&im.key));
|
||
let b = n.geometric_bounds().unwrap();
|
||
assert!(close(b.x0, vb.x0) && close(b.y0, vb.y0) && b.width() >= vb.width());
|
||
assert_eq!(selected(&s), vec![img]);
|
||
}
|
||
|
||
#[test]
|
||
fn crop_image_to_rect() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
|
||
let r = s.execute("object.cropImage", &json!({"rect": [10, 10, 50, 40]})).unwrap();
|
||
assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (50, 40));
|
||
let b = bounds(&s, img);
|
||
assert!(close(b.x0, 10.0) && close(b.y0, 10.0) && close(b.width(), 50.0) && close(b.height(), 40.0), "{b:?}");
|
||
// Image fully inside the artboard: default crop has nothing to do.
|
||
assert!(s.execute("object.cropImage", &json!({})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn crop_image_disabled_without_image() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a]);
|
||
assert!(matches!(s.execute("object.cropImage", &json!({})), Err(EngineError::Disabled(..))));
|
||
}
|
||
|
||
#[test]
|
||
fn mask_image_clips_it_to_its_outline_and_selects_the_clipping_path() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0, 20.0, 100.0, 50.0);
|
||
sel(&mut s, &[a]);
|
||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
|
||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||
let ib = bounds(&s, img);
|
||
let r = s.execute("object.maskImage", &json!({})).unwrap();
|
||
let (g, p) = (id_of(&r), NodeId(r["path"].as_u64().unwrap()));
|
||
let group = node(&s, g);
|
||
assert!(matches!(group.kind, NodeKind::Group { clip: true, .. }));
|
||
assert_eq!(group.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [p, img]);
|
||
// The clipping path follows the rotated image's outline, so nothing is cut off yet.
|
||
assert!(matches!(node(&s, p).kind, NodeKind::Path { clipping: true, .. }));
|
||
let pb = bounds(&s, p);
|
||
assert!(close(pb.x0, ib.x0) && close(pb.y0, ib.y0) && close(pb.x1, ib.x1) && close(pb.y1, ib.y1), "{pb:?} {ib:?}");
|
||
assert_eq!(s.doc().unwrap().selection.objects, [p]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(s.doc().unwrap().doc.node(g).is_none() && s.doc().unwrap().doc.node(img).is_some());
|
||
sel(&mut s, &[]);
|
||
assert!(matches!(s.execute("object.maskImage", &json!({})), Err(EngineError::Disabled(..))));
|
||
}
|
||
|
||
#[test]
|
||
fn trim_marks_around_selection() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||
fill(&mut s, a, "#00ff00");
|
||
s.execute("stroke.set", &json!({"ids": [a.0], "weight": 0})).unwrap();
|
||
sel(&mut s, &[a]);
|
||
let g = id_of(&s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20})).unwrap());
|
||
let n = node(&s, g);
|
||
assert_eq!(n.children().unwrap().len(), 8);
|
||
assert_eq!(n.name.as_deref(), Some("Trim Marks"));
|
||
let b = n.geometric_bounds().unwrap();
|
||
let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap();
|
||
assert!(close(b.x0, vb.x0 - 30.0) && close(b.y1, vb.y1 + 30.0), "{b:?} {vb:?}");
|
||
}
|
||
|
||
// ---------- Convert to Shape ----------
|
||
|
||
#[test]
|
||
fn convert_rectangle_path_to_live_shape() {
|
||
let mut s = session();
|
||
let p = id_of(
|
||
&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 40, "y": 0}, {"x": 40, "y": 20}, {"x": 0, "y": 20}], "closed": true}))
|
||
.unwrap(),
|
||
);
|
||
sel(&mut s, &[p]);
|
||
assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 1);
|
||
let n = node(&s, p);
|
||
let (Some(LiveShape::Rectangle { w, h, .. }), NodeKind::Path { path, .. }) = (n.live_shape(), &n.kind) else { panic!() };
|
||
assert!(close(*w, 40.0) && close(*h, 20.0));
|
||
assert_eq!(path.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 20.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn convert_rotated_rectangle_and_ellipse() {
|
||
let mut s = session();
|
||
let p = id_of(
|
||
&s.execute(
|
||
"path.create",
|
||
&json!({"anchors": [{"x": 50, "y": 0}, {"x": 100, "y": 50}, {"x": 50, "y": 100}, {"x": 0, "y": 50}], "closed": true}),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap());
|
||
sel(&mut s, &[e]);
|
||
s.execute("object.expandShape", &json!({})).unwrap();
|
||
sel(&mut s, &[p, e]);
|
||
assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 2);
|
||
let b = bounds(&s, p);
|
||
assert!(close(b.x0, 0.0) && close(b.x1, 100.0) && close(b.y1, 100.0), "{b:?}");
|
||
assert!(matches!(node(&s, e).live_shape(), Some(LiveShape::Ellipse { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn convert_to_shape_rejects_other_paths() {
|
||
let mut s = session();
|
||
let p = id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 40, "y": 0}, {"x": 10, "y": 20}], "closed": true})).unwrap());
|
||
sel(&mut s, &[p]);
|
||
assert!(s.execute("object.shape.convertToShape", &json!({})).is_err());
|
||
}
|
||
|
||
// ---------- Blend ----------
|
||
|
||
#[test]
|
||
fn blend_make_release_options() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 100.0, 0.0, 30.0, 30.0);
|
||
fill(&mut s, a, "#000000");
|
||
fill(&mut s, b, "#ffffff");
|
||
sel(&mut s, &[a, b]);
|
||
let n = undo_len(&s);
|
||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap());
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
let gn = node(&s, g);
|
||
assert!(matches!(gn.kind, NodeKind::Blend { .. }), "live blend");
|
||
let ch = vectorcraft_doc::live::expand_live(&gn);
|
||
assert_eq!(ch.len(), 5);
|
||
// Middle step: halfway in position, size and colour.
|
||
let mid = &ch[2];
|
||
let mb = mid.geometric_bounds().unwrap();
|
||
assert!(close(mb.x0, 50.0) && close(mb.width(), 20.0), "{mb:?}");
|
||
let c = mid.appearance.fill_paint().color().unwrap().to_rgb();
|
||
assert!((c[0] - 0.5).abs() < 1e-3);
|
||
// Options: 1 step.
|
||
s.execute("object.blend.options", &json!({"steps": 1})).unwrap();
|
||
assert_eq!(vectorcraft_doc::live::expand_live(&node(&s, g)).len(), 3);
|
||
// Release: keys only, back in the layer.
|
||
let r = s.execute("object.blend.release", &json!({})).unwrap();
|
||
assert_eq!(r["ids"].as_array().unwrap().len(), 2);
|
||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||
assert!(s.doc().unwrap().doc.node(a).is_some() && s.doc().unwrap().doc.node(b).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn blend_expand_and_reverse() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 100.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a, b]);
|
||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap());
|
||
s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap();
|
||
assert_eq!(node(&s, g).children().unwrap().last().unwrap().id, a);
|
||
s.execute("object.blend.reverseSpine", &json!({})).unwrap();
|
||
assert!(close(bounds(&s, a).x0, 100.0));
|
||
s.execute("object.blend.expand", &json!({})).unwrap();
|
||
assert!(matches!(node(&s, g).kind, NodeKind::Group { .. }));
|
||
assert!(s.execute("object.blend.release", &json!({})).is_err(), "no longer a blend");
|
||
}
|
||
|
||
// ---------- Artboards ----------
|
||
|
||
#[test]
|
||
fn convert_to_artboards_and_rearrange() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 900.0, 0.0, 200.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
assert_eq!(s.execute("artboard.convertToArtboards", &json!({})).unwrap()["artboards"], 1);
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_eq!(d.artboards.len(), 2);
|
||
assert_eq!(d.artboards[1].rect, Rect::new(900.0, 0.0, 1100.0, 100.0));
|
||
assert!(d.node(a).is_none());
|
||
// Art on artboard 2 moves with it.
|
||
let b = rect(&mut s, 950.0, 25.0, 10.0, 10.0);
|
||
s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_eq!(d.artboards[1].rect, Rect::new(0.0, 650.0, 200.0, 750.0));
|
||
assert_eq!(bounds(&s, b), Rect::new(50.0, 675.0, 60.0, 685.0));
|
||
}
|
||
|
||
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
|
||
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
|
||
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
|
||
#[test]
|
||
fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
|
||
s.execute("artboard.new", &json!({"x": 0, "y": 1000, "width": 300, "height": 300})).unwrap();
|
||
rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||
rect(&mut s, 1100.0, 50.0, 100.0, 100.0);
|
||
// Across the gap between artboards 1 and 2.
|
||
rect(&mut s, 700.0, 250.0, 350.0, 30.0);
|
||
// Flush with artboard 1's right edge, and on no artboard.
|
||
rect(&mut s, 800.0, 500.0, 100.0, 50.0);
|
||
rect(&mut s, 3000.0, 3000.0, 10.0, 10.0);
|
||
s.execute("select.all", &json!({})).unwrap();
|
||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
|
||
let was = boards(&s);
|
||
let n = undo_len(&s);
|
||
s.execute("artboard.fitToArt", &json!({"indices": [0, 1, 2]})).unwrap();
|
||
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), Rect::new(700.0, 50.0, 1200.0, 280.0), was[2]]);
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(boards(&s), was);
|
||
assert!(s.execute("artboard.fitToArt", &json!({"index": 2})).is_err(), "no art on artboard 3");
|
||
assert_eq!(boards(&s), was);
|
||
s.execute("artboard.fitToArt", &json!({})).unwrap();
|
||
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default");
|
||
}
|
||
|
||
/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names
|
||
/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as
|
||
/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first
|
||
/// artboard.
|
||
#[test]
|
||
fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
|
||
rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||
s.execute("select.all", &json!({})).unwrap();
|
||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
|
||
let (was, n) = (boards(&s), undo_len(&s));
|
||
for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] {
|
||
for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] {
|
||
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
|
||
}
|
||
}
|
||
let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string();
|
||
assert_eq!(e, "invalid parameters for `artboard.setProps`: missing index");
|
||
assert_eq!((boards(&s), undo_len(&s)), (was.clone(), n), "the artboards and the undo steps as they were");
|
||
s.execute("artboard.fitToSelection", &json!({})).unwrap();
|
||
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 150.0, 150.0), was[1]], "the first artboard by default");
|
||
}
|
||
|
||
// ---------- Edit Colors ----------
|
||
|
||
#[test]
|
||
fn invert_and_convert_colors() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
fill(&mut s, a, "#ff0000");
|
||
sel(&mut s, &[a]);
|
||
s.execute("edit.colors.invert", &json!({"stroke": false})).unwrap();
|
||
assert_eq!(fill_of(&s, a).to_hex(), "#00ffff");
|
||
s.execute("edit.colors.toCMYK", &json!({})).unwrap();
|
||
assert!(matches!(fill_of(&s, a), Color::Cmyk { .. }));
|
||
s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
|
||
assert!(matches!(fill_of(&s, a), Color::Gray { .. }));
|
||
s.execute("edit.colors.toRGB", &json!({})).unwrap();
|
||
assert!(matches!(fill_of(&s, a), Color::Rgb { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn saturate_and_adjust_balance() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
s.execute("paint.setFill", &json!({"ids": [a.0], "color": [0.8, 0.4, 0.4]})).unwrap();
|
||
sel(&mut s, &[a]);
|
||
s.execute("edit.colors.saturate", &json!({"intensity": -100})).unwrap();
|
||
let [_, sat, _] = fill_of(&s, a).to_hsb();
|
||
assert!(sat < 1e-4);
|
||
s.execute("edit.colors.adjustBalance", &json!({"r": 10, "g": -10})).unwrap();
|
||
let [r, g, _] = fill_of(&s, a).to_rgb();
|
||
assert!((r - 0.9).abs() < 1e-3 && (g - 0.7).abs() < 1e-3, "{r} {g}");
|
||
assert!(s.execute("edit.colors.adjustBalance", &json!({})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn blend_colors_horizontally() {
|
||
let mut s = session();
|
||
let ids: Vec<NodeId> = (0..3).map(|i| rect(&mut s, 200.0 - i as f64 * 100.0, 0.0, 10.0, 10.0)).collect();
|
||
fill(&mut s, ids[2], "#000000"); // leftmost
|
||
fill(&mut s, ids[0], "#ffffff"); // rightmost
|
||
fill(&mut s, ids[1], "#ff0000");
|
||
sel(&mut s, &ids);
|
||
s.execute("edit.colors.blendHorizontally", &json!({})).unwrap();
|
||
assert_eq!(fill_of(&s, ids[1]).to_hex(), "#808080");
|
||
sel(&mut s, &ids[..2]);
|
||
assert!(s.execute("edit.colors.blendVertically", &json!({})).is_err(), "needs three");
|
||
}
|
||
|
||
#[test]
|
||
fn blend_front_to_back_uses_stacking() {
|
||
let mut s = session();
|
||
let ids: Vec<NodeId> = (0..3).map(|_| rect(&mut s, 0.0, 0.0, 10.0, 10.0)).collect();
|
||
fill(&mut s, ids[0], "#000000");
|
||
fill(&mut s, ids[2], "#ffffff");
|
||
sel(&mut s, &ids);
|
||
s.execute("edit.colors.blendFrontToBack", &json!({})).unwrap();
|
||
assert_eq!(fill_of(&s, ids[1]).to_hex(), "#808080");
|
||
}
|
||
|
||
// ---------- Paste without formatting / Find ----------
|
||
|
||
#[test]
|
||
fn paste_without_formatting_resets_text_style() {
|
||
let mut s = session();
|
||
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Bold", "size": 40})).unwrap());
|
||
sel(&mut s, &[t]);
|
||
s.execute("edit.copy", &json!({})).unwrap();
|
||
let n = undo_len(&s);
|
||
let r = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
|
||
let p = NodeId(r["ids"][0].as_u64().unwrap());
|
||
let NodeKind::Text(tt) = &node(&s, p).kind else { panic!() };
|
||
assert_eq!(tt.plain_text(), "Bold");
|
||
assert_eq!(tt.first_style().size, 12.0);
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
// Clipboard keeps its formatting.
|
||
let NodeKind::Text(ct) = &s.clipboard.nodes[0].kind else { panic!() };
|
||
assert_eq!(ct.first_style().size, 40.0);
|
||
}
|
||
|
||
#[test]
|
||
fn find_and_replace_options() {
|
||
let mut s = session();
|
||
let a = text(&mut s, "Cat cat catalog");
|
||
let b = text(&mut s, "the CAT");
|
||
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog", "wholeWord": true})).unwrap();
|
||
assert_eq!(r["count"], 3);
|
||
assert_eq!(plain(&s, a), "dog dog catalog");
|
||
assert_eq!(plain(&s, b), "the dog");
|
||
let r = s.execute("edit.findReplace", &json!({"find": "Dog", "replace": "x", "matchCase": true})).unwrap();
|
||
assert_eq!(r["count"], 0);
|
||
assert!(s.execute("edit.findReplace", &json!({"find": ""})).is_err());
|
||
}
|
||
|
||
/// Find and Replace that replaces each match with the text it is (`CAT` found in `cat`, ignoring
|
||
/// case, and replaced by `cat`) records no undo step: the step to redo stays and the document stays
|
||
/// unmodified. It still replies with the count. A replacement that changes the text is one undo step.
|
||
#[test]
|
||
fn find_and_replace_that_changes_nothing_records_no_undo_step() {
|
||
let mut s = session();
|
||
let a = text(&mut s, "cat cat");
|
||
select_with_a_step_to_redo(&mut s, &[a]);
|
||
let before = history(&s);
|
||
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
|
||
assert_eq!(s.execute("edit.findReplace", &json!({"find": "CAT", "replace": "cat"})).unwrap(), json!({"count": 2}));
|
||
assert_eq!((history(&s), plain(&s, a)), (before, "cat cat".to_string()));
|
||
assert_eq!(s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog"})).unwrap(), json!({"count": 2}));
|
||
assert_eq!((history(&s), plain(&s, a)), ((before.0 + 1, 0, true), "dog dog".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn find_next_cycles_through_text_objects() {
|
||
let mut s = session();
|
||
let a = text(&mut s, "apple");
|
||
let _ = text(&mut s, "banana");
|
||
let c = text(&mut s, "pineapple");
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "APPLE"})).unwrap()), a);
|
||
assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "apple"})).unwrap()), c);
|
||
assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "apple"})).unwrap()), a);
|
||
assert!(s.execute("edit.findNext", &json!({"find": "zzz"})).unwrap()["id"].is_null());
|
||
}
|
||
|
||
// ---------- Select ----------
|
||
|
||
#[test]
|
||
fn select_same_font_size_and_fill() {
|
||
let mut s = session();
|
||
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 20, "text": "a", "size": 20})).unwrap());
|
||
let b = id_of(&s.execute("text.create", &json!({"x": 0, "y": 60, "text": "b", "size": 20})).unwrap());
|
||
let c = id_of(&s.execute("text.create", &json!({"x": 0, "y": 90, "text": "c", "size": 30, "font": "Inter"})).unwrap());
|
||
sel(&mut s, &[a]);
|
||
assert_eq!(s.execute("select.same.fontSize", &json!({})).unwrap()["count"], 2);
|
||
assert_eq!(selected(&s), vec![a, b]);
|
||
sel(&mut s, &[c]);
|
||
assert_eq!(s.execute("select.same.fontFamily", &json!({})).unwrap()["count"], 1);
|
||
sel(&mut s, &[a]);
|
||
assert_eq!(s.execute("select.same.textFillColor", &json!({})).unwrap()["count"], 3);
|
||
assert_eq!(s.execute("select.same.fontFamilyStyle", &json!({})).unwrap()["count"], 2);
|
||
let r = rect(&mut s, 0.0, 0.0, 5.0, 5.0);
|
||
sel(&mut s, &[r]);
|
||
assert!(s.execute("select.same.fontSize", &json!({})).is_err());
|
||
}
|
||
|
||
/// #1737: Select > Same reads type's fill and stroke from its characters (the first run), so type
|
||
/// matches type and paths both ways.
|
||
#[test]
|
||
fn select_same_paint_reads_type_characters() {
|
||
let mut s = session();
|
||
let t1 = text(&mut s, "a");
|
||
let t2 = text(&mut s, "b");
|
||
let t3 = text(&mut s, "c");
|
||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let q = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[t1, t2, r]);
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
sel(&mut s, &[t3, q]);
|
||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||
sel(&mut s, &[t1, r]);
|
||
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
|
||
s.execute("stroke.set", &json!({"weight": 3})).unwrap();
|
||
sel(&mut s, &[t3]);
|
||
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
|
||
let q_stroke = node(&s, q).appearance;
|
||
assert_eq!((q_stroke.stroke_paint(), q_stroke.stroke_width()), (Paint::solid(Color::BLACK), 1.0));
|
||
|
||
let same = |s: &mut Session, from: NodeId, cmd: &str| {
|
||
sel(s, &[from]);
|
||
s.execute(cmd, &json!({})).unwrap();
|
||
let mut v = selected(s);
|
||
v.sort();
|
||
v
|
||
};
|
||
assert_eq!(same(&mut s, t1, "select.same.fillColor"), vec![t1, t2, r]);
|
||
assert_eq!(same(&mut s, q, "select.same.fillColor"), vec![t3, q]);
|
||
assert_eq!(same(&mut s, r, "select.same.strokeColor"), vec![t1, r]);
|
||
assert_eq!(same(&mut s, t3, "select.same.strokeColor"), vec![t3, q]);
|
||
assert_eq!(same(&mut s, q, "select.same.strokeWeight"), vec![t3, q]);
|
||
assert_eq!(same(&mut s, t1, "select.same.fillAndStroke"), vec![t1, r]);
|
||
}
|
||
|
||
#[test]
|
||
fn select_direction_handles_selects_all_anchors() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a]);
|
||
assert_eq!(s.execute("select.object.directionHandles", &json!({})).unwrap()["anchors"], 4);
|
||
assert_eq!(s.doc().unwrap().selection.anchors.get(&a).unwrap().len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn direction_handles_leave_out_hidden_and_locked_paths() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let hidden = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
let locked = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a, hidden, locked]);
|
||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
s.execute("object.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap();
|
||
s.execute("object.setProps", &json!({"ids": [locked.0], "locked": true})).unwrap();
|
||
sel(&mut s, &[g]);
|
||
assert_eq!(s.execute("select.object.directionHandles", &json!({})).unwrap()["anchors"], 4);
|
||
assert_eq!(selected(&s), vec![a]);
|
||
}
|
||
|
||
#[test]
|
||
fn select_point_and_area_text() {
|
||
let mut s = session();
|
||
let p = text(&mut s, "point");
|
||
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "area", "area": {"width": 100, "height": 50}})).unwrap());
|
||
let _ = rect(&mut s, 0.0, 0.0, 5.0, 5.0);
|
||
assert_eq!(s.execute("select.object.pointText", &json!({})).unwrap()["count"], 1);
|
||
assert_eq!(selected(&s), vec![p]);
|
||
s.execute("select.object.areaText", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![a]);
|
||
assert_eq!(s.execute("select.object.brushStrokes", &json!({})).unwrap()["count"], 0);
|
||
}
|
||
|
||
/// Select › Object and Select › Same leave out hidden and locked objects, and the objects in hidden
|
||
/// or locked groups and sublayers, as Select All does.
|
||
#[test]
|
||
fn select_menu_commands_leave_out_hidden_and_locked_art() {
|
||
let mut s = session();
|
||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||
let shown = text(&mut s, "shown");
|
||
let hidden = text(&mut s, "hidden");
|
||
let locked = text(&mut s, "locked");
|
||
let in_hidden_group = text(&mut s, "in a hidden group");
|
||
let in_locked_group = text(&mut s, "in a locked group");
|
||
let open = id_of(&s.execute("shape.line", &json!({"x1": 0, "y1": 0, "x2": 10, "y2": 10})).unwrap());
|
||
let hidden_open = id_of(&s.execute("shape.line", &json!({"x1": 0, "y1": 20, "x2": 10, "y2": 30})).unwrap());
|
||
sel(&mut s, &[in_hidden_group]);
|
||
let hidden_group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
sel(&mut s, &[in_locked_group]);
|
||
let locked_group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
let hidden_sub = id_of(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap());
|
||
let in_hidden_sub = text(&mut s, "in a hidden sublayer");
|
||
let locked_sub = id_of(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap());
|
||
let in_locked_sub = text(&mut s, "in a locked sublayer");
|
||
assert_eq!(s.doc().unwrap().doc.parent_of(in_hidden_sub), Some(hidden_sub));
|
||
assert_eq!(s.doc().unwrap().doc.parent_of(in_locked_sub), Some(locked_sub));
|
||
s.execute("object.setProps", &json!({"ids": [hidden.0, hidden_open.0, hidden_group.0], "visible": false})).unwrap();
|
||
s.execute("object.setProps", &json!({"ids": [locked.0, locked_group.0], "locked": true})).unwrap();
|
||
s.execute("layer.setProps", &json!({"ids": [hidden_sub.0], "visible": false})).unwrap();
|
||
s.execute("layer.setProps", &json!({"ids": [locked_sub.0], "locked": true})).unwrap();
|
||
assert_eq!(s.execute("select.object.textObjects", &json!({})).unwrap()["count"], 1);
|
||
assert_eq!(selected(&s), vec![shown]);
|
||
assert_eq!(s.execute("select.object.pointText", &json!({})).unwrap()["count"], 1);
|
||
assert_eq!(selected(&s), vec![shown]);
|
||
assert_eq!(s.execute("select.same.fontSize", &json!({})).unwrap()["count"], 1);
|
||
assert_eq!(selected(&s), vec![shown]);
|
||
s.execute("select.object.openPaths", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![open]);
|
||
}
|
||
|
||
/// Next Object Above and Below pass over the hidden and locked objects and the sublayers beside the
|
||
/// selected object.
|
||
#[test]
|
||
fn next_object_above_and_below_pass_over_what_cant_be_selected() {
|
||
let mut s = session();
|
||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let hidden = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
let locked = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||
let sub = id_of(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap());
|
||
s.execute("layer.setCurrent", &json!({"id": layer.0})).unwrap();
|
||
let b = rect(&mut s, 60.0, 0.0, 10.0, 10.0);
|
||
let order: Vec<NodeId> = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
|
||
assert_eq!(order, vec![a, hidden, locked, sub, b]);
|
||
s.execute("object.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap();
|
||
s.execute("object.setProps", &json!({"ids": [locked.0], "locked": true})).unwrap();
|
||
sel(&mut s, &[a]);
|
||
s.execute("select.nextAbove", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![b]);
|
||
s.execute("select.nextBelow", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![a]);
|
||
// Nothing that can be selected below it: the selection stays.
|
||
s.execute("select.nextBelow", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![a]);
|
||
}
|
||
|
||
/// In isolation mode Select › Same and Select › Object select only objects in the isolated group.
|
||
#[test]
|
||
fn select_menu_commands_stay_inside_the_isolated_group() {
|
||
let mut s = session();
|
||
let outside = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let _text_outside = text(&mut s, "outside");
|
||
let a = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||
let text_inside = text(&mut s, "inside");
|
||
for id in [outside, a, b] {
|
||
fill(&mut s, id, "#ff0000");
|
||
}
|
||
sel(&mut s, &[a, b, text_inside]);
|
||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||
sel(&mut s, &[a]);
|
||
s.execute("select.same.fillColor", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![a, b]);
|
||
s.execute("select.object.textObjects", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![text_inside]);
|
||
s.execute("select.object.pointText", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![text_inside]);
|
||
s.execute("select.same.fontSize", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![text_inside]);
|
||
// All on Same Layers takes the objects of the isolated group.
|
||
sel(&mut s, &[a]);
|
||
s.execute("select.object.allOnSameLayers", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![a, b, text_inside]);
|
||
}
|
||
|
||
/// In isolation mode the Layers panel's target circle and selection column can't select an object
|
||
/// outside the isolated group, so it can't be moved either (#2286).
|
||
#[test]
|
||
fn layers_panel_selects_nothing_outside_the_isolated_group() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
|
||
let b = rect(&mut s, 100.0, 0.0, 50.0, 50.0);
|
||
let outside = rect(&mut s, 300.0, 0.0, 50.0, 50.0);
|
||
sel(&mut s, &[a, b]);
|
||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||
let before = bounds(&s, outside);
|
||
let _ = s.execute("layer.target", &json!({"id": outside.0}));
|
||
assert!(!selected(&s).contains(&outside));
|
||
let _ = s.execute("layer.target", &json!({"id": outside.0, "add": true}));
|
||
assert!(!selected(&s).contains(&outside));
|
||
let _ = s.execute("layer.selectAll", &json!({"id": outside.0}));
|
||
assert!(!selected(&s).contains(&outside));
|
||
let _ = s.execute("layer.selectAll", &json!({"id": outside.0, "add": true}));
|
||
assert!(!selected(&s).contains(&outside));
|
||
let _ = s.execute("object.move", &json!({"dx": 10, "dy": 0}));
|
||
assert_eq!(bounds(&s, outside), before);
|
||
// Objects inside the group can still be targeted and selected.
|
||
s.execute("layer.target", &json!({"id": a.0})).unwrap();
|
||
assert_eq!(selected(&s), vec![a]);
|
||
s.execute("layer.selectAll", &json!({"id": b.0})).unwrap();
|
||
assert_eq!(selected(&s), vec![b]);
|
||
}
|
||
|
||
/// In isolation mode a recalled selection leaves out the objects outside the isolated group, and
|
||
/// Next Object Above selects nothing outside it.
|
||
#[test]
|
||
fn recalled_selections_and_next_object_stay_inside_the_isolated_group() {
|
||
let mut s = session();
|
||
let outside = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let _above_outside = rect(&mut s, 0.0, 20.0, 10.0, 10.0);
|
||
let a = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[outside, a]);
|
||
s.execute("select.save", &json!({"name": "Both"})).unwrap();
|
||
sel(&mut s, &[a, b]);
|
||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||
s.execute("select.recall", &json!({"name": "Both"})).unwrap();
|
||
assert_eq!(selected(&s), vec![a]);
|
||
// An object outside the group (selected by id) has no neighbor in it: the selection stays.
|
||
sel(&mut s, &[outside]);
|
||
s.execute("select.nextAbove", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![outside]);
|
||
}
|
||
|
||
/// In isolation mode Open Paths and All on Same Layers select nothing when the isolated group, or
|
||
/// the layer it is on, is hidden or locked.
|
||
#[test]
|
||
fn select_menu_commands_leave_out_an_isolated_group_hidden_or_locked_since() {
|
||
let mut s = session();
|
||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||
let a = id_of(&s.execute("shape.line", &json!({"x1": 0, "y1": 0, "x2": 10, "y2": 10})).unwrap());
|
||
let b = id_of(&s.execute("shape.line", &json!({"x1": 0, "y1": 20, "x2": 10, "y2": 30})).unwrap());
|
||
sel(&mut s, &[a, b]);
|
||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||
s.execute("select.object.openPaths", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![a, b]);
|
||
s.execute("layer.setProps", &json!({"ids": [layer.0], "visible": false})).unwrap();
|
||
s.execute("select.object.openPaths", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![]);
|
||
sel(&mut s, &[a]);
|
||
s.execute("select.object.allOnSameLayers", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![]);
|
||
s.execute("layer.setProps", &json!({"ids": [layer.0], "visible": true})).unwrap();
|
||
s.execute("object.setProps", &json!({"ids": [g.0], "locked": true})).unwrap();
|
||
s.execute("select.object.openPaths", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![]);
|
||
}
|
||
|
||
/// The selection after command `c` runs with nothing selected.
|
||
fn select_from_none(s: &mut Session, c: &str) -> Vec<NodeId> {
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
s.execute(c, &json!({})).unwrap();
|
||
selected(s)
|
||
}
|
||
|
||
/// In isolation mode Select All, All on Active Artboard and Inverse select nothing while the
|
||
/// isolated group is hidden or locked, or the layer it is on is hidden or a template.
|
||
#[test]
|
||
fn select_all_leaves_out_an_isolated_group_hidden_or_locked_since() {
|
||
let mut s = session();
|
||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a, b]);
|
||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||
let commands = ["select.all", "select.allOnArtboard", "select.inverse"];
|
||
for c in commands {
|
||
assert_eq!(select_from_none(&mut s, c), vec![a, b], "{c}");
|
||
}
|
||
let changes = [
|
||
("object.setProps", json!({"ids": [g.0], "visible": false}), json!({"ids": [g.0], "visible": true})),
|
||
("object.setProps", json!({"ids": [g.0], "locked": true}), json!({"ids": [g.0], "locked": false})),
|
||
("layer.setProps", json!({"ids": [layer.0], "visible": false}), json!({"ids": [layer.0], "visible": true})),
|
||
// A template layer that has been unlocked again.
|
||
("layer.setProps", json!({"ids": [layer.0], "template": true, "locked": false}), json!({"ids": [layer.0], "template": false})),
|
||
];
|
||
for (cmd, on, off) in changes {
|
||
s.execute(cmd, &on).unwrap();
|
||
for c in commands {
|
||
assert_eq!(select_from_none(&mut s, c), vec![], "{c} after {cmd} {on}");
|
||
}
|
||
s.execute(cmd, &off).unwrap();
|
||
for c in commands {
|
||
assert_eq!(select_from_none(&mut s, c), vec![a, b], "{c} after {cmd} {off}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Pattern editing and opacity mask editing isolate a new layer: Select All, All on Active
|
||
/// Artboard and Inverse select the art on it.
|
||
#[test]
|
||
fn select_all_in_pattern_and_mask_editing_selects_the_art_being_edited() {
|
||
let mut s = session();
|
||
let commands = ["select.all", "select.allOnArtboard", "select.inverse"];
|
||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[r]);
|
||
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
|
||
let tile = selected(&s);
|
||
assert_eq!(tile.len(), 1);
|
||
for c in commands {
|
||
assert_eq!(select_from_none(&mut s, c), tile, "{c} in pattern editing");
|
||
}
|
||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||
let obj = rect(&mut s, 0.0, 0.0, 200.0, 200.0);
|
||
let m = rect(&mut s, 0.0, 0.0, 100.0, 200.0);
|
||
sel(&mut s, &[obj, m]);
|
||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||
s.execute("transparency.editOpacityMask", &json!({})).unwrap();
|
||
let mask = selected(&s);
|
||
assert_eq!(mask.len(), 1);
|
||
for c in commands {
|
||
assert_eq!(select_from_none(&mut s, c), mask, "{c} in opacity mask editing");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn save_and_recall_selection() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a, b]);
|
||
assert_eq!(s.execute("select.save", &json!({})).unwrap()["name"], "Selection 1");
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
assert_eq!(s.execute("select.recall", &json!({"name": "Selection 1"})).unwrap()["count"], 2);
|
||
s.execute("select.editSaved", &json!({"name": "Selection 1", "newName": "Pair"})).unwrap();
|
||
assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["Pair"]));
|
||
s.execute("select.editSaved", &json!({"name": "Pair", "delete": true})).unwrap();
|
||
assert!(s.execute("select.recall", &json!({"name": "Pair"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn update_selection_replaces_the_recalled_saved_selection() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a, b]);
|
||
s.execute("select.save", &json!({"name": "A"})).unwrap();
|
||
s.execute("select.save", &json!({"name": "Other"})).unwrap();
|
||
// Nothing recalled yet: Update Selection is off.
|
||
assert!(s.execute("select.update", &json!({})).is_err());
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
s.execute("select.recall", &json!({"name": "A"})).unwrap();
|
||
s.execute("select.add", &json!({"ids": [c.0]})).unwrap();
|
||
s.doc_mut().unwrap().mark_saved();
|
||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||
assert!(s.doc().unwrap().is_dirty());
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
assert_eq!(s.execute("select.recall", &json!({"name": "A"})).unwrap()["count"], 3);
|
||
assert_eq!(selected(&s), vec![a, b, c]);
|
||
// Only the recalled one changes.
|
||
s.execute("select.recall", &json!({"name": "Other"})).unwrap();
|
||
assert_eq!(selected(&s), vec![a, b]);
|
||
// It travels with the file.
|
||
let loaded = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
|
||
assert_eq!(loaded.saved_selections.iter().map(|x| x.objects.len()).collect::<Vec<_>>(), [3, 2]);
|
||
// It is one undo step.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("select.recall", &json!({"name": "A"})).unwrap();
|
||
assert_eq!(selected(&s), vec![a, b]);
|
||
// Nothing selected: Update Selection is off.
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
assert!(s.execute("select.update", &json!({})).is_err());
|
||
// It can run again without another recall.
|
||
sel(&mut s, &[c]);
|
||
s.execute("select.update", &json!({})).unwrap();
|
||
sel(&mut s, &[b]);
|
||
s.execute("select.update", &json!({})).unwrap();
|
||
s.execute("select.recall", &json!({"name": "A"})).unwrap();
|
||
assert_eq!(selected(&s), vec![b]);
|
||
}
|
||
|
||
#[test]
|
||
fn update_selection_follows_a_rename_and_stops_after_a_delete() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a]);
|
||
s.execute("select.save", &json!({"name": "One"})).unwrap();
|
||
s.execute("select.recall", &json!({"name": "One"})).unwrap();
|
||
s.execute("select.editSaved", &json!({"name": "One", "newName": "Uno"})).unwrap();
|
||
sel(&mut s, &[a, b]);
|
||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "Uno");
|
||
s.execute("select.recall", &json!({"name": "Uno"})).unwrap();
|
||
assert_eq!(selected(&s), vec![a, b]);
|
||
// A saved selection of the same name saved after the delete is not the recalled one.
|
||
s.execute("select.editSaved", &json!({"name": "Uno", "delete": true})).unwrap();
|
||
s.execute("select.save", &json!({"name": "Uno"})).unwrap();
|
||
assert!(s.execute("select.update", &json!({})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn update_selection_changes_the_recalled_saved_selection_after_undo_and_redo() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||
let recall = |s: &mut Session, name: &str| {
|
||
s.execute("select.recall", &json!({"name": name})).unwrap();
|
||
selected(s)
|
||
};
|
||
sel(&mut s, &[a]);
|
||
s.execute("select.save", &json!({"name": "A"})).unwrap();
|
||
sel(&mut s, &[b]);
|
||
s.execute("select.save", &json!({"name": "B"})).unwrap();
|
||
recall(&mut s, "A");
|
||
// Swap the two names, then undo the swap: Update Selection still changes the one recalled.
|
||
let swap = json!({"edits": [{"name": "A", "newName": "B"}, {"name": "B", "newName": "A"}]});
|
||
s.execute("select.editSaved", &swap).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
sel(&mut s, &[a, c]);
|
||
s.execute("select.update", &json!({})).unwrap();
|
||
assert_eq!((recall(&mut s, "A"), recall(&mut s, "B")), (vec![a, c], vec![b]));
|
||
// Undo and redo a rename of the recalled one.
|
||
recall(&mut s, "A");
|
||
s.execute("select.editSaved", &json!({"name": "A", "newName": "First"})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
sel(&mut s, &[c]);
|
||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "First");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
sel(&mut s, &[c]);
|
||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||
assert_eq!(recall(&mut s, "A"), vec![c]);
|
||
// Undo and redo an Edit Selection… that renamed it and deleted the other one.
|
||
recall(&mut s, "A");
|
||
let edits = json!({"edits": [{"name": "A", "newName": "First"}, {"name": "B", "delete": true}]});
|
||
s.execute("select.editSaved", &edits).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
sel(&mut s, &[a]);
|
||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "First");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
sel(&mut s, &[a]);
|
||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||
assert_eq!((recall(&mut s, "A"), recall(&mut s, "B")), (vec![a], vec![b]));
|
||
// A cancelled undo group that renamed it.
|
||
recall(&mut s, "B");
|
||
s.execute("edit.undoGroup.begin", &json!({})).unwrap();
|
||
s.execute("select.editSaved", &json!({"name": "B", "newName": "Second"})).unwrap();
|
||
s.execute("edit.undoGroup.end", &json!({"cancel": true})).unwrap();
|
||
sel(&mut s, &[a, b]);
|
||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "B");
|
||
assert_eq!(recall(&mut s, "B"), vec![a, b]);
|
||
// Undo an Update Selection that gave it the other one's objects.
|
||
recall(&mut s, "A");
|
||
sel(&mut s, &[a, b]);
|
||
s.execute("select.update", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
sel(&mut s, &[c]);
|
||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||
assert_eq!((recall(&mut s, "A"), recall(&mut s, "B")), (vec![c], vec![a, b]));
|
||
// Save Selection made after the recall, and undone.
|
||
recall(&mut s, "A");
|
||
s.execute("select.save", &json!({"name": "C"})).unwrap();
|
||
sel(&mut s, &[b]);
|
||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
sel(&mut s, &[a]);
|
||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||
}
|
||
|
||
#[test]
|
||
fn edit_selection_needs_a_saved_selection_and_a_free_name() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a]);
|
||
// Edit Selection… is off until something is saved.
|
||
assert!(s.execute("select.editSaved", &json!({"name": "Selection 1", "newName": "A"})).is_err());
|
||
s.execute("select.save", &json!({"name": "One"})).unwrap();
|
||
s.execute("select.save", &json!({"name": "Two"})).unwrap();
|
||
// A name already saved can't be taken; both selections keep theirs.
|
||
assert!(s.execute("select.editSaved", &json!({"name": "One", "newName": " Two "})).is_err());
|
||
assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["One", "Two"]));
|
||
// Renaming to its own name, or a blank name, changes nothing; a free name renames.
|
||
s.execute("select.editSaved", &json!({"name": "One", "newName": "One"})).unwrap();
|
||
s.execute("select.editSaved", &json!({"name": "One", "newName": " "})).unwrap();
|
||
s.execute("select.editSaved", &json!({"name": "One", "newName": " Uno "})).unwrap();
|
||
assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["Uno", "Two"]));
|
||
}
|
||
|
||
#[test]
|
||
fn saved_selections_live_in_the_document() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
let list = |s: &mut Session| s.execute("select.savedList", &json!({})).unwrap();
|
||
sel(&mut s, &[a]);
|
||
s.execute("select.save", &json!({"name": "A"})).unwrap();
|
||
sel(&mut s, &[a, b]);
|
||
s.execute("select.save", &json!({"name": "Both"})).unwrap();
|
||
// They travel with the file.
|
||
let loaded = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
|
||
assert_eq!(loaded.saved_selections.iter().map(|x| x.name.as_str()).collect::<Vec<_>>(), ["A", "Both"]);
|
||
// Saving is an undo step.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(list(&mut s), json!(["A"]));
|
||
// An existing name is replaced by the current selection.
|
||
sel(&mut s, &[b]);
|
||
s.execute("select.save", &json!({"name": "A"})).unwrap();
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
assert_eq!(s.execute("select.recall", &json!({"name": "A"})).unwrap()["count"], 1);
|
||
assert_eq!(selected(&s), vec![b]);
|
||
// Objects deleted since are left out of a recall.
|
||
sel(&mut s, &[a, b]);
|
||
s.execute("select.save", &json!({"name": "Pair"})).unwrap();
|
||
sel(&mut s, &[a]);
|
||
s.execute("edit.clear", &json!({})).unwrap();
|
||
assert_eq!(s.execute("select.recall", &json!({"name": "Pair"})).unwrap()["count"], 1);
|
||
}
|
||
|
||
/// A recall leaves out the objects hidden or locked since the save, themselves or through their
|
||
/// group or sublayer.
|
||
#[test]
|
||
fn recalled_selections_leave_out_objects_hidden_or_locked_since() {
|
||
let mut s = session();
|
||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let hidden = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||
let locked = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||
let in_group = rect(&mut s, 60.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[in_group]);
|
||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
let sub = id_of(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap());
|
||
let in_sub = rect(&mut s, 80.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a, hidden, locked, in_group, in_sub]);
|
||
s.execute("select.save", &json!({"name": "All"})).unwrap();
|
||
s.execute("object.setProps", &json!({"ids": [hidden.0, g.0], "visible": false})).unwrap();
|
||
s.execute("object.setProps", &json!({"ids": [locked.0], "locked": true})).unwrap();
|
||
s.execute("layer.setProps", &json!({"ids": [sub.0], "locked": true})).unwrap();
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
assert_eq!(s.execute("select.recall", &json!({"name": "All"})).unwrap()["count"], 1);
|
||
assert_eq!(selected(&s), vec![a]);
|
||
}
|
||
|
||
#[test]
|
||
fn edit_selection_applies_several_edits_as_one_step() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a]);
|
||
for n in ["A", "B", "C"] {
|
||
s.execute("select.save", &json!({ "name": n })).unwrap();
|
||
}
|
||
let list = |s: &mut Session| s.execute("select.savedList", &json!({})).unwrap();
|
||
// Edits name the selections as they are, so two can swap names; a delete goes with them.
|
||
s.execute("select.editSaved", &json!({"edits": [{"name": "A", "newName": "B"}, {"name": "B", "newName": "A"}, {"name": "C", "delete": true}]}))
|
||
.unwrap();
|
||
assert_eq!(list(&mut s), json!(["B", "A"]));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(list(&mut s), json!(["A", "B", "C"]));
|
||
// Two ending with one name, or an unknown name, change nothing.
|
||
assert!(s.execute("select.editSaved", &json!({"edits": [{"name": "A", "newName": "B"}]})).is_err());
|
||
assert!(s.execute("select.editSaved", &json!({"edits": [{"name": "Z", "delete": true}]})).is_err());
|
||
assert_eq!(list(&mut s), json!(["A", "B", "C"]));
|
||
}
|
||
|
||
#[test]
|
||
fn a_document_keeps_at_most_25_saved_selections() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a]);
|
||
for i in 0..25 {
|
||
s.execute("select.save", &json!({ "name": format!("S{i}") })).unwrap();
|
||
}
|
||
assert!(s.execute("select.save", &json!({"name": "One too many"})).is_err());
|
||
// A name already saved is replaced even when the list is full.
|
||
s.execute("select.save", &json!({"name": "S3"})).unwrap();
|
||
// Unnamed saves take the first free "Selection N".
|
||
s.execute("select.editSaved", &json!({"name": "S0", "delete": true})).unwrap();
|
||
assert_eq!(s.execute("select.save", &json!({})).unwrap()["name"], "Selection 1");
|
||
}
|
||
|
||
// ---------- Type ----------
|
||
|
||
#[test]
|
||
fn change_case_variants() {
|
||
let mut s = session();
|
||
let t = text(&mut s, "hello wORLD. again here");
|
||
sel(&mut s, &[t]);
|
||
s.execute("type.changeCase", &json!({"case": "upper"})).unwrap();
|
||
assert_eq!(plain(&s, t), "HELLO WORLD. AGAIN HERE");
|
||
s.execute("type.changeCase", &json!({"case": "lower"})).unwrap();
|
||
assert_eq!(plain(&s, t), "hello world. again here");
|
||
s.execute("type.changeCase", &json!({"case": "title"})).unwrap();
|
||
assert_eq!(plain(&s, t), "Hello World. Again Here");
|
||
s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap();
|
||
assert_eq!(plain(&s, t), "Hello world. Again here");
|
||
assert!(s.execute("type.changeCase", &json!({"case": "weird"})).is_err());
|
||
}
|
||
|
||
/// Change Case is one undo step, and Change Case to the case the text has already records no undo
|
||
/// step: the step to redo stays and the document stays unmodified. It still replies with the
|
||
/// objects.
|
||
#[test]
|
||
fn change_case_to_the_case_the_text_has_records_no_undo_step() {
|
||
let mut s = session();
|
||
let t = text(&mut s, "hello wORLD. again here");
|
||
for case in ["upper", "lower", "title", "sentence"] {
|
||
sel(&mut s, &[t]);
|
||
let undo = history(&s).0;
|
||
s.execute("type.changeCase", &json!({"case": case})).unwrap();
|
||
assert_eq!(history(&s), (undo + 1, 0, true), "{case}");
|
||
select_with_a_step_to_redo(&mut s, &[t]);
|
||
let before = (history(&s), plain(&s, t));
|
||
assert_eq!(s.execute("type.changeCase", &json!({"case": case})).unwrap(), json!({"ids": [t.0]}), "{case} again");
|
||
assert_eq!((history(&s), plain(&s, t)), before, "{case} again");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn smart_punctuation_quotes_dashes_ellipsis() {
|
||
let mut s = session();
|
||
let t = text(&mut s, "\"Hi\" it's 1--2 and 3---4...");
|
||
sel(&mut s, &[t]);
|
||
assert_eq!(s.execute("type.smartPunctuation", &json!({})).unwrap()["changed"], 1);
|
||
assert_eq!(plain(&s, t), "“Hi” it’s 1–2 and 3—4…");
|
||
}
|
||
|
||
/// Smart Punctuation on type with nothing left to change, or on a selection with no type in it,
|
||
/// records no undo step and replies `changed: 0`.
|
||
#[test]
|
||
fn smart_punctuation_with_nothing_to_change_records_no_undo_step() {
|
||
let mut s = session();
|
||
let t = text(&mut s, "\"Hi\" it's 1--2 and 3---4...");
|
||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[t]);
|
||
assert_eq!(s.execute("type.smartPunctuation", &json!({})).unwrap()["changed"], 1);
|
||
for id in [t, r] {
|
||
select_with_a_step_to_redo(&mut s, &[id]);
|
||
let before = history(&s);
|
||
assert_eq!(s.execute("type.smartPunctuation", &json!({})).unwrap(), json!({"changed": 0}), "{id:?}");
|
||
assert_eq!(history(&s), before, "{id:?}");
|
||
}
|
||
assert_eq!(plain(&s, t), "“Hi” it’s 1–2 and 3—4…");
|
||
}
|
||
|
||
#[test]
|
||
fn convert_point_area_roundtrip() {
|
||
let mut s = session();
|
||
let t = text(&mut s, "Some words");
|
||
sel(&mut s, &[t]);
|
||
s.execute("type.convertToAreaType", &json!({})).unwrap();
|
||
let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() };
|
||
assert!(matches!(tt.kind, TextKind::Area { .. }));
|
||
assert_eq!(tt.plain_text(), "Some words");
|
||
s.execute("type.convertToPointType", &json!({})).unwrap();
|
||
let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() };
|
||
assert!(matches!(tt.kind, TextKind::Point));
|
||
assert_eq!(tt.plain_text(), "Some words");
|
||
}
|
||
|
||
/// Convert To Area Type with no point type selected, and Convert To Point Type with no area type
|
||
/// selected, record no undo step and reply with the objects. With point type and area type
|
||
/// selected, Convert To Area Type converts the point type in one undo step, and Convert To Point
|
||
/// Type then converts both in another.
|
||
#[test]
|
||
fn converting_type_to_the_kind_it_is_records_no_undo_step() {
|
||
let mut s = session();
|
||
let p = text(&mut s, "Point");
|
||
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 100, "text": "Area", "area": {"width": 100, "height": 50}})).unwrap());
|
||
for (c, id) in [("type.convertToAreaType", a), ("type.convertToPointType", p)] {
|
||
select_with_a_step_to_redo(&mut s, &[id]);
|
||
let before = history(&s);
|
||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"ids": [id.0]}), "{c}");
|
||
assert_eq!(history(&s), before, "{c}");
|
||
}
|
||
for c in ["type.convertToAreaType", "type.convertToPointType"] {
|
||
select_with_a_step_to_redo(&mut s, &[p, a]);
|
||
let undo = history(&s).0;
|
||
s.execute(c, &json!({})).unwrap();
|
||
assert_eq!(history(&s), (undo + 1, 0, true), "{c}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn area_to_point_turns_wraps_into_breaks() {
|
||
let mut s = session();
|
||
let a = id_of(
|
||
&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "one two three four five six", "area": {"width": 40, "height": 200}})).unwrap(),
|
||
);
|
||
sel(&mut s, &[a]);
|
||
s.execute("type.convertToPointType", &json!({})).unwrap();
|
||
let txt = plain(&s, a);
|
||
assert!(txt.contains('\n'), "{txt:?}");
|
||
assert_eq!(txt.replace('\n', " "), "one two three four five six");
|
||
}
|
||
|
||
#[test]
|
||
fn placeholder_and_insert_characters() {
|
||
let mut s = session();
|
||
let t = text(&mut s, "x");
|
||
sel(&mut s, &[t]);
|
||
s.execute("type.fillPlaceholder", &json!({})).unwrap();
|
||
assert!(plain(&s, t).len() > 10);
|
||
s.execute("text.setText", &json!({"text": "A"})).unwrap();
|
||
s.execute("type.insert", &json!({"char": "emDash"})).unwrap();
|
||
s.execute("type.insert", &json!({"char": "copyright"})).unwrap();
|
||
s.execute("type.insert", &json!({"text": "z"})).unwrap();
|
||
assert_eq!(plain(&s, t), "A—©z");
|
||
assert!(s.execute("type.insert", &json!({"char": "nope"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn area_placeholder_fills_frame() {
|
||
let mut s = session();
|
||
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "", "area": {"width": 300, "height": 300}})).unwrap());
|
||
sel(&mut s, &[a]);
|
||
s.execute("type.fillPlaceholder", &json!({})).unwrap();
|
||
assert!(plain(&s, a).len() > 400);
|
||
}
|
||
|
||
/// Fill with Placeholder Text on type that holds its placeholder text already (point type, and area
|
||
/// type filled to its frame), and `type.insert` with an empty `text`, record no undo step and reply
|
||
/// with the objects. Inserting text is one undo step.
|
||
#[test]
|
||
fn placeholder_and_insert_that_change_nothing_record_no_undo_step() {
|
||
let mut s = session();
|
||
let t = text(&mut s, "x");
|
||
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 100, "text": "", "area": {"width": 300, "height": 300}})).unwrap());
|
||
sel(&mut s, &[t, a]);
|
||
s.execute("type.fillPlaceholder", &json!({})).unwrap();
|
||
select_with_a_step_to_redo(&mut s, &[t, a]);
|
||
let before = (history(&s), plain(&s, t), plain(&s, a));
|
||
for (c, p) in [("type.fillPlaceholder", json!({})), ("type.insert", json!({"text": ""}))] {
|
||
assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [t.0, a.0]}), "{c}");
|
||
assert_eq!((history(&s), plain(&s, t), plain(&s, a)), before, "{c}");
|
||
}
|
||
s.execute("type.insert", &json!({"text": "z"})).unwrap();
|
||
assert_eq!(history(&s), (before.0.0 + 1, 0, true));
|
||
}
|
||
|
||
#[test]
|
||
fn font_and_size_menu_items_use_set_style() {
|
||
let mut s = session();
|
||
let t = text(&mut s, "x");
|
||
sel(&mut s, &[t]);
|
||
s.execute("text.setStyle", &json!({"size": 36})).unwrap();
|
||
s.execute("text.setStyle", &json!({"font": "Inter"})).unwrap();
|
||
let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() };
|
||
assert_eq!(tt.first_style().size, 36.0);
|
||
assert_eq!(tt.first_style().font_family, "Inter");
|
||
}
|
||
|
||
// ---------- View → Guides ----------
|
||
|
||
#[test]
|
||
fn make_release_clear_guides() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a]);
|
||
assert_eq!(s.execute("view.guides.make", &json!({})).unwrap()["count"], 1);
|
||
assert!(matches!(node(&s, a).kind, NodeKind::Path { guide: true, .. }));
|
||
assert!(selected(&s).is_empty());
|
||
assert_eq!(s.execute("view.guides.release", &json!({})).unwrap()["count"], 1);
|
||
assert!(matches!(node(&s, a).kind, NodeKind::Path { guide: false, .. }));
|
||
sel(&mut s, &[a]);
|
||
s.execute("view.guides.make", &json!({})).unwrap();
|
||
s.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap();
|
||
let n = undo_len(&s);
|
||
assert_eq!(s.execute("view.guides.clear", &json!({})).unwrap()["count"], 2);
|
||
assert!(s.doc().unwrap().doc.guides.is_empty());
|
||
assert!(s.doc().unwrap().doc.node(a).is_none());
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
}
|
||
|
||
/// The anchor positions of `id`, which must be a path of one open subpath, and whether it is still a guide.
|
||
fn line_ends(s: &Session, id: NodeId) -> (Vec<(f64, f64)>, bool) {
|
||
let NodeKind::Path { path, guide, .. } = &node(s, id).kind else { panic!("not a path") };
|
||
let [sub] = &path.subpaths[..] else { panic!("not one subpath") };
|
||
assert!(!sub.closed);
|
||
(sub.anchors.iter().map(|a| (a.p.x, a.p.y)).collect(), *guide)
|
||
}
|
||
|
||
/// Release Guides turns ruler guides into paths with no fill or stroke, each in one undo step. An
|
||
/// artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the
|
||
/// bounds of all the artboards.
|
||
#[test]
|
||
fn release_guides_turns_ruler_guides_into_paths() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
let g = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[g]);
|
||
s.execute("view.guides.make", &json!({})).unwrap();
|
||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||
s.execute("guide.add", &json!({"vertical": false, "pos": 50, "artboard": 0})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
let (ab, all) = (d.artboards[0].rect, d.artboards[0].rect.union(d.artboards[1].rect));
|
||
assert!(all.x1 > ab.x1);
|
||
let layer = s.doc().unwrap().current_layer();
|
||
// The selected artboard guide goes, and a selected path across its artboard takes its place.
|
||
s.execute("guide.select", &json!({"indexes": [1]})).unwrap();
|
||
let n = undo_len(&s);
|
||
assert_eq!(s.execute("view.guides.release", &json!({})).unwrap()["count"], 1);
|
||
assert_eq!((guides(&s), undo_len(&s)), (vec![(false, 300.0)], n + 1));
|
||
let [line] = selected(&s)[..] else { panic!("one path selected") };
|
||
assert_eq!(line_ends(&s, line), (vec![(ab.x0, 50.0), (ab.x1, 50.0)], false));
|
||
assert_eq!(node(&s, line).appearance, Appearance::basic(Paint::None, Paint::None, 0.0));
|
||
assert_eq!(s.doc().unwrap().doc.parent_of(line), layer);
|
||
assert!(matches!(node(&s, g).kind, NodeKind::Path { guide: true, .. }), "the guide path stays a guide");
|
||
// Undo brings the guide back.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(guides(&s), vec![(false, 300.0), (false, 50.0)]);
|
||
assert!(s.doc().unwrap().doc.node(line).is_none());
|
||
// index releases the canvas guide as a line across the artboards.
|
||
assert_eq!(s.execute("view.guides.release", &json!({"index": 0})).unwrap()["count"], 1);
|
||
assert_eq!(guides(&s), vec![(false, 50.0)]);
|
||
let [line] = selected(&s)[..] else { panic!("one path selected") };
|
||
assert_eq!(line_ends(&s, line), (vec![(all.x0, 300.0), (all.x1, 300.0)], false));
|
||
// ids releases those guide paths.
|
||
assert_eq!(s.execute("view.guides.release", &json!({"ids": [g.0]})).unwrap()["count"], 1);
|
||
assert!(matches!(node(&s, g).kind, NodeKind::Path { guide: false, .. }));
|
||
assert_eq!(selected(&s), vec![g]);
|
||
// Locked guides are not released by index or ids.
|
||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||
for p in [json!({"index": 0}), json!({"ids": [g.0]})] {
|
||
assert!(matches!(s.execute("view.guides.release", &p), Err(EngineError::Other(m)) if m == "guides are locked"), "{p}");
|
||
}
|
||
assert_eq!(guides(&s).len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn ruler_guides_and_lock() {
|
||
let mut s = session();
|
||
assert_eq!(s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"], 0);
|
||
s.execute("guide.move", &json!({"index": 0, "pos": 70})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 70.0);
|
||
assert_eq!(s.execute("view.guides.lock", &json!({})).unwrap()["locked"], true);
|
||
assert!(s.guides_locked());
|
||
assert!(matches!(s.execute("guide.remove", &json!({"index": 0})), Err(EngineError::Disabled(..))));
|
||
s.execute("view.guides.lock", &json!({})).unwrap();
|
||
s.execute("guide.remove", &json!({"index": 0})).unwrap();
|
||
assert!(s.doc().unwrap().doc.guides.is_empty());
|
||
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
|
||
}
|
||
|
||
/// #1534: Lock Guides locks the paths made into guides too: they can't be selected (so neither
|
||
/// moved, reshaped nor deleted), by a click, a marquee or Select All, and locking lets them go.
|
||
#[test]
|
||
fn locked_guides_lock_guide_paths() {
|
||
let mut s = session();
|
||
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 20.0, 20.0, 10.0, 10.0));
|
||
sel(&mut s, &[a]);
|
||
s.execute("view.guides.make", &json!({})).unwrap();
|
||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||
sel(&mut s, &[a, b]);
|
||
assert_eq!(selected(&s), vec![b]);
|
||
s.execute("select.all", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), vec![b]);
|
||
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
|
||
sel(&mut s, &[a, b]);
|
||
assert_eq!(selected(&s), vec![a, b]);
|
||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||
assert_eq!(selected(&s), vec![b]);
|
||
}
|
||
|
||
fn guides(s: &Session) -> Vec<(bool, f64)> {
|
||
s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect()
|
||
}
|
||
|
||
fn selected_guides(s: &Session) -> Vec<usize> {
|
||
s.doc().unwrap().selection.guides.clone()
|
||
}
|
||
|
||
/// #414: ruler guides are selected, moved, copied, nudged and deleted like art, over commands.
|
||
#[test]
|
||
fn ruler_guides_select_move_and_delete() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
for (vertical, pos) in [(true, 100.0), (false, 50.0), (true, 300.0)] {
|
||
s.execute("guide.add", &json!({"vertical": vertical, "pos": pos})).unwrap();
|
||
}
|
||
sel(&mut s, &[a]);
|
||
// Selecting guides deselects the art; toggle adds and removes.
|
||
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([0]));
|
||
assert!(selected(&s).is_empty());
|
||
assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1]));
|
||
let layer = s.doc().unwrap().current_layer().unwrap().0;
|
||
assert_eq!(
|
||
s.execute("guide.list", &json!({})).unwrap()[1],
|
||
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, "shown": true, "editable": true})
|
||
);
|
||
assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err());
|
||
// A move takes each selected guide along its own axis, in one undo step.
|
||
let n = undo_len(&s);
|
||
s.execute("guide.move", &json!({"dx": 10, "dy": -5})).unwrap();
|
||
assert_eq!(guides(&s), [(true, 110.0), (false, 45.0), (true, 300.0)]);
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
// Alt: copies, which are then the selection.
|
||
s.execute("guide.move", &json!({"dx": 20, "copy": true})).unwrap();
|
||
assert_eq!(guides(&s).len(), 5);
|
||
assert_eq!(&guides(&s)[3..], [(true, 130.0), (false, 45.0)]);
|
||
assert_eq!(selected_guides(&s), [3, 4]);
|
||
// The arrow keys nudge them.
|
||
s.execute("object.nudge", &json!({"dx": 1, "dy": 0, "big": true})).unwrap();
|
||
assert_eq!(guides(&s)[3], (true, 140.0));
|
||
// Delete (edit.clear) deletes the selected guides; the others keep their places.
|
||
s.execute("guide.select", &json!({"indexes": [1, 3]})).unwrap();
|
||
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 5);
|
||
s.execute("edit.clear", &json!({})).unwrap();
|
||
assert_eq!(guides(&s), [(true, 110.0), (true, 300.0), (false, 45.0)]);
|
||
assert!(selected_guides(&s).is_empty());
|
||
assert!(s.doc().unwrap().doc.node(a).is_some(), "the art stays");
|
||
// Removing one keeps the rest of the selection pointing at the same guides.
|
||
s.execute("guide.select", &json!({"indexes": [1, 2]})).unwrap();
|
||
assert_eq!(s.execute("guide.remove", &json!({"index": 0})).unwrap()["count"], 1);
|
||
assert_eq!((guides(&s), selected_guides(&s)), (vec![(true, 300.0), (false, 45.0)], vec![0, 1]));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![1, 2]));
|
||
// Out of range positions are refused.
|
||
assert!(s.execute("guide.move", &json!({"index": 0, "pos": 1e300})).is_err());
|
||
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": -1e300})).is_err());
|
||
// Locking deselects them, and they can't be picked again until unlocked.
|
||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||
assert!(selected_guides(&s).is_empty());
|
||
assert!(matches!(s.execute("guide.select", &json!({"indexes": [0]})), Err(EngineError::Disabled(..))));
|
||
assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..))));
|
||
}
|
||
|
||
/// Shift selects ruler guides and art together, in either order; Delete takes both in one step.
|
||
#[test]
|
||
fn guides_and_art_are_shift_selected_together() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0, 10.0, 40.0, 20.0);
|
||
let b = rect(&mut s, 70.0, 50.0, 60.0, 30.0);
|
||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||
// Art first, then a Shift-click on a guide: the art stays selected.
|
||
sel(&mut s, &[a]);
|
||
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
|
||
assert_eq!((selected(&s), selected_guides(&s)), (vec![a], vec![0]));
|
||
// A guide first, then Shift-added art: the guide stays selected.
|
||
s.execute("guide.select", &json!({"indexes": [1]})).unwrap();
|
||
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
|
||
assert_eq!((selected(&s), selected_guides(&s)), (vec![b], vec![1]));
|
||
// A new selection drops them.
|
||
sel(&mut s, &[a]);
|
||
assert!(selected_guides(&s).is_empty());
|
||
// Delete takes the art and the guides, in one step; undo brings both back.
|
||
s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap();
|
||
let n = undo_len(&s);
|
||
s.execute("edit.clear", &json!({})).unwrap();
|
||
assert_eq!((guides(&s), undo_len(&s)), (vec![(true, 200.0)], n + 1));
|
||
assert!(s.doc().unwrap().doc.node(a).is_none() && s.doc().unwrap().doc.node(b).is_some());
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(guides(&s).len(), 2);
|
||
// Ids given: only those go, the guides stay.
|
||
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
|
||
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
|
||
s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap();
|
||
assert_eq!(guides(&s).len(), 2);
|
||
}
|
||
|
||
/// When ruler guides and art are selected together, moving the art moves the guides as well;
|
||
/// copy leaves both in place and moves the copies; single undo reverts both.
|
||
#[test]
|
||
fn guides_and_art_move_together_when_selected() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0, 10.0, 40.0, 20.0);
|
||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||
|
||
// Select art and both guides.
|
||
sel(&mut s, &[a]);
|
||
s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap();
|
||
assert_eq!((selected(&s), selected_guides(&s)), (vec![a], vec![0, 1]));
|
||
|
||
// 1. Moving via object.transform moves both art and guides in one step.
|
||
let n = undo_len(&s);
|
||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 25, 35]})).unwrap();
|
||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||
assert_eq!((b.x0, b.y0), (35.0, 45.0));
|
||
assert_eq!(guides(&s), vec![(true, 225.0), (false, 335.0)]);
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
|
||
// Undo restores both in one step.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||
assert_eq!((b.x0, b.y0), (10.0, 10.0));
|
||
assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]);
|
||
|
||
// Redo reapplies both.
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||
assert_eq!((b.x0, b.y0), (35.0, 45.0));
|
||
assert_eq!(guides(&s), vec![(true, 225.0), (false, 335.0)]);
|
||
|
||
// Undo back to start.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
|
||
// 2. object.move also moves both.
|
||
s.execute("object.move", &json!({"dx": 15, "dy": 20})).unwrap();
|
||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||
assert_eq!((b.x0, b.y0), (25.0, 30.0));
|
||
assert_eq!(guides(&s), vec![(true, 215.0), (false, 320.0)]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
|
||
// 3. Arrow-key nudge moves both.
|
||
s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap();
|
||
let k = s.prefs.keyboard_increment;
|
||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||
assert_eq!(b.x0, 10.0 + k);
|
||
assert_eq!(guides(&s)[0], (true, 200.0 + k));
|
||
assert_eq!(guides(&s)[1], (false, 300.0));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
|
||
// 4. Copy (e.g. Alt-drag) duplicates both art and guides, leaving originals in place.
|
||
let res = s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 50, 60], "copy": true})).unwrap();
|
||
let copied_id = NodeId(res["ids"][0].as_u64().unwrap());
|
||
assert_ne!(copied_id, a);
|
||
let b_orig = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||
let b_copy = s.doc().unwrap().doc.bounds_of(&[copied_id], false).unwrap();
|
||
assert_eq!((b_orig.x0, b_orig.y0), (10.0, 10.0));
|
||
assert_eq!((b_copy.x0, b_copy.y0), (60.0, 70.0));
|
||
assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0), (true, 250.0), (false, 360.0)]);
|
||
assert_eq!((selected(&s), selected_guides(&s)), (vec![copied_id], vec![2, 3]));
|
||
|
||
// Undo takes back both copies in one step.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]);
|
||
assert!(s.doc().unwrap().doc.node(copied_id).is_none());
|
||
assert!(s.doc().unwrap().doc.node(a).is_some());
|
||
|
||
// 5. Explicit ids: only the specified art moves, guides stay put.
|
||
sel(&mut s, &[a]);
|
||
s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap();
|
||
s.execute("object.transform", &json!({"ids": [a.0], "matrix": [1, 0, 0, 1, 40, 50]})).unwrap();
|
||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||
assert_eq!((b.x0, b.y0), (50.0, 60.0));
|
||
assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
|
||
// 6. Interactive preview copies: dragging with Alt previews the copies and commit keeps them.
|
||
s.begin_interaction("Move").unwrap();
|
||
s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 10, 20], "copy": true})).unwrap();
|
||
s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 40], "copy": true})).unwrap();
|
||
s.commit_interaction().unwrap();
|
||
assert_eq!(guides(&s).len(), 4);
|
||
assert_eq!(guides(&s)[2], (true, 230.0));
|
||
assert_eq!(guides(&s)[3], (false, 340.0));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]);
|
||
|
||
// 7. Dragging a guide (guide.move) when art is also selected moves both art and guides.
|
||
sel(&mut s, &[a]);
|
||
s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap();
|
||
s.execute("guide.move", &json!({"dx": 10, "dy": 20})).unwrap();
|
||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||
assert_eq!((b.x0, b.y0), (20.0, 30.0));
|
||
assert_eq!(guides(&s), vec![(true, 210.0), (false, 320.0)]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||
assert_eq!((b.x0, b.y0), (10.0, 10.0));
|
||
assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]);
|
||
|
||
// 8. Copy-dragging a guide (guide.move { copy: true }) when art is also selected duplicates both.
|
||
s.execute("guide.move", &json!({"dx": 10, "dy": 20, "copy": true})).unwrap();
|
||
assert_eq!(guides(&s).len(), 4);
|
||
assert_eq!(guides(&s)[2], (true, 210.0));
|
||
assert_eq!(guides(&s)[3], (false, 320.0));
|
||
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(guides(&s).len(), 2);
|
||
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1);
|
||
}
|
||
|
||
/// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved,
|
||
/// copied and deleted with it; canvas guides stay put.
|
||
#[test]
|
||
fn artboard_guides_go_with_their_artboard() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 400, "height": 300})).unwrap();
|
||
s.execute("guide.add", &json!({"vertical": true, "pos": 1000, "artboard": 1})).unwrap();
|
||
s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap();
|
||
let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap();
|
||
let layer = s.doc().unwrap().current_layer().unwrap().0;
|
||
assert_eq!(
|
||
list(&mut s),
|
||
json!([
|
||
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true},
|
||
{"index": 1, "vertical": false, "pos": 100.0, "selected": false, "layer": layer, "shown": true, "editable": true},
|
||
])
|
||
);
|
||
let spans = |s: &Session| {
|
||
let d = &s.doc().unwrap().doc;
|
||
d.guides.iter().map(|g| d.guide_span(g)).collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(spans(&s), [Some((0.0, 300.0)), None]);
|
||
for bad in [json!(5), json!("x"), json!(-1)] {
|
||
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 10, "artboard": bad})).is_err(), "{bad}");
|
||
}
|
||
// Moved, the artboard takes its guides along; in Artboard Options too, unless resized.
|
||
s.execute("artboard.move", &json!({"index": 1, "dx": 50, "dy": 20})).unwrap();
|
||
assert_eq!(guides(&s), [(true, 1050.0), (false, 100.0)]);
|
||
s.execute("artboard.setProps", &json!({"index": 1, "x": 960.5})).unwrap();
|
||
assert_eq!(guides(&s)[0], (true, 1060.5));
|
||
s.execute("artboard.setProps", &json!({"index": 1, "x": 900, "width": 500})).unwrap();
|
||
assert_eq!((guides(&s)[0], spans(&s)[0]), ((true, 1060.5), Some((20.0, 320.0))));
|
||
// Renumbered, it keeps them.
|
||
s.execute("artboard.reorder", &json!({"index": 1, "to": 0})).unwrap();
|
||
assert_eq!(list(&mut s)[0]["artboard"], 0);
|
||
// Duplicated (or Alt-dragged), the copy gets copies of them.
|
||
let dup = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap()["index"].as_u64().unwrap();
|
||
let dx = s.doc().unwrap().doc.artboards[dup as usize].rect.x0 - 900.0;
|
||
assert_eq!(guides(&s), [(true, 1060.5), (false, 100.0), (true, 1060.5 + dx)]);
|
||
assert_eq!(list(&mut s)[2]["artboard"], dup);
|
||
s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 400, "copy": true})).unwrap();
|
||
assert_eq!((guides(&s).len(), list(&mut s)[3]["artboard"].clone()), (4, json!(3)));
|
||
// Rearranged, they follow their artboards.
|
||
s.execute("artboard.rearrange", &json!({"columns": 4})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
for (g, ab) in d.guides.iter().filter_map(|g| Some((g, d.artboards.iter().find(|a| Some(a.id) == g.artboard)?))) {
|
||
assert_eq!(g.pos - ab.rect.x0, 160.5, "{g:?} on {ab:?}");
|
||
}
|
||
// Deleted, the artboard takes its guides; the selected ones left stay selected.
|
||
s.execute("guide.select", &json!({"indexes": [1, 2]})).unwrap();
|
||
let n = undo_len(&s);
|
||
s.execute("artboard.delete", &json!({"index": 0})).unwrap();
|
||
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1]));
|
||
assert_eq!(
|
||
list(&mut s)[0],
|
||
json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true, "layer": layer, "shown": true, "editable": true})
|
||
);
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!((guides(&s).len(), selected_guides(&s)), (4, vec![1, 2]));
|
||
// Cut, too.
|
||
s.execute("artboard.cut", &json!({"index": 0})).unwrap();
|
||
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1]));
|
||
}
|
||
|
||
/// #451: a guide dragged out of a ruler snaps (to the art's corners, side midpoints and anchors,
|
||
/// the artboards' edges and centres) and is made in one undo step where it is released over the
|
||
/// canvas; with the Artboard tool it is an artboard guide of the active artboard.
|
||
#[test]
|
||
fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() {
|
||
use vectorcraft_tools::{Overlay, PointerEvent, PointerKind};
|
||
let mut s = session();
|
||
rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||
let v = ViewInfo::default();
|
||
let ev = |kind, x, y| PointerEvent::new(kind, x, y);
|
||
let n = undo_len(&s);
|
||
s.ruler_guide(true, &ev(PointerKind::Drag, -10.0, 30.0), false, v).unwrap();
|
||
assert!(guides(&s).is_empty(), "still over the ruler");
|
||
s.ruler_guide(true, &ev(PointerKind::Drag, 153.0, 30.0), true, v).unwrap();
|
||
assert_eq!(guides(&s), [(true, 150.0)], "onto the line through the top and bottom midpoints");
|
||
assert!(s.overlays(v).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "center")));
|
||
s.ruler_guide(true, &ev(PointerKind::Drag, 797.0, 30.0), true, v).unwrap();
|
||
assert_eq!(guides(&s), [(true, 800.0)], "the artboard's edge");
|
||
s.ruler_guide(true, &ev(PointerKind::Up, 797.0, 30.0), true, v).unwrap();
|
||
assert_eq!(guides(&s), [(true, 800.0)]);
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "New Guide");
|
||
assert!(s.overlays(v).is_empty());
|
||
// Released off the canvas: none.
|
||
s.ruler_guide(false, &ev(PointerKind::Drag, 30.0, 250.0), true, v).unwrap();
|
||
s.ruler_guide(false, &ev(PointerKind::Up, 30.0, -10.0), false, v).unwrap();
|
||
assert_eq!((guides(&s).len(), undo_len(&s)), (1, n + 1));
|
||
// The Artboard tool: an artboard guide of its active artboard.
|
||
s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 400, "height": 300})).unwrap();
|
||
s.select_tool("artboard", v).unwrap();
|
||
s.set_tool_option("active", &json!(1));
|
||
s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap();
|
||
s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap();
|
||
let layer = s.doc().unwrap().current_layer().unwrap().0;
|
||
assert_eq!(
|
||
s.execute("guide.list", &json!({})).unwrap()[1],
|
||
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true})
|
||
);
|
||
}
|
||
|
||
/// Guides saved before guides had layers are put on a layer when the document opens.
|
||
#[test]
|
||
fn guides_from_older_files_are_put_on_a_layer() {
|
||
let mut s = Session::new();
|
||
let mut d = vectorcraft_doc::Document::new(400.0, 300.0);
|
||
d.guides.push(vectorcraft_doc::Guide::new(true, 50.0));
|
||
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
|
||
s.add_document(d, None);
|
||
let st = s.doc().unwrap();
|
||
let top = st.doc.default_layer();
|
||
assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides);
|
||
}
|
||
|
||
/// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it,
|
||
/// and moved to another layer.
|
||
#[test]
|
||
fn ruler_guides_live_on_layers() {
|
||
let mut s = session();
|
||
let first = s.doc().unwrap().current_layer().unwrap();
|
||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer");
|
||
let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap());
|
||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current");
|
||
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err());
|
||
// Hidden or locked with its layer.
|
||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
|
||
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1]["shown"], false);
|
||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap();
|
||
let row = s.execute("guide.list", &json!({})).unwrap()[1].clone();
|
||
assert_eq!((row["shown"].clone(), row["editable"].clone()), (json!(true), json!(false)));
|
||
s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap();
|
||
// Moved to the first layer, then back.
|
||
s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first));
|
||
s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap();
|
||
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err());
|
||
// Several at once, as dragging their rows onto a layer's row does.
|
||
s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap();
|
||
assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.layer == Some(first)));
|
||
s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap();
|
||
assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err());
|
||
// Deleting a layer deletes its guides; undo brings them back.
|
||
s.execute("layer.delete", &json!({"ids": [second.0]})).unwrap();
|
||
assert_eq!(guides(&s), [(true, 200.0)]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(guides(&s).len(), 2);
|
||
}
|
||
|
||
/// Guides on a hidden or locked layer leave the selection and can't be moved, deleted or moved to
|
||
/// another layer; new guides skip a locked current layer as new art does; no guide goes onto a
|
||
/// locked layer.
|
||
#[test]
|
||
fn guides_on_hidden_or_locked_layers_are_left_alone() {
|
||
let mut s = session();
|
||
let first = s.doc().unwrap().current_layer().unwrap();
|
||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
|
||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||
s.execute("guide.select", &json!({"indexes": [0, 1]})).unwrap();
|
||
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": true})).unwrap();
|
||
assert_eq!(selected_guides(&s), [1], "the locked layer's guide is deselected");
|
||
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([]), "and can't be selected");
|
||
assert!(s.execute("guide.move", &json!({"index": 0, "pos": 50})).is_err());
|
||
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
|
||
assert!(s.execute("guide.setLayer", &json!({"index": 0, "layer": second.0})).is_err());
|
||
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).is_err(), "not onto a locked layer");
|
||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
|
||
assert!(selected_guides(&s).is_empty(), "the hidden layer's guide is deselected");
|
||
// Both layers hidden or locked: a new guide is refused, as new art is.
|
||
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 10})).is_err());
|
||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap();
|
||
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap();
|
||
// The current layer locked: the new guide goes on the top shown, unlocked layer.
|
||
s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first));
|
||
assert_eq!(guides(&s).len(), 3);
|
||
}
|
||
|
||
/// Guides follow their layers through Duplicate, Merge Selected and Flatten Artwork, in one undo
|
||
/// step each.
|
||
#[test]
|
||
fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() {
|
||
let mut s = session();
|
||
let first = s.doc().unwrap().current_layer().unwrap();
|
||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
|
||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||
let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||
// Duplicate Layer copies its guides onto the copy.
|
||
let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap();
|
||
assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)]));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(guides(&s).len(), 2);
|
||
// Merged into the first layer, the second layer's guide goes with its art.
|
||
s.execute("layer.merge", &json!({"ids": [second.0, first.0]})).unwrap();
|
||
assert_eq!(on(&s, first), [(true, 200.0), (false, 300.0)]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!((on(&s, first), on(&s, second)), (vec![(true, 200.0)], vec![(false, 300.0)]));
|
||
// Flattened into the first layer, a hidden layer is discarded with its guides.
|
||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
|
||
s.execute("layer.flatten", &json!({"id": first.0})).unwrap();
|
||
assert_eq!(guides(&s), [(true, 200.0)]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(guides(&s).len(), 2);
|
||
}
|
||
|
||
/// #451: art moved with the Selection tool lands flush on the artboard's edges and centre (Smart
|
||
/// Guides).
|
||
#[test]
|
||
fn moved_art_snaps_to_the_artboard_edges_and_centre() {
|
||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||
let v = ViewInfo::default();
|
||
s.select_tool("selection", v).unwrap();
|
||
let mut drag = |from: (f64, f64), to: (f64, f64)| {
|
||
for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
|
||
}
|
||
let b = bounds(&s, a);
|
||
(b.x0, b.y0)
|
||
};
|
||
assert_eq!(drag((150.0, 150.0), (53.0, 147.0)), (0.0, 97.0), "3 pt off the left edge: flush with it");
|
||
assert_eq!(drag((50.0, 147.0), (402.0, 298.0)), (350.0, 250.0), "its centre on the artboard's");
|
||
}
|
||
|
||
/// #414: the Selection tool picks a ruler guide over the art and drags it in one undo step; with
|
||
/// guides hidden or locked the press goes to the art.
|
||
#[test]
|
||
fn selection_tool_drags_a_ruler_guide() {
|
||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||
s.execute("guide.add", &json!({"vertical": true, "pos": 150})).unwrap();
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||
s.select_tool("selection", v).unwrap();
|
||
let drag = |s: &mut Session, v: ViewInfo, from: (f64, f64), to: (f64, f64)| {
|
||
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap();
|
||
};
|
||
let n = undo_len(&s);
|
||
drag(&mut s, v, (151.0, 120.0), (171.0, 140.0));
|
||
assert_eq!(guides(&s), [(true, 170.0)]);
|
||
assert_eq!(selected_guides(&s), [0]);
|
||
assert!(selected(&s).is_empty());
|
||
assert_eq!(bounds(&s, a).x0, 100.0, "the art stays");
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Move Guide");
|
||
// Hidden guides: the press takes the art.
|
||
let hidden = ViewInfo { guides: false, ..v };
|
||
drag(&mut s, hidden, (170.0, 120.0), (180.0, 120.0));
|
||
assert_eq!((guides(&s), bounds(&s, a).x0), (vec![(true, 170.0)], 110.0));
|
||
// Locked guides too.
|
||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||
drag(&mut s, v, (170.0, 120.0), (180.0, 120.0));
|
||
assert_eq!((guides(&s), bounds(&s, a).x0), (vec![(true, 170.0)], 120.0));
|
||
}
|
||
|
||
// ---------- File ----------
|
||
|
||
#[test]
|
||
fn close_all_documents() {
|
||
let mut s = session();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
assert_eq!(s.execute("file.closeAll", &json!({})).unwrap()["closed"], 2);
|
||
assert!(s.active().is_none());
|
||
assert!(s.execute("file.closeAll", &json!({})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn document_color_mode_converts_colours() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
fill(&mut s, a, "#ff0000");
|
||
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Cmyk);
|
||
// Converted through the colour settings: a press red, mostly magenta and yellow.
|
||
let Some(Color::Cmyk { c, m, y, k }) = node(&s, a).appearance.fill_paint().color() else { panic!("not CMYK") };
|
||
assert!(m > 0.8 && y > 0.8 && c < 0.05 && k < 0.05, "{c} {m} {y} {k}");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Rgb);
|
||
assert!(s.execute("file.documentColorMode", &json!({"mode": "lab"})).is_err());
|
||
}
|
||
|
||
/// Four RGB greys (#000000, #333333, #808080, #e6e6e6, as in issue #421) and a red, a 40% tint of a
|
||
/// grey global swatch, a gradient to grey and a pattern of a grey tile → (session, the five
|
||
/// rectangles, the tinted one, the gradient one).
|
||
fn greys_document() -> (Session, Vec<NodeId>, NodeId, NodeId) {
|
||
let mut s = session();
|
||
let ids: Vec<NodeId> = ["#000000", "#333333", "#808080", "#e6e6e6", "#ff0000"]
|
||
.iter()
|
||
.map(|hex| {
|
||
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
fill(&mut s, id, hex);
|
||
id
|
||
})
|
||
.collect();
|
||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#808080", "global": true})).unwrap();
|
||
let tint = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
s.execute("paint.setFill", &json!({"ids": [tint.0], "swatch": "Ink", "tint": 40})).unwrap();
|
||
let grad = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#333333"}]);
|
||
s.execute("paint.setFill", &json!({"ids": [grad.0], "gradient": {"kind": "linear", "stops": stops}})).unwrap();
|
||
let tile = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
fill(&mut s, tile, "#808080");
|
||
s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Grey Tile", "edit": false})).unwrap();
|
||
(s, ids, tint, grad)
|
||
}
|
||
|
||
/// The colour of pattern `name`'s first tile object.
|
||
fn pattern_fill(s: &Session, name: &str) -> Color {
|
||
s.doc().unwrap().doc.pattern(name).unwrap().art[0].appearance.fill_paint().color().unwrap()
|
||
}
|
||
|
||
/// K of a colour that prints on the black plate only.
|
||
fn k_only(c: Color) -> f32 {
|
||
match c {
|
||
Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k } => k,
|
||
other => panic!("not K only: {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn document_color_mode_separates_rgb_greys_through_the_profile() {
|
||
let (mut s, ids, _, _) = greys_document();
|
||
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||
// As Illustrator converts them: four-colour greys and a rich black.
|
||
let Color::Cmyk { c, m, y, k } = fill_of(&s, ids[2]) else { panic!("not CMYK") };
|
||
assert!(c > 0.05 && m > 0.05 && y > 0.05 && k > 0.0, "a four-colour mid grey: {c} {m} {y} {k}");
|
||
let Color::Cmyk { c, k, .. } = fill_of(&s, ids[0]) else { panic!("not CMYK") };
|
||
assert!(k > 0.9 && c > 0.3, "rich black: {c} {k}");
|
||
// Pattern tiles are swatches too: their art converts with the rest.
|
||
assert!(matches!(pattern_fill(&s, "Grey Tile"), Color::Cmyk { .. }), "{:?}", pattern_fill(&s, "Grey Tile"));
|
||
// Gray colours stay Gray.
|
||
let mut g = session();
|
||
let a = rect(&mut g, 0.0, 0.0, 10.0, 10.0);
|
||
g.execute("paint.setFill", &json!({"ids": [a.0], "color": {"gray": 0.3}})).unwrap();
|
||
g.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||
assert_eq!(fill_of(&g, a), Color::gray(0.3));
|
||
}
|
||
|
||
#[test]
|
||
fn document_color_mode_can_put_rgb_greys_on_the_black_plate() {
|
||
let (mut profile, pids, ..) = greys_document();
|
||
profile.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||
let (mut s, ids, tint, grad) = greys_document();
|
||
let e = s.execute("file.documentColorMode", &json!({"mode": "cmyk", "grays": "rich"})).unwrap_err().to_string();
|
||
assert!(e.contains("grays"), "{e}");
|
||
s.execute("file.documentColorMode", &json!({"mode": "cmyk", "grays": "black"})).unwrap();
|
||
let close = |a: f32, b: f32| (a - b).abs() < 0.005;
|
||
for (id, want) in ids.iter().zip([1.0, 0.8, 0.498, 0.098]) {
|
||
let k = k_only(fill_of(&s, *id));
|
||
assert!(close(k, want), "K {k}, want {want}");
|
||
}
|
||
// Other colours convert through the profile as before.
|
||
assert_eq!(fill_of(&s, ids[4]), fill_of(&profile, pids[4]));
|
||
// Swatches, their tints, gradient stops and pattern tiles follow the same rule.
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(close(k_only(d.swatch("Ink").unwrap().paint.color().unwrap()), 0.498));
|
||
let Paint::Solid { color, swatch, tint: t } = node(&s, tint).appearance.fill_paint() else { panic!("not solid") };
|
||
assert!(close(k_only(color), 0.498 * 0.4) && swatch.as_deref() == Some("Ink") && t == 0.4, "{color:?} {swatch:?} {t}");
|
||
let Paint::Gradient(g) = node(&s, grad).appearance.fill_paint() else { panic!("not a gradient") };
|
||
let ks: Vec<f32> = g.gradient.stops.iter().map(|st| k_only(st.color)).collect();
|
||
assert!(close(ks[0], 0.0) && close(ks[1], 0.8), "{ks:?}");
|
||
assert!(close(k_only(pattern_fill(&s, "Grey Tile")), 0.498));
|
||
// To RGB, `grays` changes nothing.
|
||
s.execute("file.documentColorMode", &json!({"mode": "rgb", "grays": "black"})).unwrap();
|
||
let rgb = s.doc().unwrap().doc.layers.clone();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("file.documentColorMode", &json!({"mode": "rgb"})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.layers, rgb);
|
||
}
|
||
|
||
#[test]
|
||
fn opening_in_cmyk_can_put_rgb_greys_on_the_black_plate() {
|
||
let mut s = session();
|
||
for hex in ["#000000", "#333333", "#808080", "#e6e6e6", "#ff0000"] {
|
||
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
fill(&mut s, id, hex);
|
||
}
|
||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||
let data = vectorcraft_format::base64_encode(svg.as_bytes());
|
||
let mut o = Session::new();
|
||
o.execute("document.open", &json!({"name": "greys.svg", "dataBase64": data, "colorMode": "cmyk", "grays": "black"})).unwrap();
|
||
let d = &o.doc().unwrap().doc;
|
||
assert_eq!(d.color_mode, ColorMode::Cmyk);
|
||
let mut fills = vec![];
|
||
for l in &d.layers {
|
||
l.walk(&mut |n| fills.extend(n.children().is_none().then(|| n.appearance.fill_paint().color()).flatten()));
|
||
}
|
||
assert_eq!(fills.len(), 5, "{fills:?}");
|
||
for (c, want) in fills.iter().zip([1.0, 0.8, 0.498, 0.098]) {
|
||
let k = k_only(*c);
|
||
assert!((k - want).abs() < 0.005, "K {k}, want {want}");
|
||
}
|
||
assert!(matches!(fills[4], Color::Cmyk { m, y, .. } if m > 0.8 && y > 0.8), "red separates as usual: {:?}", fills[4]);
|
||
let e = Session::new().execute("document.open", &json!({"name": "greys.svg", "dataBase64": data, "grays": "k"})).unwrap_err().to_string();
|
||
assert!(e.contains("grays"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn file_info_sets_title() {
|
||
let mut s = session();
|
||
let r = s.execute("file.info", &json!({"title": "Poster"})).unwrap();
|
||
assert_eq!(r["title"], "Poster");
|
||
assert_eq!(s.doc().unwrap().doc.title, "Poster");
|
||
assert_eq!(s.execute("file.info", &json!({})).unwrap()["colorMode"], "rgb");
|
||
}
|
||
|
||
#[test]
|
||
fn every_new_command_has_params_doc_and_menu() {
|
||
for id in [
|
||
"object.lock.above",
|
||
"object.transformEach",
|
||
"object.rasterize",
|
||
"object.blend.make",
|
||
"edit.colors.invert",
|
||
"edit.findReplace",
|
||
"select.same.fontSize",
|
||
"type.changeCase",
|
||
"view.guides.make",
|
||
"file.closeAll",
|
||
] {
|
||
let c = find_command(id).unwrap_or_else(|| panic!("{id}"));
|
||
assert!(!c.params.is_empty() && !c.menu.is_empty(), "{id}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn saved_selection_names_are_trimmed_and_capped() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a]);
|
||
let long = "n".repeat(1000);
|
||
let name = s.execute("select.save", &json!({ "name": format!(" {long} ") })).unwrap()["name"].clone();
|
||
assert_eq!(name.as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
|
||
s.execute("select.editSaved", &json!({"name": name, "newName": format!("{long}é")})).unwrap();
|
||
let list = s.execute("select.savedList", &json!({})).unwrap();
|
||
assert_eq!(list[0].as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
|
||
}
|
||
|
||
#[test]
|
||
fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
|
||
// 100 × 80 px, transparent but for a block at (20, 10)–(50, 40) px; placed at 2 pt a pixel.
|
||
let mut px = image::RgbaImage::new(100, 80);
|
||
for y in 10..40 {
|
||
for x in 20..50 {
|
||
px.put_pixel(x, y, image::Rgba([x as u8, y as u8, 7, if x == 20 { 1 } else { 255 }]));
|
||
}
|
||
}
|
||
let mut png = Vec::new();
|
||
px.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
let mut s = Session::new();
|
||
s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||
s.execute("select.all", &json!({})).unwrap();
|
||
let img = selected(&s)[0];
|
||
s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0]})).unwrap();
|
||
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
|
||
assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (30, 30), "{r}");
|
||
// Placed where those pixels were: (40, 20) pt, 60 × 60 pt at 2 pt a pixel.
|
||
let b = bounds(&s, img);
|
||
assert!(close(b.x0, 40.0) && close(b.y0, 20.0) && close(b.width(), 60.0) && close(b.height(), 60.0), "{b:?}");
|
||
// Pixel for pixel, a barely visible (alpha 1) edge included.
|
||
let NodeKind::Image(im) = &node(&s, img).kind else { panic!("an image") };
|
||
let out = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8();
|
||
assert_eq!(out, image::imageops::crop_imm(&px, 20, 10, 30, 30).to_image());
|
||
assert_eq!(r["trimmed"], true);
|
||
// Nothing transparent left to cut: left as it is, and not an error (scripts trim every image).
|
||
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
|
||
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64()), (Some(false), Some(30)), "{r}");
|
||
assert_eq!(bounds(&s, img), b);
|
||
}
|
||
|
||
#[test]
|
||
fn crop_image_trim_checks_the_rgba_size_before_decoding() {
|
||
// 1-bit gray PNGs, header only: their samples are within the decoder's limit, and as RGBA
|
||
// (4 bytes a pixel) they are just over or just within the 2 GB allowance.
|
||
let trim = |w: u32, h: u32| {
|
||
let png = super::tests_linkspanel::png_header(w, h);
|
||
let mut s = Session::new();
|
||
s.execute("document.open", &json!({"name": "wall.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||
s.execute("select.all", &json!({})).unwrap();
|
||
s.execute("object.cropImage", &json!({"trim": true})).unwrap_err().to_string()
|
||
};
|
||
let over = trim(23_171, 23_170);
|
||
assert!(over.contains("an image of 23171 × 23170 pixels is too large to draw"), "{over}");
|
||
// One column fewer is within the allowance: it is decoded, and its missing rows can't be read.
|
||
let within = trim(23_170, 23_170);
|
||
assert!(within.contains("the image's pixels can't be read"), "{within}");
|
||
}
|
||
|
||
#[test]
|
||
fn crop_image_trim_leaves_a_cmyk_image_alone() {
|
||
// CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error.
|
||
let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [255, 0, 0, 0].repeat(8)).unwrap();
|
||
let tiff = vectorcraft_doc::ImageBlob::cmyk_tiff(&inks).unwrap();
|
||
let mut s = Session::new();
|
||
s.execute("document.open", &json!({"name": "inks.tif", "dataBase64": vectorcraft_format::base64_encode(&tiff.bytes)})).unwrap();
|
||
s.execute("select.all", &json!({})).unwrap();
|
||
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
|
||
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64(), r["height"].as_u64()), (Some(false), Some(4), Some(2)), "{r}");
|
||
}
|