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:
parent
271b0c6c25
commit
fbc7549c31
|
|
@ -129,6 +129,51 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
list
|
list
|
||||||
),
|
),
|
||||||
cmd!("select.object.slices", "Slices", ["Select", "Object"], None, "{} select every user and object slice → {count}", has_doc, select_all),
|
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 ----------
|
// ---------- 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
|
/// The selected slices: those the Slice Selection tool selected and the selected objects that are
|
||||||
/// object slices.
|
/// object slices.
|
||||||
fn selected(s: &Session) -> Result<Vec<NodeId>> {
|
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))?;
|
s.select(|_, sel| sel.set_slices(ids))?;
|
||||||
Ok(json!({ "count": n }))
|
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) }))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -250,3 +250,80 @@ fn slices_survive_a_native_round_trip() {
|
||||||
run(&mut s, "edit.duplicate", json!({}));
|
run(&mut s, "edit.duplicate", json!({}));
|
||||||
assert_eq!(s.doc().unwrap().doc.object_slices().len(), 2);
|
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!([]));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ impl Session {
|
||||||
unit,
|
unit,
|
||||||
stroke_unit,
|
stroke_unit,
|
||||||
paste_plain_text: self.prefs.paste_text_formatting == "plain",
|
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;
|
let tool = &mut self.tool;
|
||||||
match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) {
|
match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ pub mod pen;
|
||||||
pub mod place;
|
pub mod place;
|
||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod shape;
|
pub mod shape;
|
||||||
|
pub mod slice;
|
||||||
pub mod symbolism;
|
pub mod symbolism;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
pub mod xform;
|
pub mod xform;
|
||||||
|
|
@ -209,6 +210,10 @@ pub struct ToolContext<'a> {
|
||||||
/// Clipboard Handling → When pasting text: Keep Plain Text. Text pasted while typing takes
|
/// Clipboard Handling → When pasting text: Keep Plain Text. Text pasted while typing takes
|
||||||
/// the style at the caret, even the text the Type tool copied with its formatting.
|
/// the style at the caret, even the text the Type tool copied with its formatting.
|
||||||
pub paste_plain_text: bool,
|
pub paste_plain_text: bool,
|
||||||
|
/// View → Hide Slices: the Slice Selection tool can't pick hidden slices.
|
||||||
|
pub slices_hidden: bool,
|
||||||
|
/// View → Lock Slices: the Slice Selection tool leaves locked slices alone.
|
||||||
|
pub slices_locked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolContext<'_> {
|
impl ToolContext<'_> {
|
||||||
|
|
@ -286,6 +291,10 @@ pub enum Cursor {
|
||||||
AddStop,
|
AddStop,
|
||||||
/// A gradient stop dragged off the bar: releasing deletes it.
|
/// A gradient stop dragged off the bar: releasing deletes it.
|
||||||
RemoveStop,
|
RemoveStop,
|
||||||
|
/// The Slice tool: a crosshair with a blade.
|
||||||
|
Slice,
|
||||||
|
/// The Slice Selection tool: the arrow with a slice badge.
|
||||||
|
SliceSelect,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tool state machine.
|
/// A tool state machine.
|
||||||
|
|
@ -347,6 +356,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
|
||||||
.or_else(|| meshblend::create(other))
|
.or_else(|| meshblend::create(other))
|
||||||
.or_else(|| distort::create(other))
|
.or_else(|| distort::create(other))
|
||||||
.or_else(|| extra::create(other))
|
.or_else(|| extra::create(other))
|
||||||
|
.or_else(|| slice::create(other))
|
||||||
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
|
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -409,6 +419,8 @@ pub(crate) mod testutil {
|
||||||
unit: Unit::Points,
|
unit: Unit::Points,
|
||||||
stroke_unit: Unit::Points,
|
stroke_unit: Unit::Points,
|
||||||
paste_plain_text: false,
|
paste_plain_text: false,
|
||||||
|
slices_hidden: false,
|
||||||
|
slices_locked: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,12 +80,7 @@ impl ShapeTool {
|
||||||
("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))
|
("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let mut d = p - start;
|
let r = drag_rect(start, p, m);
|
||||||
if m.shift {
|
|
||||||
let s = d.x.abs().max(d.y.abs());
|
|
||||||
d = vectorcraft_geom::Vec2::new(s * d.x.signum(), s * d.y.signum());
|
|
||||||
}
|
|
||||||
let r = if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) };
|
|
||||||
let cmd = if self.id == "ellipse" { "shape.ellipse" } else { "shape.rectangle" };
|
let cmd = if self.id == "ellipse" { "shape.ellipse" } else { "shape.rectangle" };
|
||||||
let mut v = json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() });
|
let mut v = json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() });
|
||||||
if self.id == "roundedRectangle" {
|
if self.id == "roundedRectangle" {
|
||||||
|
|
@ -97,6 +92,17 @@ impl ShapeTool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The rectangle a drag from `start` to `p` draws: Shift makes it a square, Alt draws it from its
|
||||||
|
/// centre.
|
||||||
|
pub(crate) fn drag_rect(start: Point, p: Point, m: Mods) -> Rect {
|
||||||
|
let mut d = p - start;
|
||||||
|
if m.shift {
|
||||||
|
let s = d.x.abs().max(d.y.abs());
|
||||||
|
d = vectorcraft_geom::Vec2::new(s * d.x.signum(), s * d.y.signum());
|
||||||
|
}
|
||||||
|
if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }
|
||||||
|
}
|
||||||
|
|
||||||
impl Tool for ShapeTool {
|
impl Tool for ShapeTool {
|
||||||
fn id(&self) -> &'static str {
|
fn id(&self) -> &'static str {
|
||||||
self.id
|
self.id
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,432 @@
|
||||||
|
//! Slice tool (Shift+K) and Slice Selection tool.
|
||||||
|
//!
|
||||||
|
//! The Slice tool drags out a user slice (Shift: a square, Alt: from its centre), snapping to the
|
||||||
|
//! ruler guides and, like the drawing tools, to art: the drag previews `object.slice.create` and
|
||||||
|
//! commits it as one undo step. The Slice Selection tool clicks a slice to select it (Shift adds or
|
||||||
|
//! drops it), drags the selected slices to move them (`object.slice.move`: an object slice moves
|
||||||
|
//! its object), drags a selected user slice's edge or corner handle to resize it
|
||||||
|
//! (`object.slice.setRect`), opens Slice Options on a double click and deletes the selected slices
|
||||||
|
//! with Delete. Hidden or locked slices (View menu) can't be picked.
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use vectorcraft_doc::{NodeId, SliceSource};
|
||||||
|
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||||
|
|
||||||
|
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
|
||||||
|
use crate::shape::drag_rect;
|
||||||
|
use crate::xform::{BLUE, polygon, rect_corners};
|
||||||
|
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids};
|
||||||
|
|
||||||
|
/// Create a slice tool by id (None = not one of ours).
|
||||||
|
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||||
|
Some(match id {
|
||||||
|
"slice" => Box::new(SliceTool::default()),
|
||||||
|
"sliceSelection" => Box::new(SliceSelectionTool::default()),
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rect_json(r: Rect) -> Value {
|
||||||
|
json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rectangle with a size (a drag along one axis has none).
|
||||||
|
fn has_area(r: Rect) -> bool {
|
||||||
|
r.width() > 1e-6 && r.height() > 1e-6
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snap `p` as the drawing tools do, then to the nearest ruler guide within reach (guides win).
|
||||||
|
fn snap(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
|
||||||
|
let (mut q, ov) = crate::guides::snap_draw(cx, p, &[]);
|
||||||
|
let tol = cx.tol(5.0);
|
||||||
|
let nearest = |vertical: bool, v: f64| {
|
||||||
|
cx.doc
|
||||||
|
.guides
|
||||||
|
.iter()
|
||||||
|
.filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol)
|
||||||
|
.min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs()))
|
||||||
|
};
|
||||||
|
if let Some(g) = nearest(true, p.x) {
|
||||||
|
q.x = g.pos;
|
||||||
|
}
|
||||||
|
if let Some(g) = nearest(false, p.y) {
|
||||||
|
q.y = g.pos;
|
||||||
|
}
|
||||||
|
(q, ov)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Slice tool: drag to make a user slice.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SliceTool {
|
||||||
|
start: Option<Point>,
|
||||||
|
last: Point,
|
||||||
|
began: bool,
|
||||||
|
guides: Vec<Overlay>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tool for SliceTool {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"slice"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn busy(&self) -> bool {
|
||||||
|
self.start.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||||
|
match ev.kind {
|
||||||
|
PointerKind::Down => {
|
||||||
|
let (p, _) = snap(cx, ev.pos);
|
||||||
|
(self.start, self.last, self.began) = (Some(p), p, false);
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
PointerKind::Drag => {
|
||||||
|
let Some(start) = self.start else { return vec![] };
|
||||||
|
let (p, guides) = snap(cx, ev.pos);
|
||||||
|
(self.last, self.guides) = (p, guides);
|
||||||
|
let mut out = vec![];
|
||||||
|
if !self.began {
|
||||||
|
if p.distance(start) < cx.tol(2.0) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
self.began = true;
|
||||||
|
out.push(Action::Begin("Slice".into()));
|
||||||
|
}
|
||||||
|
let r = drag_rect(start, p, ev.mods);
|
||||||
|
if has_area(r) {
|
||||||
|
out.push(Action::Preview("object.slice.create".into(), rect_json(r)));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
PointerKind::Up => {
|
||||||
|
self.guides.clear();
|
||||||
|
let began = std::mem::take(&mut self.began);
|
||||||
|
if self.start.take().is_some() && began { vec![Action::Commit] } else { vec![] }
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||||
|
if key == ToolKey::Escape && self.began { self.deactivate(_cx) } else { vec![] }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||||
|
match self.start {
|
||||||
|
Some(s) if self.began => {
|
||||||
|
let d = self.last - s;
|
||||||
|
let mut o = self.guides.clone();
|
||||||
|
o.push(Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) });
|
||||||
|
o
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||||
|
Cursor::Slice
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||||
|
self.start = None;
|
||||||
|
self.guides.clear();
|
||||||
|
if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum Drag {
|
||||||
|
Move { start: Point, ids: Vec<NodeId>, began: bool },
|
||||||
|
Resize { id: NodeId, handle: Handle, rect: Rect, began: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drag {
|
||||||
|
fn began(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Drag::Move { began, .. } | Drag::Resize { began, .. } => *began,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Slice Selection tool: select, move, resize and delete slices.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SliceSelectionTool {
|
||||||
|
drag: Option<Drag>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can the slices be picked (not hidden or locked)?
|
||||||
|
fn pickable(cx: &ToolContext) -> bool {
|
||||||
|
!cx.slices_hidden && !cx.slices_locked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user or object slice under `p` (the smallest one where several overlap).
|
||||||
|
fn slice_at(cx: &ToolContext, p: Point) -> Option<NodeId> {
|
||||||
|
let lay = cx.doc.slice_layout();
|
||||||
|
let hit = lay.iter().filter(|a| a.source != SliceSource::Auto && a.rect.contains(p));
|
||||||
|
hit.min_by(|a, b| a.rect.area().total_cmp(&b.rect.area())).and_then(|a| a.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handle of a selected user slice under `p`: (slice, handle, its rectangle).
|
||||||
|
fn handle_at(cx: &ToolContext, p: Point) -> Option<(NodeId, Handle, Rect)> {
|
||||||
|
cx.selection.slices.iter().filter_map(|id| cx.doc.slice(*id)).find_map(|s| hit_handle(s.rect, p, cx.tol(5.0)).map(|h| (s.id, h, s.rect)))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SliceSelectionTool {
|
||||||
|
/// Select `ids` only.
|
||||||
|
fn select(ids: &[NodeId]) -> Action {
|
||||||
|
Action::Exec("object.slice.select".into(), json!({ "slices": json_ids(ids) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tool for SliceSelectionTool {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"sliceSelection"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn busy(&self) -> bool {
|
||||||
|
self.drag.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||||
|
let (p, m) = (ev.pos, ev.mods);
|
||||||
|
match (ev.kind, self.drag.clone()) {
|
||||||
|
(PointerKind::Down, _) => {
|
||||||
|
self.drag = None;
|
||||||
|
if !pickable(cx) {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
if let Some((id, handle, rect)) = handle_at(cx, p) {
|
||||||
|
self.drag = Some(Drag::Resize { id, handle, rect, began: false });
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let selected = &cx.selection.slices;
|
||||||
|
match slice_at(cx, p) {
|
||||||
|
Some(id) if m.shift => vec![Action::Exec("object.slice.select".into(), json!({ "slices": [id.0], "toggle": true }))],
|
||||||
|
Some(id) if selected.contains(&id) => {
|
||||||
|
self.drag = Some(Drag::Move { start: p, ids: selected.clone(), began: false });
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
Some(id) => {
|
||||||
|
self.drag = Some(Drag::Move { start: p, ids: vec![id], began: false });
|
||||||
|
vec![Self::select(&[id])]
|
||||||
|
}
|
||||||
|
None if !m.shift && !selected.is_empty() => vec![Self::select(&[])],
|
||||||
|
None => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(PointerKind::Drag, Some(Drag::Move { start, ids, began })) => {
|
||||||
|
let mut out = vec![];
|
||||||
|
if !began {
|
||||||
|
if p.distance(start) < cx.tol(3.0) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out.push(Action::Begin("Move Slice".into()));
|
||||||
|
}
|
||||||
|
let d = move_delta(start, p, m.shift);
|
||||||
|
out.push(Action::Preview("object.slice.move".into(), json!({ "slices": json_ids(&ids), "dx": d.x, "dy": d.y })));
|
||||||
|
self.drag = Some(Drag::Move { start, ids, began: true });
|
||||||
|
out
|
||||||
|
}
|
||||||
|
(PointerKind::Drag, Some(Drag::Resize { id, handle, rect, began })) => {
|
||||||
|
let mut out = vec![];
|
||||||
|
if !began {
|
||||||
|
out.push(Action::Begin("Resize Slice".into()));
|
||||||
|
}
|
||||||
|
self.drag = Some(Drag::Resize { id, handle, rect, began: true });
|
||||||
|
let r = scale_for_drag(rect, handle, p, m.shift, m.alt).transform_rect_bbox(rect);
|
||||||
|
if has_area(r) {
|
||||||
|
let mut v = rect_json(r);
|
||||||
|
v["id"] = json!(id.0);
|
||||||
|
out.push(Action::Preview("object.slice.setRect".into(), v));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
(PointerKind::Up, Some(d)) => {
|
||||||
|
self.drag = None;
|
||||||
|
if d.began() { vec![Action::Commit] } else { vec![] }
|
||||||
|
}
|
||||||
|
(PointerKind::DoubleClick, _) => {
|
||||||
|
self.drag = None;
|
||||||
|
match slice_at(cx, p).filter(|_| pickable(cx)) {
|
||||||
|
Some(id) if cx.selection.slices == [id] => vec![Action::Dialog("sliceOptions".into(), json!({}))],
|
||||||
|
Some(id) => vec![Self::select(&[id]), Action::Dialog("sliceOptions".into(), json!({}))],
|
||||||
|
None => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||||
|
matches!(key, ToolKey::Delete | ToolKey::Backspace) && !cx.selection.slices.is_empty() && pickable(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||||
|
match key {
|
||||||
|
ToolKey::Delete | ToolKey::Backspace if self.drag.is_none() && self.claims_key(cx, key) => {
|
||||||
|
vec![Action::Exec("object.slice.delete".into(), json!({}))]
|
||||||
|
}
|
||||||
|
ToolKey::Escape => self.deactivate(cx),
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||||
|
let mut o = vec![];
|
||||||
|
for id in &cx.selection.slices {
|
||||||
|
let Some(r) = cx.doc.slice_bounds(*id) else { continue };
|
||||||
|
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: false });
|
||||||
|
if cx.doc.slice(*id).is_some() && pickable(cx) {
|
||||||
|
o.extend(Handle::ALL.iter().map(|h| Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match &self.drag {
|
||||||
|
Some(Drag::Resize { id, began: true, .. }) => {
|
||||||
|
if let Some(r) = cx.doc.slice_bounds(*id) {
|
||||||
|
o.push(Overlay::Measure {
|
||||||
|
p: Point::new(r.x1, r.y1) + Vec2::new(cx.tol(12.0), cx.tol(12.0)),
|
||||||
|
text: cx.size_label(r.width(), r.height()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Drag::Move { ids, began: true, .. }) => {
|
||||||
|
if let Some(r) = ids.iter().filter_map(|id| cx.doc.slice_bounds(*id)).reduce(|a, b| a.union(b)) {
|
||||||
|
o.push(Overlay::Measure {
|
||||||
|
p: Point::new(r.x1, r.y1) + Vec2::new(cx.tol(12.0), cx.tol(12.0)),
|
||||||
|
text: cx.size_label(r.width(), r.height()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||||
|
match handle_at(cx, p).filter(|_| pickable(cx)).map(|(_, h, _)| h) {
|
||||||
|
Some(Handle::Top | Handle::Bottom) => Cursor::ResizeV,
|
||||||
|
Some(Handle::Left | Handle::Right) => Cursor::ResizeH,
|
||||||
|
Some(Handle::TopLeft | Handle::BottomRight) => Cursor::ResizeNwSe,
|
||||||
|
Some(Handle::TopRight | Handle::BottomLeft) => Cursor::ResizeNeSw,
|
||||||
|
None => Cursor::SliceSelect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||||
|
if self.drag.take().is_some_and(|d| d.began()) { vec![Action::Cancel] } else { vec![] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::testutil::*;
|
||||||
|
use vectorcraft_doc::{Document, Guide, Selection, Slice};
|
||||||
|
|
||||||
|
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||||
|
PointerEvent::new(kind, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with(kind: PointerKind, x: f64, y: f64, m: Mods) -> PointerEvent {
|
||||||
|
ev(kind, x, y).with_mods(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_preview(a: &[Action]) -> Option<Value> {
|
||||||
|
a.iter().find_map(|a| match a {
|
||||||
|
Action::Preview(c, v) if c == "object.slice.create" => Some(v.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_slice_tool_drags_out_a_slice_in_one_undo_step() {
|
||||||
|
let (mut d, _) = doc_with_rect();
|
||||||
|
d.guides.push(Guide { vertical: true, pos: 403.0 });
|
||||||
|
let s = Selection::default();
|
||||||
|
let p = paint();
|
||||||
|
let mut c = cx(&d, &s, &p);
|
||||||
|
c.smart_guides = false;
|
||||||
|
let mut t = SliceTool::default();
|
||||||
|
assert!(t.pointer(&c, &ev(PointerKind::Down, 300.0, 300.0)).is_empty());
|
||||||
|
assert!(t.pointer(&c, &ev(PointerKind::Drag, 300.5, 300.0)).is_empty(), "a jitter is no drag");
|
||||||
|
let a = t.pointer(&c, &ev(PointerKind::Drag, 350.0, 320.0));
|
||||||
|
assert_eq!(a[0], Action::Begin("Slice".into()));
|
||||||
|
assert_eq!(create_preview(&a), Some(json!({"x": 300.0, "y": 300.0, "width": 50.0, "height": 20.0})));
|
||||||
|
assert!(matches!(t.overlays(&c).last(), Some(Overlay::Measure { .. })));
|
||||||
|
// Shift: a square; Alt: from the centre.
|
||||||
|
let sq = t.pointer(&c, &with(PointerKind::Drag, 350.0, 320.0, Mods { shift: true, ..Default::default() }));
|
||||||
|
assert_eq!(create_preview(&sq), Some(json!({"x": 300.0, "y": 300.0, "width": 50.0, "height": 50.0})));
|
||||||
|
let mid = t.pointer(&c, &with(PointerKind::Drag, 350.0, 320.0, Mods { alt: true, ..Default::default() }));
|
||||||
|
assert_eq!(create_preview(&mid), Some(json!({"x": 250.0, "y": 280.0, "width": 100.0, "height": 40.0})));
|
||||||
|
// The edge near a guide snaps to it.
|
||||||
|
let g = t.pointer(&c, &ev(PointerKind::Drag, 401.0, 320.0));
|
||||||
|
assert_eq!(create_preview(&g), Some(json!({"x": 300.0, "y": 300.0, "width": 103.0, "height": 20.0})));
|
||||||
|
// A drag along one axis has no area: no preview.
|
||||||
|
assert_eq!(create_preview(&t.pointer(&c, &ev(PointerKind::Drag, 350.0, 300.0))), None);
|
||||||
|
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 350.0, 320.0)), vec![Action::Commit]);
|
||||||
|
assert!(!t.busy());
|
||||||
|
// A click makes nothing; Escape cancels a drag.
|
||||||
|
t.pointer(&c, &ev(PointerKind::Down, 10.0, 10.0));
|
||||||
|
assert!(t.pointer(&c, &ev(PointerKind::Up, 10.0, 10.0)).is_empty());
|
||||||
|
t.pointer(&c, &ev(PointerKind::Down, 10.0, 10.0));
|
||||||
|
t.pointer(&c, &ev(PointerKind::Drag, 40.0, 40.0));
|
||||||
|
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||||
|
assert_eq!(t.cursor(&c, Point::ZERO, Mods::default()), Cursor::Slice);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc_with_slices() -> (Document, NodeId, NodeId) {
|
||||||
|
let (mut d, _) = doc_with_rect();
|
||||||
|
let a = d.alloc_id();
|
||||||
|
d.slices.push(Slice { id: a, rect: Rect::new(10.0, 10.0, 60.0, 40.0), options: Default::default() });
|
||||||
|
let b = d.alloc_id();
|
||||||
|
d.slices.push(Slice { id: b, rect: Rect::new(300.0, 300.0, 400.0, 400.0), options: Default::default() });
|
||||||
|
(d, a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_slice_selection_tool_selects_moves_resizes_and_deletes() {
|
||||||
|
let (d, a, b) = doc_with_slices();
|
||||||
|
let p = paint();
|
||||||
|
let none = Selection::default();
|
||||||
|
let c = cx(&d, &none, &p);
|
||||||
|
let mut t = SliceSelectionTool::default();
|
||||||
|
// A click selects the slice under the pointer; a drag moves it as one gesture.
|
||||||
|
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 20.0, 20.0)), vec![Action::Exec("object.slice.select".into(), json!({"slices": [a.0]}))]);
|
||||||
|
let mut sel = Selection::default();
|
||||||
|
sel.set_slices([a]);
|
||||||
|
let c = cx(&d, &sel, &p);
|
||||||
|
let m = t.pointer(&c, &ev(PointerKind::Drag, 30.0, 25.0));
|
||||||
|
assert_eq!(
|
||||||
|
m,
|
||||||
|
vec![Action::Begin("Move Slice".into()), Action::Preview("object.slice.move".into(), json!({"slices": [a.0], "dx": 10.0, "dy": 5.0}))]
|
||||||
|
);
|
||||||
|
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 30.0, 25.0)), vec![Action::Commit]);
|
||||||
|
// Shift-click toggles another slice.
|
||||||
|
let shift = Mods { shift: true, ..Default::default() };
|
||||||
|
assert_eq!(
|
||||||
|
t.pointer(&c, &with(PointerKind::Down, 350.0, 350.0, shift)),
|
||||||
|
vec![Action::Exec("object.slice.select".into(), json!({"slices": [b.0], "toggle": true}))]
|
||||||
|
);
|
||||||
|
t.pointer(&c, &ev(PointerKind::Up, 350.0, 350.0));
|
||||||
|
// A corner handle of the selected user slice resizes it.
|
||||||
|
assert_eq!(t.cursor(&c, Point::new(60.0, 40.0), Mods::default()), Cursor::ResizeNwSe);
|
||||||
|
assert!(t.pointer(&c, &ev(PointerKind::Down, 60.0, 40.0)).is_empty());
|
||||||
|
let r = t.pointer(&c, &ev(PointerKind::Drag, 80.0, 50.0));
|
||||||
|
assert_eq!(r[0], Action::Begin("Resize Slice".into()));
|
||||||
|
assert_eq!(r[1], Action::Preview("object.slice.setRect".into(), json!({"id": a.0, "x": 10.0, "y": 10.0, "width": 70.0, "height": 40.0})));
|
||||||
|
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 80.0, 50.0)), vec![Action::Commit]);
|
||||||
|
assert!(t.overlays(&c).iter().filter(|o| matches!(o, Overlay::Anchor { .. })).count() == 8);
|
||||||
|
// A double click opens Slice Options; Delete deletes the selected slices.
|
||||||
|
assert_eq!(t.pointer(&c, &ev(PointerKind::DoubleClick, 20.0, 20.0)), vec![Action::Dialog("sliceOptions".into(), json!({}))]);
|
||||||
|
assert!(t.claims_key(&c, ToolKey::Delete));
|
||||||
|
assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("object.slice.delete".into(), json!({}))]);
|
||||||
|
// A click on no slice deselects.
|
||||||
|
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 200.0, 5.0)), vec![Action::Exec("object.slice.select".into(), json!({"slices": []}))]);
|
||||||
|
// Locked slices can't be picked.
|
||||||
|
let mut locked = cx(&d, &sel, &p);
|
||||||
|
locked.slices_locked = true;
|
||||||
|
assert!(t.pointer(&locked, &ev(PointerKind::Down, 20.0, 20.0)).is_empty());
|
||||||
|
assert!(!t.claims_key(&locked, ToolKey::Delete));
|
||||||
|
assert_eq!(t.cursor(&locked, Point::new(60.0, 40.0), Mods::default()), Cursor::SliceSelect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -369,6 +369,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||||
Cursor::NotAllowed => C::NotAllowed,
|
Cursor::NotAllowed => C::NotAllowed,
|
||||||
Cursor::AddStop => C::Copy,
|
Cursor::AddStop => C::Copy,
|
||||||
Cursor::RemoveStop => C::NotAllowed,
|
Cursor::RemoveStop => C::NotAllowed,
|
||||||
|
Cursor::Slice => C::Crosshair,
|
||||||
|
Cursor::SliceSelect => C::Default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,21 @@ fn stop_badge(p: &Painter, o: Pos2, add: bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A slice badge: a small rectangle cut by a line, at `b` (its top left).
|
||||||
|
fn slice_badge(p: &Painter, b: Pos2) {
|
||||||
|
let pts = vec![b, b + vec2(8.0, 0.0), b + vec2(8.0, 6.0), b + vec2(0.0, 6.0)];
|
||||||
|
poly(p, pts, HALO, INK);
|
||||||
|
line(p, b + vec2(4.0, 0.0), b + vec2(4.0, 6.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Slice tool: a crosshair with a blade below right of the hotspot.
|
||||||
|
fn slice(p: &Painter, o: Pos2) {
|
||||||
|
crosshair(p, o);
|
||||||
|
let b = o + vec2(7.0, 7.0);
|
||||||
|
poly(p, vec![b, b + vec2(9.0, 4.0), b + vec2(10.0, 7.0), b + vec2(3.0, 6.0)], HALO, INK);
|
||||||
|
line(p, b + vec2(8.0, 6.0), b + vec2(12.0, 12.0));
|
||||||
|
}
|
||||||
|
|
||||||
fn ibeam(p: &Painter, o: Pos2) {
|
fn ibeam(p: &Painter, o: Pos2) {
|
||||||
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
|
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
|
||||||
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
|
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
|
||||||
|
|
@ -150,6 +165,11 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
|
||||||
Cursor::Text => ibeam(painter, p),
|
Cursor::Text => ibeam(painter, p),
|
||||||
Cursor::AddStop => stop_badge(painter, p, true),
|
Cursor::AddStop => stop_badge(painter, p, true),
|
||||||
Cursor::RemoveStop => stop_badge(painter, p, false),
|
Cursor::RemoveStop => stop_badge(painter, p, false),
|
||||||
|
Cursor::Slice => slice(painter, p),
|
||||||
|
Cursor::SliceSelect => {
|
||||||
|
arrow(painter, p, false);
|
||||||
|
slice_badge(painter, p + vec2(11.0, 14.0));
|
||||||
|
}
|
||||||
_ => return false,
|
_ => return false,
|
||||||
}
|
}
|
||||||
let _ = pos2;
|
let _ = pos2;
|
||||||
|
|
|
||||||
|
|
@ -72,3 +72,31 @@ fn slice_menu_items_follow_the_state() {
|
||||||
app.run("view.slices.lock", json!({})).unwrap();
|
app.run("view.slices.lock", json!({})).unwrap();
|
||||||
assert_eq!(crate::menus::checked(&app, "view.slices.lock", &json!({})), Some(true));
|
assert_eq!(crate::menus::checked(&app, "view.slices.lock", &json!({})), Some(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slice_tool_cursors_are_drawn_in_code() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||||
|
for c in [vectorcraft_tools::Cursor::Slice, vectorcraft_tools::Cursor::SliceSelect] {
|
||||||
|
assert!(crate::cursors::paint(ui.painter(), c, egui::pos2(50.0, 50.0)), "{c:?}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out.textures_delta.clear();
|
||||||
|
assert!(!out.shapes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_slice_tools_are_real_tools() {
|
||||||
|
let mut app = app();
|
||||||
|
for t in ["slice", "sliceSelection"] {
|
||||||
|
app.run("tool.select", json!({"tool": t})).unwrap();
|
||||||
|
assert_eq!(app.session.tool_id(), t);
|
||||||
|
}
|
||||||
|
// Double-clicking a slice opens Slice Options.
|
||||||
|
let id = app.run("object.slice.create", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||||
|
let view = app.view_info();
|
||||||
|
let ev = vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::DoubleClick, 20.0, 20.0);
|
||||||
|
crate::canvas::dispatch(&mut app, &ev, view);
|
||||||
|
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::slices::OPTIONS));
|
||||||
|
assert_eq!(app.session.active().unwrap().selection.slices, vec![vectorcraft_doc::NodeId(id)]);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -325,3 +325,8 @@ selected slices. `sliceOptions` (fields `kind`: `image`/`noImage`/`htmlText`, `n
|
||||||
runs `object.slice.options` on `ui.dialog.confirm`; `divideSlices` (fields `divideRows`, `rows`, `rowMode`:
|
runs `object.slice.options` on `ui.dialog.confirm`; `divideSlices` (fields `divideRows`, `rows`, `rowMode`:
|
||||||
`count`/`size`, `rowHeight`, and `divideColumns`, `columns`, `columnMode`, `columnWidth`) runs `object.slice.divide`.
|
`count`/`size`, `rowHeight`, and `divideColumns`, `columns`, `columnMode`, `columnWidth`) runs `object.slice.divide`.
|
||||||
Read slice options with `slice.list`.
|
Read slice options with `slice.list`.
|
||||||
|
|
||||||
|
The Slice tools: `ui.pointer` drags with the `slice` tool make a user slice (Shift square, Alt from the centre; one
|
||||||
|
undo step). With `sliceSelection` a click selects a slice (Shift toggles), a drag moves the selected slices, a drag on
|
||||||
|
a selected user slice's handle resizes it, a double click opens `sliceOptions` and `ui.key` Delete deletes the
|
||||||
|
selected slices. Hidden or locked slices (`view.slices.hide`, `view.slices.lock`) can't be picked.
|
||||||
|
|
|
||||||
11
docs/mcp.md
11
docs/mcp.md
|
|
@ -1138,3 +1138,14 @@ the `sliceLineColor` preference, numbered while `showSliceNumbers` is on.
|
||||||
{"name":"run_command","arguments":{"command":"object.slice.options","params":{"name":"logo","url":"https://example.com","alt":"Logo"}}}
|
{"name":"run_command","arguments":{"command":"object.slice.options","params":{"name":"logo","url":"https://example.com","alt":"Logo"}}}
|
||||||
{"name":"run_command","arguments":{"command":"slice.list","params":{}}}
|
{"name":"run_command","arguments":{"command":"slice.list","params":{}}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Slice tool (`slice`, Shift+K) and the Slice Selection tool (`sliceSelection`) reduce to commands:
|
||||||
|
`object.slice.create {x, y, width, height}` (the Slice tool's drag; it snaps to ruler guides), `object.slice.select
|
||||||
|
{slices?, toggle?}`, `object.slice.move {slices?, dx, dy}` (an object slice moves its object),
|
||||||
|
`object.slice.setRect {id, x, y, width, height}` (user slices) and `object.slice.delete {slices?}`. Slice ids are user
|
||||||
|
slice ids and the ids of objects with an object slice.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"run_command","arguments":{"command":"object.slice.create","params":{"x":0,"y":0,"width":200,"height":80}}}
|
||||||
|
{"name":"run_command","arguments":{"command":"object.slice.move","params":{"dx":10,"dy":0}}}
|
||||||
|
```
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue