Files
vectorcraft/crates/tools/src/slice.rs
T
dotCooCoo a12af589aa Rotate View: resize and rotate cursors turn with the view
In a rotated view the resize and rotate cursors were the ones for an
unturned view. With the view turned 90°, the right-middle handle of a
selection's bounding box shows at the middle of the box's bottom edge
and drags up and down, but its cursor was the left-right arrow. This
affected the Selection, Free Transform, Artboard, Slice Selection, Crop
Image, Perspective Grid and Perspective Selection tools, ruler guides,
and the spread handle of a freeform gradient.

Now each of these cursors turns with the view to the nearest 45°. The
Selection tool and the Perspective Selection tool turn the handle's
direction by the view's angle before they round it to a cursor.
ToolContext::view_angle reads the view's angle from the screen frame,
and Cursor::turned turns a resize or rotate cursor by it. The new
Cursor::rotate_about_in_view turns the pointer's direction from the
center by the view before it picks one of the rotate cursor's eight
directions. Cursor::rotate_about keeps its signature and behavior. At
0° every cursor is as it was.

Docs: docs/target-app-parity.md.
2026-10-17 16:18:28 +07:00

441 lines
19 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,
}
.turned(cx.view_angle())
}
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);
// In a view turned 90° its cursor turns with the view.
let mut turned = cx(&d, &sel, &p);
turned.screen = turned_view(90.0);
assert_eq!(t.cursor(&turned, Point::new(60.0, 40.0), Mods::default()), Cursor::ResizeNeSw);
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);
}
}