mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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]>
435 lines
18 KiB
Rust
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);
|
|
}
|
|
}
|