From fbc7549c31351e4791d93824e23b868b92bd65eb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 12:41:16 +0700 Subject: [PATCH] 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 --- crates/engine/src/cmd/slices.rs | 150 ++++++++++ crates/engine/src/tests_slices.rs | 77 +++++ crates/engine/src/tooling.rs | 2 + crates/tools/src/lib.rs | 12 + crates/tools/src/shape.rs | 18 +- crates/tools/src/slice.rs | 432 +++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/cursors.rs | 20 ++ crates/ui-egui/src/tests_slices.rs | 28 ++ docs/control-protocol.md | 5 + docs/mcp.md | 11 + 11 files changed, 751 insertions(+), 6 deletions(-) create mode 100644 crates/tools/src/slice.rs diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 4482955c..189b0b5e 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -129,6 +129,51 @@ pub fn specs() -> Vec { 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> { + 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> { @@ -565,3 +622,96 @@ fn select_all(s: &mut Session, _: &Value) -> Result { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) })) +} diff --git a/crates/engine/src/tests_slices.rs b/crates/engine/src/tests_slices.rs index 67a2f941..ab0a4c08 100644 --- a/crates/engine/src/tests_slices.rs +++ b/crates/engine/src/tests_slices.rs @@ -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 = 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!([])); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 01d6ed61..0f0b2ee4 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -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)) { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 70002c16..ba23828c 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -22,6 +22,7 @@ pub mod pen; pub mod place; pub mod select; pub mod shape; +pub mod slice; pub mod symbolism; pub mod text; pub mod xform; @@ -209,6 +210,10 @@ pub struct ToolContext<'a> { /// 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. 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<'_> { @@ -286,6 +291,10 @@ pub enum Cursor { AddStop, /// A gradient stop dragged off the bar: releasing deletes it. 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. @@ -347,6 +356,7 @@ pub fn create(id: &str) -> Box { .or_else(|| meshblend::create(other)) .or_else(|| distort::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")))), } } @@ -409,6 +419,8 @@ pub(crate) mod testutil { unit: Unit::Points, stroke_unit: Unit::Points, paste_plain_text: false, + slices_hidden: false, + slices_locked: false, } } } diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index 0afb0525..30e5b4c1 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -80,12 +80,7 @@ impl ShapeTool { ("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y })) } _ => { - 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()); - } - let r = if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }; + let r = drag_rect(start, p, m); 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() }); 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 { fn id(&self) -> &'static str { self.id diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs new file mode 100644 index 00000000..c31d1906 --- /dev/null +++ b/crates/tools/src/slice.rs @@ -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> { + 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) { + 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, + last: Point, + began: bool, + guides: Vec, +} + +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 { + 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 { + if key == ToolKey::Escape && self.began { self.deactivate(_cx) } else { vec![] } + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + 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 { + 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, 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, +} + +/// 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f7a40fb9..39a121ff 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -369,6 +369,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::NotAllowed => C::NotAllowed, Cursor::AddStop => C::Copy, Cursor::RemoveStop => C::NotAllowed, + Cursor::Slice => C::Crosshair, + Cursor::SliceSelect => C::Default, } } diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 45c817c1..7027e130 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -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) { 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)); @@ -150,6 +165,11 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool { Cursor::Text => ibeam(painter, p), Cursor::AddStop => stop_badge(painter, p, true), 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, } let _ = pos2; diff --git a/crates/ui-egui/src/tests_slices.rs b/crates/ui-egui/src/tests_slices.rs index 5a5180e4..4d61b024 100644 --- a/crates/ui-egui/src/tests_slices.rs +++ b/crates/ui-egui/src/tests_slices.rs @@ -72,3 +72,31 @@ fn slice_menu_items_follow_the_state() { app.run("view.slices.lock", json!({})).unwrap(); 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)]); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2cde208d..86200a06 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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`: `count`/`size`, `rowHeight`, and `divideColumns`, `columns`, `columnMode`, `columnWidth`) runs `object.slice.divide`. 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. diff --git a/docs/mcp.md b/docs/mcp.md index 7ecab56e..0230e53b 100644 --- a/docs/mcp.md +++ b/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":"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}}} +```