M4.89: Slice and Slice Selection tools draw, pick, move, resize and delete slices

The Slice tool (Shift+K) drags out a user slice (Shift square, Alt from the
centre, snapping to ruler guides) as one undo step through
object.slice.create. The Slice Selection tool selects slices (Shift
toggles, object.slice.select), moves them (object.slice.move; object
slices move their objects), resizes a user slice by its handles
(object.slice.setRect), opens Slice Options on a double click and deletes
with Delete (object.slice.delete). Hidden or locked slices can't be
picked. Both tools have cursors drawn in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 12:52:15 +07:00
co-authored by Claude Opus 5.5
parent 271b0c6c25
commit fbc7549c31
11 changed files with 751 additions and 6 deletions
+150
View File
@@ -129,6 +129,51 @@ pub fn specs() -> Vec<CommandSpec> {
list
),
cmd!("select.object.slices", "Slices", ["Select", "Object"], None, "{} select every user and object slice → {count}", has_doc, select_all),
cmd!(
"object.slice.create",
"Create Slice",
[],
None,
"{x, y, width, height} a user slice over that rectangle, selected (the Slice tool's drag) → {id}",
has_doc,
create
),
cmd!(
"object.slice.setRect",
"Set Slice Rectangle",
[],
None,
"{id: user slice, x, y, width, height} move or resize a user slice (an object slice follows its object) → {id}",
has_doc,
set_rect
),
cmd!(
"object.slice.move",
"Move Slices",
[],
None,
"{slices?: [id…] (default: the selected slices), dx, dy} move the slices: user slices move, object slices move their objects → {ids}",
has_doc,
move_slices
),
cmd!(
"object.slice.delete",
"Delete Slices",
[],
None,
"{slices?: [id…] (default: the selected slices)} delete the user slices and release the object slices (their objects stay) → {count}",
has_doc,
delete
),
cmd!(
"object.slice.select",
"Select Slices",
[],
None,
"{slices?: [id…] (user slice ids or ids of objects with an object slice; none: deselect the slices), toggle?: bool (Shift: add the unselected ones, drop the selected ones)} select slices as the Slice Selection tool does; the objects are deselected → {selected: [id…]}",
has_doc,
select
),
]
}
@@ -145,6 +190,18 @@ impl Session {
// ---------- targets and params ----------
/// The slices a command acts on: `slices` (ids that must all be slices), else the selected ones.
fn slice_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let st = s.doc()?;
match ids_param(p, "slices") {
Some(ids) => match ids.iter().find(|id| !st.doc.is_slice(**id)) {
Some(id) => Err(bad(cmd, format!("{id} is not a slice"))),
None => Ok(dedup(ids)),
},
None => Ok(selected_slices(&st.doc, &st.selection)),
}
}
/// The selected slices: those the Slice Selection tool selected and the selected objects that are
/// object slices.
fn selected(s: &Session) -> Result<Vec<NodeId>> {
@@ -565,3 +622,96 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
s.select(|_, sel| sel.set_slices(ids))?;
Ok(json!({ "count": n }))
}
// ---------- the Slice and Slice Selection tools ----------
/// `{x, y, width, height}` as a rectangle a slice can have.
fn rect_param(p: &Value, cmd: &str) -> Result<Rect> {
let (x, y) = (f64_req(p, "x", cmd)?, f64_req(p, "y", cmd)?);
let r = Rect::new(x, y, x + f64_req(p, "width", cmd)?, y + f64_req(p, "height", cmd)?).abs();
if slice_rect_ok(r) { Ok(r) } else { Err(bad(cmd, "the rectangle needs a size and must lie on the canvas")) }
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_param(p, "object.slice.create")?;
let id = s.edit("Slice", |d, sel| {
let id = add_slice(d, r, SliceOptions::default());
sel.set_slices([id]);
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
}
fn set_rect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.slice.setRect";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let r = rect_param(p, C)?;
let d = &s.doc()?.doc;
if d.slice(id).is_none() {
return Err(bad(C, if d.is_slice(id) { format!("{id} is an object slice: it follows its object") } else { format!("{id} is not a slice") }));
}
s.edit("Resize Slice", |d, _| {
if let Some(sl) = d.slice_mut(id) {
sl.rect = r;
}
Ok(())
})?;
Ok(json!({ "id": id.0 }))
}
fn move_slices(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.slice.move";
let targets = slice_targets(s, p, C)?;
let v = vectorcraft_geom::Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
if !(v.x.is_finite() && v.y.is_finite()) {
return Err(bad(C, "dx and dy must be numbers"));
}
s.edit("Move Slice", |d, _| {
for id in &targets {
match d.slice_mut(*id) {
Some(sl) if slice_rect_ok(sl.rect + v) => sl.rect = sl.rect + v,
Some(_) => return Err(bad(C, "the slice would leave the canvas")),
None => {
if let Some(n) = d.node_mut(*id) {
n.transform(vectorcraft_geom::Affine::translate(v), vectorcraft_doc::Scaling::default());
}
}
}
}
Ok(())
})?;
Ok(json!({ "ids": ids_json(&targets) }))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let targets = slice_targets(s, p, "object.slice.delete")?;
if targets.is_empty() {
return Ok(json!({ "count": 0 }));
}
s.edit("Delete Slice", |d, _| {
for id in &targets {
remove_slice(d, *id);
}
Ok(())
})?;
Ok(json!({ "count": targets.len() }))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let ids = slice_targets(s, &json!({ "slices": p.get("slices").cloned().unwrap_or(json!([])) }), "object.slice.select")?;
let toggle = bool_or(p, "toggle", false);
s.select(|_, sel| {
let mut now = if toggle { sel.slices.clone() } else { vec![] };
for id in ids {
match now.iter().position(|x| *x == id) {
Some(i) if toggle => {
now.remove(i);
}
Some(_) => {}
None => now.push(id),
}
}
sel.set_slices(now);
})?;
Ok(json!({ "selected": ids_json(&s.doc()?.selection.slices) }))
}
+77
View File
@@ -250,3 +250,80 @@ fn slices_survive_a_native_round_trip() {
run(&mut s, "edit.duplicate", json!({}));
assert_eq!(s.doc().unwrap().doc.object_slices().len(), 2);
}
#[test]
fn slice_tool_gestures_are_one_undo_step_each() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind as K, ToolKey};
let mut s = session();
let view = ViewInfo { smart_guides: false, ..Default::default() };
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let gesture = |s: &mut Session, pts: &[(K, f64, f64)]| {
for (k, x, y) in pts {
s.pointer(&PointerEvent::new(*k, *x, *y), view).unwrap();
}
};
s.select_tool("slice", view).unwrap();
let n = undo(&s);
gesture(&mut s, &[(K::Down, 10.0, 10.0), (K::Drag, 50.0, 30.0), (K::Drag, 110.0, 60.0), (K::Up, 110.0, 60.0)]);
assert_eq!(undo(&s), n + 1);
let id = s.doc().unwrap().doc.slices[0].id;
assert_eq!(s.doc().unwrap().doc.slices.len(), 1, "the previews left one slice");
assert_eq!(rect_of(&mut s, id.0), [10.0, 10.0, 100.0, 50.0]);
assert_eq!(s.doc().unwrap().selection.slices, vec![id]);
// Slice Selection: drag the slice, then its bottom-right handle, then Delete.
s.select_tool("sliceSelection", view).unwrap();
gesture(&mut s, &[(K::Down, 20.0, 20.0), (K::Drag, 30.0, 40.0), (K::Drag, 40.0, 50.0), (K::Up, 40.0, 50.0)]);
assert_eq!(undo(&s), n + 2);
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 100.0, 50.0]);
gesture(&mut s, &[(K::Down, 130.0, 90.0), (K::Drag, 140.0, 95.0), (K::Drag, 150.0, 100.0), (K::Up, 150.0, 100.0)]);
assert_eq!(undo(&s), n + 3);
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 120.0, 60.0]);
assert!(s.tool_claims_key(ToolKey::Delete, view));
s.tool_key(ToolKey::Delete, Mods::default(), view).unwrap();
assert!(s.doc().unwrap().doc.slices.is_empty());
assert_eq!(undo(&s), n + 4);
// Undo walks the gestures back one at a time.
run(&mut s, "edit.undo", json!({}));
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 120.0, 60.0]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 100.0, 50.0]);
// An object slice moves its object; locked slices stay put.
let a = rect(&mut s, 200.0, 200.0, 40.0, 40.0);
run(&mut s, "object.slice.make", json!({}));
gesture(&mut s, &[(K::Down, 220.0, 220.0), (K::Drag, 230.0, 220.0), (K::Drag, 240.0, 230.0), (K::Up, 240.0, 230.0)]);
assert_eq!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().geometric_bounds().unwrap().x0, 220.0);
run(&mut s, "view.slices.lock", json!({"locked": true}));
let before = undo(&s);
gesture(&mut s, &[(K::Down, 250.0, 240.0), (K::Drag, 280.0, 260.0), (K::Up, 280.0, 260.0)]);
assert_eq!(undo(&s), before);
// The journal replays the gestures as commands.
let journal: Vec<String> = s.journal.iter().map(|(c, _)| c.clone()).collect();
for c in ["object.slice.create", "object.slice.move", "object.slice.setRect", "object.slice.delete", "object.slice.select"] {
assert!(journal.iter().any(|j| j == c), "{c} journaled: {journal:?}");
}
}
#[test]
fn slice_commands_check_their_params() {
let mut s = session();
assert!(s.execute("object.slice.create", &json!({"x": 0, "y": 0, "width": 0, "height": 10})).is_err(), "no area");
assert!(s.execute("object.slice.create", &json!({"x": 1e9, "y": 0, "width": 10, "height": 10})).is_err(), "off the canvas");
let id = run(&mut s, "object.slice.create", json!({"x": 50, "y": 60, "width": -20, "height": 10}))["id"].as_u64().unwrap();
assert_eq!(rect_of(&mut s, id), [30.0, 60.0, 20.0, 10.0], "a negative size draws the other way");
run(&mut s, "object.slice.setRect", json!({"id": id, "x": 0, "y": 0, "width": 5, "height": 5}));
assert_eq!(rect_of(&mut s, id), [0.0, 0.0, 5.0, 5.0]);
let a = rect(&mut s, 100.0, 100.0, 10.0, 10.0);
run(&mut s, "object.slice.make", json!({}));
assert!(
s.execute("object.slice.setRect", &json!({"id": a, "x": 0, "y": 0, "width": 5, "height": 5})).is_err(),
"object slices follow their object"
);
assert!(s.execute("object.slice.move", &json!({"slices": [12345], "dx": 1, "dy": 1})).is_err(), "not a slice");
assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [id, a]}))["selected"], json!([id, a]));
assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [a], "toggle": true}))["selected"], json!([id]));
assert_eq!(run(&mut s, "object.slice.delete", json!({"slices": [a]}))["count"], 1);
assert!(s.doc().unwrap().doc.node(NodeId(a)).is_some(), "deleting an object slice keeps its object");
assert_eq!(run(&mut s, "object.slice.select", json!({}))["selected"], json!([]));
}
+2
View File
@@ -89,6 +89,8 @@ impl Session {
unit,
stroke_unit,
paste_plain_text: self.prefs.paste_text_formatting == "plain",
slices_hidden: self.menu.slices_hidden,
slices_locked: self.menu.slices_locked,
};
let tool = &mut self.tool;
match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) {