Files
vectorcraft/crates/tools/src/slice.rs
T
wstockandClaude Fable 5.1 14d7b3ddce Part of #394: Smart Guides display options and Snapping Tolerance
Color, Alignment Guides, Anchor/Path Labels, Measurement Labels,
Transform Tools, Object Highlighting and Snapping Tolerance are read:

- tools: `guides::GuideStyle` on `Targets` (`styled(cx)`) colours the
  lines and labels and leaves them out when their option is off, the
  snap itself unchanged; `ToolContext` gains the six fields and
  `snap_tol()`, replacing the 5 px literal for every smart guide snap
  (drawn points, moves, bounding-box handles, ruler guides, artboards,
  reference points). The tools gate their `Overlay::Measure` readouts on
  Measurement Labels (drawing, moving, ruler guides, artboards) or
  Transform Tools (scaling, rotating, Free Transform, transform tools).
- engine: `with_tool_cx` fills them from `Prefs` (the colour parsed, magenta
  as fallback); the colour's default is `#ff3dfc`, what the tools drew.
- ui-egui: the hover outline needs Object Highlighting and Smart Guides.
- Tests in tools (guides, select), engine (tests_prefs) and the canvas;
  docs/mcp.md and ROADMAP.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-08 15:46:15 +07:00

435 lines
18 KiB
Rust

//! 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 && cx.doc.guide_passes(g, p, 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();
if cx.measurement_labels {
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::new(true, 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);
}
}