mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
- `Targets::align_rects` tells `snap_point_with` an axis lined up even when nothing moved or Alignment Guides hide the line, so the "align" label stays. - Free Transform's move offset is a measurement label (as the Selection tool's), its scale/rotate readouts Transform Tools; slice move/resize readouts follow Measurement Labels. - Snap to Point (Smart Guides off) keeps the default look for moves too, as for drawn points and guides. - Test: guides dragged out of a ruler (#451) follow Snapping Tolerance, Anchor/Path Labels and Measurement Labels; docs/mcp.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
436 lines
19 KiB
Rust
436 lines
19 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 }));
|
||
}
|
||
}
|
||
// Smart Guides › Measurement Labels: the size readouts while resizing or moving slices.
|
||
match self.drag.as_ref().filter(|_| cx.measurement_labels) {
|
||
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);
|
||
}
|
||
}
|