Document.slices holds user slices, Node.slice makes an object an object slice that follows its bounds, and Document.slices_clip_to_artboard picks the region auto slices fill (the artboards, or the art and the slices). Object → Slice: Make, Release, Create from Guides, Create from Selection, Duplicate, Combine, Divide… (dialog), Delete All, Slice Options… (dialog) and Clip to Artboard; View → Hide/Lock Slices; Select → Object → Slices; slice.list reads the numbered layout. The canvas draws the slices in the Slices preferences' line colour with their numbers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
12 KiB
Rust
253 lines
12 KiB
Rust
//! Object → Slice, View → Hide/Lock Slices, Select → Object → Slices and `slice.list`.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::SliceKind;
|
|
|
|
use super::*;
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
|
// Unstroked art: visual bounds are the geometry.
|
|
s.paint.stroke = vectorcraft_color::Paint::None;
|
|
s
|
|
}
|
|
|
|
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
|
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
|
}
|
|
|
|
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> u64 {
|
|
run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap()
|
|
}
|
|
|
|
/// `slice.list`'s slices.
|
|
fn slices(s: &mut Session) -> Vec<Value> {
|
|
run(s, "slice.list", json!({}))["slices"].as_array().unwrap().clone()
|
|
}
|
|
|
|
/// The laid-out rectangle of slice `id` as [x, y, width, height].
|
|
fn rect_of(s: &mut Session, id: u64) -> [f64; 4] {
|
|
let v = slices(s).into_iter().find(|v| v["id"] == json!(id)).unwrap();
|
|
["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap())
|
|
}
|
|
|
|
fn user_count(s: &Session) -> usize {
|
|
s.doc().unwrap().doc.slices.len()
|
|
}
|
|
|
|
/// A user slice over `[x, y, w, h]`, made from a temporary rectangle.
|
|
fn user_slice(s: &mut Session, r: [f64; 4]) -> u64 {
|
|
let tmp = rect(s, r[0], r[1], r[2], r[3]);
|
|
let id = run(s, "object.slice.fromSelection", json!({"ids": [tmp]}))["id"].as_u64().unwrap();
|
|
run(s, "select.set", json!({"ids": [tmp]}));
|
|
run(s, "edit.clear", json!({}));
|
|
id
|
|
}
|
|
|
|
#[test]
|
|
fn an_object_slice_follows_its_object_and_releases_back_to_it() {
|
|
let mut s = session();
|
|
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
|
let made = run(&mut s, "object.slice.make", json!({}));
|
|
assert_eq!(made["ids"], json!([a]));
|
|
assert_eq!(rect_of(&mut s, a), [100.0, 100.0, 50.0, 40.0]);
|
|
let list = slices(&mut s);
|
|
assert!(list.iter().any(|v| v["source"] == "auto"), "auto slices fill the artboard");
|
|
let area: f64 = list.iter().map(|v| v["width"].as_f64().unwrap() * v["height"].as_f64().unwrap()).sum();
|
|
assert_eq!(area, 300.0 * 300.0);
|
|
// Moving the object moves its slice; one undo step brings it back.
|
|
run(&mut s, "object.move", json!({"dx": 20, "dy": 10}));
|
|
assert_eq!(rect_of(&mut s, a), [120.0, 110.0, 50.0, 40.0]);
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(rect_of(&mut s, a), [100.0, 100.0, 50.0, 40.0]);
|
|
// Make again changes nothing; Release leaves the object.
|
|
assert_eq!(run(&mut s, "object.slice.make", json!({}))["ids"], json!([]));
|
|
assert_eq!(run(&mut s, "object.slice.release", json!({}))["ids"], json!([a]));
|
|
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none());
|
|
assert!(slices(&mut s).is_empty());
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn releasing_a_user_slice_leaves_an_unpainted_rectangle() {
|
|
let mut s = session();
|
|
let id = user_slice(&mut s, [10.0, 20.0, 30.0, 40.0]);
|
|
run(&mut s, "select.object.slices", json!({}));
|
|
let r = run(&mut s, "object.slice.release", json!({}));
|
|
assert_eq!(s.doc().unwrap().doc.slice(NodeId(id)), None);
|
|
let rid = r["ids"][0].as_u64().unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
assert!(d.slices.is_empty());
|
|
let n = d.node(NodeId(rid)).unwrap();
|
|
assert_eq!(n.geometric_bounds().unwrap(), vectorcraft_geom::Rect::new(10.0, 20.0, 40.0, 60.0));
|
|
assert!(matches!(n.appearance.fill_paint(), vectorcraft_color::Paint::None));
|
|
assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(rid)]);
|
|
}
|
|
|
|
#[test]
|
|
fn guides_give_nine_slices() {
|
|
let mut s = session();
|
|
for (vertical, pos) in [(true, 100.0), (true, 200.0), (false, 50.0), (false, 250.0), (true, 400.0)] {
|
|
run(&mut s, "guide.add", json!({"vertical": vertical, "pos": pos}));
|
|
}
|
|
user_slice(&mut s, [0.0, 0.0, 10.0, 10.0]);
|
|
let ids = run(&mut s, "object.slice.fromGuides", json!({}))["ids"].as_array().unwrap().len();
|
|
assert_eq!(ids, 9, "the guide off the artboard cuts nothing");
|
|
assert_eq!(user_count(&s), 9, "the earlier slice is replaced");
|
|
let list = slices(&mut s);
|
|
assert_eq!(list.len(), 9, "the grid covers the artboard: no auto slices");
|
|
assert_eq!(list[4]["x"], 100.0);
|
|
assert_eq!(list[4]["width"], 100.0);
|
|
assert_eq!(list[4]["height"], 200.0);
|
|
assert_eq!(list.iter().map(|v| v["number"].as_u64().unwrap()).collect::<Vec<_>>(), (1..=9).collect::<Vec<_>>());
|
|
// One undo step takes the grid back.
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(user_count(&s), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn divide_two_by_three_and_by_size() {
|
|
let mut s = session();
|
|
user_slice(&mut s, [0.0, 0.0, 90.0, 60.0]);
|
|
run(&mut s, "select.object.slices", json!({}));
|
|
run(&mut s, "object.slice.options", json!({"name": "hero", "url": "https://example.com"}));
|
|
let pieces = run(&mut s, "object.slice.divide", json!({"rows": 2, "columns": 3}))["ids"].as_array().unwrap().clone();
|
|
assert_eq!(pieces.len(), 6);
|
|
let d = &s.doc().unwrap().doc;
|
|
let rects: Vec<_> = d.slices.iter().map(|sl| (sl.rect.x0, sl.rect.y0, sl.rect.width(), sl.rect.height())).collect();
|
|
assert_eq!(
|
|
rects,
|
|
[
|
|
(0.0, 0.0, 30.0, 30.0),
|
|
(30.0, 0.0, 30.0, 30.0),
|
|
(60.0, 0.0, 30.0, 30.0),
|
|
(0.0, 30.0, 30.0, 30.0),
|
|
(30.0, 30.0, 30.0, 30.0),
|
|
(60.0, 30.0, 30.0, 30.0)
|
|
]
|
|
);
|
|
assert!(d.slices.iter().all(|sl| sl.options.url == "https://example.com"));
|
|
assert_eq!(d.slices.iter().filter(|sl| sl.options.name == "hero").count(), 1, "only the first piece keeps the name");
|
|
assert_eq!(s.doc().unwrap().selection.slices.len(), 6, "the pieces are selected");
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(user_count(&s), 1);
|
|
// By size: 25 pt rows of a 60 pt slice: 25, 25 and 10.
|
|
run(&mut s, "select.object.slices", json!({}));
|
|
run(&mut s, "object.slice.divide", json!({"rowHeight": 25}));
|
|
let hs: Vec<f64> = s.doc().unwrap().doc.slices.iter().map(|sl| sl.rect.height()).collect();
|
|
assert_eq!(hs, [25.0, 25.0, 10.0]);
|
|
assert!(s.execute("object.slice.divide", &json!({"rows": 0})).is_err());
|
|
assert!(s.execute("object.slice.divide", &json!({"columns": 5000})).is_err());
|
|
assert!(s.execute("object.slice.divide", &json!({})).is_err(), "nothing to divide by");
|
|
}
|
|
|
|
#[test]
|
|
fn combine_and_duplicate() {
|
|
let mut s = session();
|
|
user_slice(&mut s, [0.0, 0.0, 20.0, 20.0]);
|
|
run(&mut s, "select.object.slices", json!({}));
|
|
assert!(s.execute("object.slice.combine", &json!({})).is_err(), "two or more");
|
|
let b = rect(&mut s, 50.0, 40.0, 10.0, 10.0);
|
|
run(&mut s, "object.slice.make", json!({}));
|
|
run(&mut s, "select.object.slices", json!({}));
|
|
let c = run(&mut s, "object.slice.combine", json!({}))["id"].as_u64().unwrap();
|
|
assert_eq!(rect_of(&mut s, c), [0.0, 0.0, 60.0, 50.0]);
|
|
assert_eq!(user_count(&s), 1);
|
|
assert!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().slice.is_none(), "the object slice was released");
|
|
assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(c)]);
|
|
let dup = run(&mut s, "object.slice.duplicate", json!({}))["ids"][0].as_u64().unwrap();
|
|
assert_eq!(rect_of(&mut s, dup), [10.0, 10.0, 60.0, 50.0]);
|
|
assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(dup)]);
|
|
}
|
|
|
|
#[test]
|
|
fn slice_options_set_read_and_undo_in_one_step() {
|
|
let mut s = session();
|
|
user_slice(&mut s, [0.0, 0.0, 50.0, 50.0]);
|
|
run(&mut s, "select.object.slices", json!({}));
|
|
let o = run(&mut s, "object.slice.options", json!({}));
|
|
assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("image"), json!(false)));
|
|
let undo = s.doc().unwrap().history.undo.len();
|
|
let o = run(
|
|
&mut s,
|
|
"object.slice.options",
|
|
json!({"kind": "No Image", "name": " cell ", "text": " <b>Hi</b>", "background": "#FF0000", "hAlign": "center", "vAlign": "bottom"}),
|
|
);
|
|
assert_eq!(
|
|
o,
|
|
json!({"kind": "noImage", "name": "cell", "url": "", "target": "", "message": "", "alt": "", "text": " <b>Hi</b>", "background": "#ff0000", "hAlign": "center", "vAlign": "bottom", "htmlText": false})
|
|
);
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
|
assert_eq!(slices(&mut s)[0]["name"], "cell");
|
|
assert!(s.execute("object.slice.options", &json!({"kind": "htmlText"})).is_err(), "HTML Text is for type");
|
|
assert!(s.execute("object.slice.options", &json!({"background": "pink-ish"})).is_err());
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(s.doc().unwrap().doc.slices[0].options, Default::default());
|
|
// An object slice of type can be HTML Text.
|
|
let t = run(&mut s, "text.create", json!({"x": 10, "y": 100, "text": "Hello"}))["id"].as_u64().unwrap();
|
|
run(&mut s, "object.slice.make", json!({"ids": [t]}));
|
|
run(&mut s, "select.set", json!({"ids": [t]}));
|
|
let o = run(&mut s, "object.slice.options", json!({"kind": "htmlText"}));
|
|
assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("htmlText"), json!(true)));
|
|
assert_eq!(s.doc().unwrap().doc.node(NodeId(t)).unwrap().slice.as_ref().unwrap().kind, SliceKind::HtmlText);
|
|
}
|
|
|
|
#[test]
|
|
fn clip_to_artboard_hide_lock_select_and_delete_all() {
|
|
let mut s = session();
|
|
let id = user_slice(&mut s, [250.0, 250.0, 100.0, 100.0]);
|
|
assert_eq!(rect_of(&mut s, id), [250.0, 250.0, 50.0, 50.0], "clipped to the artboard");
|
|
assert_eq!(run(&mut s, "object.slice.clipToArtboard", json!({}))["on"], false);
|
|
assert_eq!(rect_of(&mut s, id), [250.0, 250.0, 100.0, 100.0]);
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert!(s.doc().unwrap().doc.slices_clip_to_artboard);
|
|
assert_eq!(run(&mut s, "object.slice.clipToArtboard", json!({"on": true}))["on"], true);
|
|
|
|
assert_eq!(run(&mut s, "view.slices.hide", json!({}))["hidden"], true);
|
|
assert!(s.slices_hidden());
|
|
assert_eq!(run(&mut s, "view.slices.lock", json!({"locked": true}))["locked"], true);
|
|
let l = run(&mut s, "slice.list", json!({}));
|
|
assert_eq!((l["hidden"].clone(), l["locked"].clone(), l["clipToArtboard"].clone()), (json!(true), json!(true), json!(true)));
|
|
run(&mut s, "view.slices.hide", json!({"hidden": false}));
|
|
assert!(!s.slices_hidden());
|
|
|
|
let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
|
run(&mut s, "object.slice.make", json!({}));
|
|
assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 2);
|
|
let sel = &s.doc().unwrap().selection;
|
|
assert_eq!((sel.slices.clone(), sel.objects.is_empty()), (vec![NodeId(id), NodeId(a)], true));
|
|
assert!(slices(&mut s).iter().filter(|v| v["selected"] == true).count() == 2);
|
|
// Selecting objects drops the slice selection.
|
|
run(&mut s, "select.all", json!({}));
|
|
assert!(s.doc().unwrap().selection.slices.is_empty());
|
|
|
|
assert_eq!(run(&mut s, "object.slice.deleteAll", json!({}))["count"], 2);
|
|
assert_eq!(user_count(&s), 0);
|
|
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none());
|
|
assert!(s.execute("object.slice.deleteAll", &json!({})).is_err(), "nothing to delete");
|
|
}
|
|
|
|
#[test]
|
|
fn slices_survive_a_native_round_trip() {
|
|
let mut s = session();
|
|
user_slice(&mut s, [100.0, 100.0, 40.0, 40.0]);
|
|
run(&mut s, "select.object.slices", json!({}));
|
|
run(&mut s, "object.slice.options", json!({"kind": "noImage", "text": "x"}));
|
|
let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
|
run(&mut s, "object.slice.make", json!({}));
|
|
run(&mut s, "object.slice.options", json!({"alt": "logo"}));
|
|
run(&mut s, "object.slice.clipToArtboard", json!({"on": false}));
|
|
let doc = s.doc().unwrap().doc.clone();
|
|
vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap();
|
|
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
|
assert_eq!(back.slices, doc.slices);
|
|
assert_eq!(back.node(NodeId(a)).unwrap().slice.as_ref().unwrap().alt, "logo");
|
|
assert!(!back.slices_clip_to_artboard);
|
|
// A copy of an object slice is an object slice too, under its own id.
|
|
run(&mut s, "select.set", json!({"ids": [a]}));
|
|
run(&mut s, "edit.duplicate", json!({}));
|
|
assert_eq!(s.doc().unwrap().doc.object_slices().len(), 2);
|
|
}
|