vectorcraft/crates/tools/src/slice.rs

436 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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_shown(g) && 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);
}
}