1317 lines
68 KiB
Rust
1317 lines
68 KiB
Rust
//! Direct Selection (A) and Group Selection tools.
|
||
//!
|
||
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
|
||
//! path's anchors on that segment, drag to move selected anchors (the one pressed on snaps to
|
||
//! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45°
|
||
//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it), drag a
|
||
//! direction handle to
|
||
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
|
||
//! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are
|
||
//! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners
|
||
//! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens
|
||
//! the Corners dialog).
|
||
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group;
|
||
//! its marquee works as Direct Selection's.
|
||
//! Both move objects selected as a whole as the Selection tool does, snapping alike.
|
||
//! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]).
|
||
//! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves
|
||
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
|
||
//! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient
|
||
//! mesh's point last clicked). Dragging a corner or an edge of area
|
||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size.
|
||
//! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]).
|
||
//!
|
||
//! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors
|
||
//! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else
|
||
//! moves its anchors.
|
||
|
||
use std::borrow::Cow;
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
|
||
use vectorcraft_geom::{Anchor, PathData, Point, Rect};
|
||
|
||
use crate::bbox::move_delta;
|
||
use crate::corners::{self, CornerDrag, over_widget};
|
||
use crate::guides::{HandleSnap, Leave, PointSnap, Targets};
|
||
use crate::meshedit::MeshEdit;
|
||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||
use crate::rulerguide::GuideEdit;
|
||
use crate::select::{MoveSnap, is_area_type, matrix_json};
|
||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||
|
||
#[derive(Clone, Debug)]
|
||
enum State {
|
||
Idle,
|
||
/// Dragging the selected anchors, which move alike: `grab` is where the anchor pressed on (or
|
||
/// the pointer on a segment) was, the point that snaps.
|
||
MoveAnchors {
|
||
start: Point,
|
||
grab: Point,
|
||
began: bool,
|
||
},
|
||
/// Moving (Alt: copying) the selected objects; `grab`: the anchor an Alt-press picked the
|
||
/// path by, which snaps as a dragged anchor does.
|
||
MoveObject {
|
||
start: Point,
|
||
grab: Option<Point>,
|
||
began: bool,
|
||
},
|
||
/// Dragging a curved segment: it follows the pointer (`path.reshapeSegment`).
|
||
Segment {
|
||
id: NodeId,
|
||
si: usize,
|
||
seg: usize,
|
||
t: f64,
|
||
start: Point,
|
||
began: bool,
|
||
},
|
||
Handle {
|
||
id: NodeId,
|
||
si: usize,
|
||
ai: usize,
|
||
out: bool,
|
||
},
|
||
Marquee {
|
||
start: Point,
|
||
cur: Point,
|
||
/// Shift: the anchors inside leave the selection if selected, else join it.
|
||
toggle: bool,
|
||
},
|
||
Corner(CornerDrag),
|
||
/// Dragging a bracket of type on a path.
|
||
Bracket(BracketDrag),
|
||
/// Dragging a point of a blend's spine from `from`.
|
||
SpinePoint {
|
||
id: NodeId,
|
||
anchor: usize,
|
||
from: Point,
|
||
start: Point,
|
||
began: bool,
|
||
},
|
||
/// Dragging a curved segment of a selected blend's spine: it follows the pointer
|
||
/// (`object.blend.spine.reshapeSegment`, #1111).
|
||
SpineSegment {
|
||
id: NodeId,
|
||
seg: usize,
|
||
t: f64,
|
||
start: Point,
|
||
began: bool,
|
||
},
|
||
/// Dragging a handle of the clicked spine point.
|
||
SpineHandle {
|
||
id: NodeId,
|
||
anchor: usize,
|
||
out: bool,
|
||
},
|
||
/// Dragging a mesh point or handle ([`MeshEdit`]).
|
||
Mesh,
|
||
/// Dragging anchors of area type's frame (a corner, or the two ends of an edge).
|
||
TypeArea {
|
||
id: NodeId,
|
||
anchors: Vec<AnchorRef>,
|
||
start: Point,
|
||
began: bool,
|
||
},
|
||
}
|
||
|
||
pub struct DirectSelectionTool {
|
||
group: bool,
|
||
state: State,
|
||
/// The spine point last clicked (blend, anchor): its handles show and can be dragged.
|
||
spine: Option<(NodeId, usize)>,
|
||
mesh: MeshEdit,
|
||
/// Smart guides of the handle, anchors or objects being dragged.
|
||
guides: Vec<Overlay>,
|
||
/// What the dragged anchors snap to, gathered when the drag begins.
|
||
anchor_snap: Option<PointSnap>,
|
||
/// What the anchor holding paths moved whole snaps to while Alt makes a copy: the original left
|
||
/// behind too. Gathered with [`Self::anchor_snap`], before the previews move the original.
|
||
copy_snap: Option<PointSnap>,
|
||
/// What the dragged handle snaps to.
|
||
handle_snap: HandleSnap,
|
||
/// What the objects being moved snap to, gathered when the move begins.
|
||
move_snap: Option<MoveSnap>,
|
||
/// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is.
|
||
hover: Option<(NodeId, Point)>,
|
||
guide: GuideEdit,
|
||
}
|
||
|
||
impl DirectSelectionTool {
|
||
pub fn new(group: bool) -> Self {
|
||
Self {
|
||
group,
|
||
state: State::Idle,
|
||
spine: None,
|
||
mesh: MeshEdit::default(),
|
||
guides: vec![],
|
||
anchor_snap: None,
|
||
copy_snap: None,
|
||
handle_snap: HandleSnap::default(),
|
||
move_snap: None,
|
||
hover: None,
|
||
guide: GuideEdit::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The spines of the editable blends, topmost first: (blend, spine).
|
||
pub(crate) fn spines(cx: &ToolContext) -> Vec<(NodeId, PathData)> {
|
||
let mut v = vec![];
|
||
cx.doc.walk(|n| {
|
||
if let NodeKind::Blend { children, spec } = &n.kind
|
||
&& let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec)
|
||
{
|
||
v.push((n.id, path));
|
||
}
|
||
});
|
||
v.reverse();
|
||
v.retain(|(id, _)| cx.doc.is_editable(*id));
|
||
v
|
||
}
|
||
|
||
/// The spine point under `p`: (blend, anchor, where it is).
|
||
fn hit_spine_point(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, Point)> {
|
||
spines(cx).into_iter().find_map(|(id, path)| {
|
||
let (_, ai, a) = path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol)?;
|
||
Some((id, ai, a.p))
|
||
})
|
||
}
|
||
|
||
/// The segment of a selected blend's spine passing within `tol` of `p`: (blend, segment, t, whether
|
||
/// it is straight).
|
||
pub(crate) fn hit_spine_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, f64, bool)> {
|
||
spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)).find_map(|(id, path)| {
|
||
let (si, seg, t, _, dist) = path.nearest(p)?;
|
||
(dist <= tol).then(|| (id, seg, t, path.subpaths.get(si).is_some_and(|sp| sp.segment_is_line(seg))))
|
||
})
|
||
}
|
||
|
||
/// The spine point `sel` and whether `p` is on its outgoing handle's end (`Some(true)`) or its
|
||
/// incoming one's.
|
||
fn hit_spine_handle(cx: &ToolContext, sel: (NodeId, usize), p: Point, tol: f64) -> Option<bool> {
|
||
let a = spine_anchor(cx, sel)?;
|
||
if a.has_out() && a.h_out.distance(p) <= tol {
|
||
return Some(true);
|
||
}
|
||
(a.has_in() && a.h_in.distance(p) <= tol).then_some(false)
|
||
}
|
||
|
||
/// Anchor `sel.1` of blend `sel.0`'s spine.
|
||
fn spine_anchor(cx: &ToolContext, (id, ai): (NodeId, usize)) -> Option<vectorcraft_geom::Anchor> {
|
||
match &cx.doc.node(id)?.kind {
|
||
NodeKind::Blend { children, spec } => vectorcraft_doc::live::blend_spine(children, spec)?.0.subpaths.first()?.anchors.get(ai).copied(),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The anchors Direct Selection edits on `n`, in document space: a path's, or area type's frame.
|
||
fn editable_path(n: &Node) -> Option<Cow<'_, PathData>> {
|
||
match &n.kind {
|
||
NodeKind::Path { path, .. } => Some(Cow::Borrowed(path)),
|
||
NodeKind::Text(t) => t.area_frame().map(Cow::Owned),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The editable objects `f` accepts, topmost first.
|
||
fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
|
||
let mut v = vec![];
|
||
cx.doc.walk(|n| {
|
||
if f(n) {
|
||
v.push(n.id)
|
||
}
|
||
});
|
||
v.reverse();
|
||
v.retain(|id| cx.doc.is_editable(*id));
|
||
v
|
||
}
|
||
|
||
/// The anchor within `tol` of `p` nearest to it, of the topmost visible path or area type frame
|
||
/// that has one: (id, si, ai, where it is).
|
||
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, Point)> {
|
||
let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
|
||
owners.into_iter().find_map(|id| {
|
||
let pd = cx.doc.node(id).and_then(editable_path)?;
|
||
nearest_anchor(&pd, p, tol).map(|(si, ai, a)| (id, si, ai, a))
|
||
})
|
||
}
|
||
|
||
/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is).
|
||
fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Point)> {
|
||
pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p)))
|
||
}
|
||
|
||
/// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors).
|
||
fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<AnchorRef>)> {
|
||
anchor_owners(cx, is_area_type).into_iter().find_map(|id| {
|
||
let frame = cx.doc.node(id).and_then(editable_path)?;
|
||
let (si, seg, _, _, d) = frame.nearest(p)?;
|
||
let n = frame.subpaths.get(si)?.anchors.len();
|
||
(d <= tol && n > 0).then(|| (id, vec![(si, seg % n), (si, (seg + 1) % n)]))
|
||
})
|
||
}
|
||
|
||
/// The anchors whose direction handles show, and drag, with the tools that edit anchors
|
||
/// ([`crate::catalog::edits_anchors`]): the direct-selected anchors of a path, every anchor of a
|
||
/// path selected as a whole. Selection & Anchor Display → Show handles when multiple anchors are
|
||
/// selected off, none while more than one anchor is selected.
|
||
fn handle_anchors(cx: &ToolContext) -> Vec<(NodeId, usize, usize, Anchor)> {
|
||
let mut v = vec![];
|
||
for &id in &cx.selection.objects {
|
||
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
|
||
let shown = cx.selection.partial(id);
|
||
v.extend(pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))).map(|(si, ai, a)| (id, si, ai, *a)));
|
||
}
|
||
if !cx.handles_multiple && v.len() > 1 {
|
||
v.clear();
|
||
}
|
||
v
|
||
}
|
||
|
||
/// The end of a shown direction handle ([`handle_anchors`]) nearest to `p` within `tol`, and
|
||
/// nearer to it than the handle's anchor (a short handle leaves its anchor to be picked): (path,
|
||
/// subpath, anchor, is_out).
|
||
pub(crate) fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
|
||
let mut best: Option<((NodeId, usize, usize, bool), f64)> = None;
|
||
for (id, si, ai, a) in handle_anchors(cx) {
|
||
for (out, has, h) in [(true, a.has_out(), a.h_out), (false, a.has_in(), a.h_in)] {
|
||
let d = h.distance(p);
|
||
if has && d <= tol && d < a.p.distance(p) && best.is_none_or(|b| d < b.1) {
|
||
best = Some(((id, si, ai, out), d));
|
||
}
|
||
}
|
||
}
|
||
best.map(|b| b.0)
|
||
}
|
||
|
||
fn anchors_json(v: &[AnchorRef]) -> Value {
|
||
Value::Array(v.iter().map(|(s, a)| json!([s, a])).collect())
|
||
}
|
||
|
||
/// A segment of a path under the pointer.
|
||
struct SegmentHit {
|
||
si: usize,
|
||
seg: usize,
|
||
t: f64,
|
||
/// The indexes of the segment's two anchors in its subpath.
|
||
anchors: [usize; 2],
|
||
curved: bool,
|
||
}
|
||
|
||
/// The segment of path `id` nearest to `p`, where the hit test found its stroke.
|
||
fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option<SegmentHit> {
|
||
let pd = cx.doc.node(id)?.path_data()?;
|
||
let (si, seg, t, ..) = pd.nearest(p)?;
|
||
let sp = pd.subpaths.get(si)?;
|
||
let n = sp.anchors.len();
|
||
let (i0, i1) = (seg.checked_rem(n)?, (seg + 1).checked_rem(n)?);
|
||
let (a, b) = (sp.anchors.get(i0)?, sp.anchors.get(i1)?);
|
||
Some(SegmentHit { si, seg, t, anchors: [i0, i1], curved: a.has_out() || b.has_in() })
|
||
}
|
||
|
||
impl Tool for DirectSelectionTool {
|
||
fn id(&self) -> &'static str {
|
||
if self.group { "groupSelection" } else { "directSelection" }
|
||
}
|
||
fn busy(&self) -> bool {
|
||
!matches!(self.state, State::Idle) || self.guide.busy()
|
||
}
|
||
fn options(&self) -> Value {
|
||
self.mesh.options()
|
||
}
|
||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||
return out;
|
||
}
|
||
let p = ev.pos;
|
||
// Anchors and handle ends are picked from a little further than segments and objects.
|
||
let (tol, point) = (cx.pick_tol(), cx.point_tol());
|
||
match (ev.kind, self.state.clone()) {
|
||
(PointerKind::Down, _) if self.group => {
|
||
if let Some(out) = self.guide.press(cx, ev) {
|
||
self.state = State::Idle;
|
||
return out;
|
||
}
|
||
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else {
|
||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift };
|
||
return vec![];
|
||
};
|
||
// Walk up from the leaf: select the first ancestor not yet selected (below the layer).
|
||
let chain: Vec<NodeId> = h.ancestry.iter().skip(1).rev().copied().collect();
|
||
let target = if cx.selection.contains(h.leaf) {
|
||
chain.iter().find(|id| !cx.selection.contains(**id)).copied().unwrap_or(h.leaf)
|
||
} else {
|
||
h.leaf
|
||
};
|
||
self.state = State::MoveObject { start: p, grab: None, began: false };
|
||
if cx.selection.contains(target) {
|
||
vec![]
|
||
} else if ev.mods.shift || cx.selection.contains(h.leaf) {
|
||
vec![Action::Exec("select.add".into(), json!({"ids": [target.0]}))]
|
||
} else {
|
||
vec![Action::Exec("select.set".into(), json!({"ids": [target.0]}))]
|
||
}
|
||
}
|
||
(PointerKind::DoubleClick, _) if !self.group => corners::double_click(cx, p, true).into_iter().collect(),
|
||
(PointerKind::Move, State::Idle) => {
|
||
self.hover = if self.group || !cx.highlight_anchors { None } else { hovered_anchor(cx, p) };
|
||
vec![]
|
||
}
|
||
(PointerKind::Down, _) => {
|
||
if let Some(c) = CornerDrag::hit(cx, ev, true) {
|
||
self.state = State::Corner(c);
|
||
return vec![];
|
||
}
|
||
if let Some(b) = BracketDrag::hit(cx, ev) {
|
||
self.state = State::Bracket(b);
|
||
return vec![];
|
||
}
|
||
if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id))
|
||
&& let Some(out) = hit_spine_handle(cx, sel, p, point)
|
||
{
|
||
self.state = State::SpineHandle { id: sel.0, anchor: sel.1, out };
|
||
return vec![Action::Begin("Reshape Spine".into())];
|
||
}
|
||
self.spine = None;
|
||
if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) {
|
||
self.state = State::Mesh;
|
||
return self.mesh.press(g);
|
||
}
|
||
self.mesh.unfocus();
|
||
if let Some((id, si, ai, out)) = hit_handle(cx, p, point) {
|
||
self.state = State::Handle { id, si, ai, out };
|
||
self.handle_snap = HandleSnap::default();
|
||
return vec![Action::Begin("Reshape".into())];
|
||
}
|
||
if let Some((id, anchor, from)) = hit_spine_point(cx, p, point) {
|
||
self.spine = Some((id, anchor));
|
||
self.state = State::SpinePoint { id, anchor, from, start: p, began: false };
|
||
return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
|
||
}
|
||
if let Some((id, si, ai, grab)) = hit_anchor(cx, p, point) {
|
||
if cx.doc.node(id).is_some_and(is_area_type) {
|
||
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
|
||
}
|
||
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
|
||
if ev.mods.alt {
|
||
self.state = State::MoveObject { start: p, grab: Some(grab), began: false };
|
||
let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none();
|
||
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
|
||
}
|
||
// A path with all its anchors selected is stored whole (no partial entry).
|
||
let already = cx.selection.contains(id) && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai)));
|
||
// Paths all selected whole move as objects, held by this anchor, so Alt can turn
|
||
// the copy on and off during the drag as with the Selection tool.
|
||
self.state = if already && cx.selection.anchors.is_empty() {
|
||
State::MoveObject { start: p, grab: Some(grab), began: false }
|
||
} else {
|
||
State::MoveAnchors { start: p, grab, began: false }
|
||
};
|
||
if ev.mods.shift {
|
||
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))];
|
||
}
|
||
if !already {
|
||
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))];
|
||
}
|
||
return vec![];
|
||
}
|
||
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
|
||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||
}
|
||
// A curved segment of a selected blend's spine reshapes, as a path's does.
|
||
if let Some((id, seg, t, false)) = hit_spine_segment(cx, p, tol) {
|
||
self.state = State::SpineSegment { id, seg, t, start: p, began: false };
|
||
return vec![];
|
||
}
|
||
if let Some(out) = self.guide.press(cx, ev) {
|
||
self.state = State::Idle;
|
||
return out;
|
||
}
|
||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||
// A segment of a path that isn't selected as a whole: its two anchors get
|
||
// selected, and dragging it reshapes it if it's curved, else moves it. Alt picks
|
||
// the whole path (and Alt-drag copies it), as with Group Selection.
|
||
let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none();
|
||
if !whole
|
||
&& !ev.mods.shift
|
||
&& !ev.mods.alt
|
||
&& matches!(h.kind, HitKind::Stroke | HitKind::Outline)
|
||
&& let Some(s) = segment_at(cx, h.leaf, p)
|
||
{
|
||
let selected = cx.selection.partial(h.leaf).is_some_and(|sel| s.anchors.iter().all(|&ai| sel.contains(&(s.si, ai))));
|
||
self.state = if s.curved {
|
||
State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false }
|
||
} else {
|
||
State::MoveAnchors { start: p, grab: p, began: false }
|
||
};
|
||
if selected {
|
||
return vec![];
|
||
}
|
||
let anchors: Vec<Value> = s.anchors.iter().map(|ai| json!([s.si, ai])).collect();
|
||
return vec![Action::Exec("select.anchors".into(), json!({"id": h.leaf.0, "anchors": anchors, "mode": "set"}))];
|
||
}
|
||
// Clicking the fill selects the whole leaf path (all anchors).
|
||
self.state = State::MoveObject { start: p, grab: None, began: false };
|
||
if ev.mods.shift {
|
||
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
|
||
}
|
||
if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() {
|
||
return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))];
|
||
}
|
||
return vec![];
|
||
}
|
||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift };
|
||
vec![]
|
||
}
|
||
(PointerKind::Drag, State::MoveAnchors { start, grab, began }) => {
|
||
let mut out = vec![];
|
||
if !began {
|
||
if p.distance(start) < cx.tol(3.0) {
|
||
return out;
|
||
}
|
||
out.push(Action::Begin("Move".into()));
|
||
self.state = State::MoveAnchors { start, grab, began: true };
|
||
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
|
||
}
|
||
let mut d = move_delta(start, p, ev.mods.shift);
|
||
// The grabbed anchor snaps and the others follow it. Shift keeps the move at its
|
||
// angle, and the anchor slides along it onto what Smart Guides find there (#886).
|
||
self.guides.clear();
|
||
if let Some(snap) = &self.anchor_snap {
|
||
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
|
||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||
(d, self.guides) = (q - grab, guides);
|
||
}
|
||
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
|
||
out
|
||
}
|
||
(PointerKind::Drag, State::Segment { id, si, seg, t, start, began }) => {
|
||
let mut out = vec![];
|
||
if !began {
|
||
if p.distance(start) < cx.tol(3.0) {
|
||
return out;
|
||
}
|
||
out.push(Action::Begin("Reshape".into()));
|
||
self.state = State::Segment { id, si, seg, t, start, began: true };
|
||
}
|
||
let d = p - start;
|
||
out.push(Action::Preview(
|
||
"path.reshapeSegment".into(),
|
||
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": d.x, "dy": d.y}),
|
||
));
|
||
out
|
||
}
|
||
(PointerKind::Drag, State::MoveObject { start, grab, began }) => {
|
||
let mut out = vec![];
|
||
if !began {
|
||
if p.distance(start) < cx.tol(3.0) {
|
||
return out;
|
||
}
|
||
out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() }));
|
||
self.state = State::MoveObject { start, grab, began: true };
|
||
if grab.is_some() {
|
||
// Alt can go down or up at any time: gather both while nothing has moved.
|
||
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
|
||
self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default())));
|
||
} else {
|
||
self.move_snap = Some(MoveSnap::new(cx));
|
||
}
|
||
}
|
||
let mut d = move_delta(start, p, ev.mods.shift);
|
||
self.guides.clear();
|
||
let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap };
|
||
if let (Some(grab), Some(snap)) = (grab, snap) {
|
||
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
|
||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||
(d, self.guides) = (q - grab, guides);
|
||
} else if let Some(snap) = &self.move_snap {
|
||
(d, self.guides) = snap.snap(cx, start, d, ev.mods.alt);
|
||
}
|
||
out.push(Action::Preview(
|
||
"object.transform".into(),
|
||
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}),
|
||
));
|
||
out
|
||
}
|
||
(PointerKind::Drag, State::TypeArea { id, anchors, start, began }) => {
|
||
let mut out = vec![];
|
||
if !began {
|
||
if p.distance(start) < cx.tol(3.0) {
|
||
return out;
|
||
}
|
||
out.push(Action::Begin("Reshape Type Area".into()));
|
||
}
|
||
let d = move_delta(start, p, ev.mods.shift);
|
||
out.push(Action::Preview("text.reshapeArea".into(), json!({"id": id.0, "anchors": anchors_json(&anchors), "dx": d.x, "dy": d.y})));
|
||
self.state = State::TypeArea { id, anchors, start, began: true };
|
||
out
|
||
}
|
||
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
|
||
let (q, guides) = self.handle_snap.snap(cx, (id, si, ai), p, ev.mods.shift);
|
||
self.guides = guides;
|
||
vec![Action::Preview(
|
||
"path.setHandle".into(),
|
||
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": q.x, "y": q.y, "independent": ev.mods.alt}),
|
||
)]
|
||
}
|
||
(PointerKind::Drag, State::SpinePoint { id, anchor, from, start, began }) => {
|
||
let mut out = vec![];
|
||
if !began {
|
||
if p.distance(start) < cx.tol(3.0) {
|
||
return out;
|
||
}
|
||
out.push(Action::Begin("Reshape Spine".into()));
|
||
self.state = State::SpinePoint { id, anchor, from, start, began: true };
|
||
}
|
||
let q = from + move_delta(start, p, ev.mods.shift);
|
||
out.push(Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y})));
|
||
out
|
||
}
|
||
(PointerKind::Drag, State::SpineSegment { id, seg, t, start, began }) => {
|
||
let mut out = vec![];
|
||
if !began {
|
||
if p.distance(start) < cx.tol(3.0) {
|
||
return out;
|
||
}
|
||
out.push(Action::Begin("Reshape Spine".into()));
|
||
self.state = State::SpineSegment { id, seg, t, start, began: true };
|
||
}
|
||
let d = p - start;
|
||
out.push(Action::Preview(
|
||
"object.blend.spine.reshapeSegment".into(),
|
||
json!({"id": id.0, "segment": seg, "t": t, "dx": d.x, "dy": d.y}),
|
||
));
|
||
out
|
||
}
|
||
(PointerKind::Drag, State::SpineHandle { id, anchor, out }) => vec![Action::Preview(
|
||
"object.blend.spine.moveAnchor".into(),
|
||
json!({"id": id.0, "anchor": anchor, "handle": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
|
||
)],
|
||
(PointerKind::Drag, State::Mesh) => self.mesh.drag_to(p).unwrap_or_default(),
|
||
(PointerKind::Up, State::Mesh) => {
|
||
self.state = State::Idle;
|
||
self.mesh.release().unwrap_or_default()
|
||
}
|
||
(PointerKind::Drag, State::Marquee { start, toggle, .. }) => {
|
||
self.state = State::Marquee { start, cur: p, toggle };
|
||
vec![]
|
||
}
|
||
(PointerKind::Drag, State::Corner(mut c)) => {
|
||
let out = c.drag(cx, p);
|
||
self.state = State::Corner(c);
|
||
out
|
||
}
|
||
(PointerKind::Up, State::Corner(c)) => {
|
||
self.state = State::Idle;
|
||
c.finish()
|
||
}
|
||
(PointerKind::Drag, State::Bracket(mut b)) => {
|
||
let out = b.drag(cx, p, ev.mods.cmd);
|
||
self.state = State::Bracket(b);
|
||
out
|
||
}
|
||
(PointerKind::Up, State::Bracket(b)) => {
|
||
self.state = State::Idle;
|
||
b.finish()
|
||
}
|
||
(
|
||
PointerKind::Up,
|
||
State::MoveAnchors { began, .. }
|
||
| State::MoveObject { began, .. }
|
||
| State::Segment { began, .. }
|
||
| State::SpinePoint { began, .. }
|
||
| State::SpineSegment { began, .. }
|
||
| State::TypeArea { began, .. },
|
||
) => {
|
||
self.state = State::Idle;
|
||
self.guides.clear();
|
||
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
|
||
if began { vec![Action::Commit] } else { vec![] }
|
||
}
|
||
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
|
||
self.state = State::Idle;
|
||
self.guides.clear();
|
||
vec![Action::Commit]
|
||
}
|
||
(PointerKind::Up, State::Marquee { start, toggle, .. }) => {
|
||
self.state = State::Idle;
|
||
let r = Rect::from_points(start, p);
|
||
if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) {
|
||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||
}
|
||
// Collect anchors inside the rect for every editable path.
|
||
let mut sel: Vec<(NodeId, Vec<AnchorRef>)> = vec![];
|
||
cx.doc.walk(|n| {
|
||
if let NodeKind::Path { path, .. } = &n.kind {
|
||
let v: Vec<AnchorRef> = path.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect();
|
||
if !v.is_empty() {
|
||
sel.push((n.id, v));
|
||
}
|
||
}
|
||
});
|
||
// Objects without anchors to pick (type, images, symbols…) are selected whole when
|
||
// the marquee touches them, as with the Selection tool (#927).
|
||
for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) {
|
||
let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
|
||
if whole && !sel.iter().any(|(s, _)| *s == id) {
|
||
sel.push((id, vec![]));
|
||
}
|
||
}
|
||
sel.retain(|(id, _)| cx.doc.is_editable(*id));
|
||
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
|
||
vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}))]
|
||
}
|
||
_ => vec![],
|
||
}
|
||
}
|
||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
match &self.state {
|
||
State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
|
||
State::Corner(c) => c.overlays(cx),
|
||
_ => {
|
||
let mut out = self.spine_overlays(cx);
|
||
out.extend(self.mesh.overlays(cx));
|
||
if !self.group {
|
||
out.extend(frame_overlays(cx));
|
||
out.extend(pathtype::overlays(cx));
|
||
}
|
||
if matches!(self.state, State::Handle { .. } | State::MoveAnchors { .. } | State::MoveObject { .. }) {
|
||
out.extend(self.guides.iter().cloned());
|
||
}
|
||
if let Some((id, p)) = self.hover.filter(|_| matches!(self.state, State::Idle)) {
|
||
out.push(Overlay::Anchor { p, color: cx.doc.layer_color(id), filled: false, size: 8.0 });
|
||
}
|
||
out.extend(self.guide.overlays(cx));
|
||
out
|
||
}
|
||
}
|
||
}
|
||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p, true)) {
|
||
return Cursor::CornerRadius;
|
||
}
|
||
if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p)) {
|
||
return Cursor::PathBracket;
|
||
}
|
||
// A press on the highlighted anchor picks it, not the guide.
|
||
self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(Cursor::ArrowHollow)
|
||
}
|
||
}
|
||
|
||
/// The anchor within a press's reach of `p` ([`ToolContext::point_tol`]), of a selected path or the
|
||
/// path under `p`: what Highlight anchors on mouse over marks (a press there picks it).
|
||
fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> {
|
||
let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.leaf);
|
||
let tol = cx.point_tol();
|
||
cx.selection.objects.iter().copied().chain(under).find_map(|id| {
|
||
let path = cx.doc.node(id).and_then(editable_path)?;
|
||
nearest_anchor(&path, p, tol).map(|(.., a)| (id, a))
|
||
})
|
||
}
|
||
|
||
/// The frame anchors of the selected area type, which Direct Selection drags.
|
||
fn frame_overlays(cx: &ToolContext) -> Vec<Overlay> {
|
||
let mut out = vec![];
|
||
for id in &cx.selection.objects {
|
||
let Some(frame) = cx.doc.node(*id).filter(|n| is_area_type(n)).and_then(editable_path) else { continue };
|
||
let color = cx.doc.layer_color(*id);
|
||
out.extend(frame.anchors().map(|(_, _, a)| Overlay::Anchor { p: a.p, color, filled: false, size: 5.0 }));
|
||
}
|
||
out
|
||
}
|
||
|
||
impl DirectSelectionTool {
|
||
/// Press on area type's frame anchors (a corner, or an edge's two ends): select the text and
|
||
/// get ready to drag them.
|
||
fn press_type_area(&mut self, cx: &ToolContext, id: NodeId, anchors: Vec<AnchorRef>, p: Point, shift: bool) -> Vec<Action> {
|
||
self.state = State::TypeArea { id, anchors, start: p, began: false };
|
||
if cx.selection.contains(id) {
|
||
vec![]
|
||
} else if shift {
|
||
vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))]
|
||
} else {
|
||
vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]
|
||
}
|
||
}
|
||
|
||
/// The clicked spine point, filled, with its handles.
|
||
fn spine_overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) else { return vec![] };
|
||
let Some(a) = spine_anchor(cx, sel) else { return vec![] };
|
||
let color = cx.doc.layer_color(sel.0);
|
||
let mut out = vec![];
|
||
for h in [a.h_in, a.h_out].into_iter().filter(|h| h.distance(a.p) > 1e-6) {
|
||
out.push(Overlay::Line { a: a.p, b: h, color, dashed: false });
|
||
out.push(Overlay::Handle { p: h, color });
|
||
}
|
||
out.push(Overlay::Anchor { p: a.p, color, filled: true, size: 5.0 });
|
||
out
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::testutil::*;
|
||
use vectorcraft_doc::Selection;
|
||
|
||
/// #414: a ruler guide is picked over the art, but an anchor on it is picked first.
|
||
#[test]
|
||
fn direct_and_group_selection_pick_ruler_guides() {
|
||
let (mut d, id) = doc_with_rect();
|
||
d.guides.push(vectorcraft_doc::Guide::new(true, 100.0));
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y));
|
||
let pick = vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))];
|
||
for group in [false, true] {
|
||
let mut t = DirectSelectionTool::new(group);
|
||
assert_eq!(down(&mut t, 150.0), pick, "on the rect's edge (group: {group})");
|
||
assert!(t.busy());
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 150.0));
|
||
assert_eq!(t.cursor(&c, Point::new(101.0, 300.0), Mods::default()), Cursor::ResizeH);
|
||
}
|
||
let mut t = DirectSelectionTool::new(false);
|
||
assert_eq!(down(&mut t, 100.0), vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]);
|
||
}
|
||
|
||
/// An anchor is picked from 2 px past its drawn square even where its segment is nearer, and
|
||
/// further with a larger anchor Size or Tolerance (#593); away from it the segment is.
|
||
#[test]
|
||
fn anchors_are_picked_before_the_segments_through_them() {
|
||
let (d, id) = doc_with_rect();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let corner = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))];
|
||
let press = |cx: &ToolContext, x: f64, y: f64| DirectSelectionTool::new(false).pointer(cx, &PointerEvent::new(PointerKind::Down, x, y));
|
||
let c = cx(&d, &s, &p);
|
||
// 4.3 px from the corner, 0.5 px from the top edge.
|
||
assert_eq!(press(&c, 104.2, 100.5), corner);
|
||
assert_ne!(press(&c, 110.0, 100.5), corner, "the segment, away from the anchor");
|
||
assert_ne!(press(&c, 105.0, 100.5), corner, "past the default reach (4.5 px)");
|
||
let large = ToolContext { anchor_size: 7, ..cx(&d, &s, &p) };
|
||
assert_eq!(press(&large, 106.0, 100.5), corner, "Size 7 draws them 9 px wide");
|
||
let loose = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) };
|
||
assert_eq!(press(&loose, 107.5, 100.5), corner);
|
||
// On screen: at 200% the reach is half as far in the document.
|
||
let zoomed = ToolContext { zoom: 2.0, ..cx(&d, &s, &p) };
|
||
assert_eq!(press(&zoomed, 102.1, 100.2), corner);
|
||
assert_ne!(press(&zoomed, 102.6, 100.2), corner);
|
||
}
|
||
|
||
/// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected.
|
||
fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) {
|
||
let (mut d, _) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let key = |id: NodeId, x: f64| {
|
||
let r = vectorcraft_geom::shapes::rectangle(Rect::new(x, 300.0, x + 20.0, 320.0));
|
||
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
|
||
};
|
||
let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||
let b = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() });
|
||
d.insert(Some(l), 1, b).unwrap();
|
||
(d, g, k1)
|
||
}
|
||
|
||
/// Highlight anchors on mouse over (#394): the anchor under the pointer, within the selection
|
||
/// tolerance, shows enlarged; none with the preference off or away from anchors.
|
||
#[test]
|
||
fn the_anchor_under_the_pointer_is_highlighted() {
|
||
let (d, id) = doc_with_rect();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let big = |t: &DirectSelectionTool, cx: &ToolContext| {
|
||
t.overlays(cx).into_iter().find_map(|o| match o {
|
||
Overlay::Anchor { p, size, .. } if size > 5.0 => Some(p),
|
||
_ => None,
|
||
})
|
||
};
|
||
let mut t = DirectSelectionTool::new(false);
|
||
let on = cx(&d, &s, &p);
|
||
t.pointer(&on, &PointerEvent::new(PointerKind::Move, 102.0, 101.0));
|
||
assert_eq!(big(&t, &on), Some(Point::new(100.0, 100.0)), "the corner of {id:?}");
|
||
t.pointer(&on, &PointerEvent::new(PointerKind::Move, 150.0, 100.0));
|
||
assert_eq!(big(&t, &on), None, "mid-segment: no anchor");
|
||
let off = ToolContext { highlight_anchors: false, ..cx(&d, &s, &p) };
|
||
t.pointer(&off, &PointerEvent::new(PointerKind::Move, 102.0, 101.0));
|
||
assert_eq!(big(&t, &off), None, "off");
|
||
}
|
||
|
||
#[test]
|
||
fn dragging_a_spine_point_moves_it() {
|
||
let (d, g, _) = blend_doc();
|
||
let mut s = Selection::default();
|
||
s.set([g]);
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 211.0, 310.0)), vec![]);
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 221.0, 350.0));
|
||
assert_eq!(a[0], Action::Begin("Reshape Spine".into()));
|
||
assert_eq!(a[1], Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": g.0, "anchor": 1, "x": 220.0, "y": 350.0})));
|
||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 221.0, 350.0)), vec![Action::Commit]);
|
||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows");
|
||
}
|
||
|
||
/// #1111: the Anchor Point tool bends a selected blend's straight spine; Direct Selection
|
||
/// reshapes a curved spine segment and leaves a straight one alone, as it does a path's.
|
||
#[test]
|
||
fn spine_segments_bend_with_the_anchor_point_tool_and_direct_selection() {
|
||
let (mut d, g, _) = blend_doc();
|
||
let mut s = Selection::default();
|
||
s.set([g]);
|
||
let p = paint();
|
||
let drag = |t: &mut dyn crate::Tool, cx: &ToolContext| {
|
||
t.pointer(cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0));
|
||
let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, 160.0, 351.0));
|
||
t.pointer(cx, &PointerEvent::new(PointerKind::Up, 160.0, 351.0));
|
||
a
|
||
};
|
||
let straight = cx(&d, &s, &p);
|
||
let a = drag(crate::create("anchorPoint").as_mut(), &straight);
|
||
assert!(
|
||
matches!(a.last(), Some(Action::Preview(c, v)) if c == "object.blend.spine.reshapeSegment" && v["id"] == g.0 && v["segment"] == 0 && v["dy"] == 40.0),
|
||
"{a:?}"
|
||
);
|
||
let a = drag(&mut DirectSelectionTool::new(false), &straight);
|
||
assert!(!a.iter().any(|x| matches!(x, Action::Preview(c, _) if c == "object.blend.spine.reshapeSegment")), "straight: {a:?}");
|
||
// The spine bent upward: Direct Selection takes it from where it passes.
|
||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false);
|
||
sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0));
|
||
let Some(NodeKind::Blend { spec, .. }) = d.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") };
|
||
(spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
let curved = cx(&d, &s, &p);
|
||
t.pointer(&curved, &PointerEvent::new(PointerKind::Down, 160.0, 330.0));
|
||
let a = t.pointer(&curved, &PointerEvent::new(PointerKind::Drag, 160.0, 345.0));
|
||
assert_eq!(a[0], Action::Begin("Reshape Spine".into()));
|
||
assert!(
|
||
matches!(&a[1], Action::Preview(c, v) if c == "object.blend.spine.reshapeSegment" && v["segment"] == 0 && v["dx"] == 0.0 && v["dy"] == 15.0),
|
||
"{a:?}"
|
||
);
|
||
assert_eq!(t.pointer(&curved, &PointerEvent::new(PointerKind::Up, 160.0, 345.0)), vec![Action::Commit]);
|
||
}
|
||
|
||
#[test]
|
||
fn direct_and_group_selection_pick_blend_keys() {
|
||
let (d, g, k1) = blend_doc();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let a = DirectSelectionTool::new(false).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0));
|
||
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]);
|
||
let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0));
|
||
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]);
|
||
// A click on a step (between the keys) still selects the blend.
|
||
let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 304.0));
|
||
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]);
|
||
}
|
||
|
||
#[test]
|
||
fn dragging_a_frame_corner_or_edge_reshapes_area_type() {
|
||
let (d, text) = doc_with_area_type();
|
||
let p = paint();
|
||
let s = Selection::default();
|
||
let cx1 = cx(&d, &s, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
// The bottom-right corner of the 120 × 40 frame at (300, 300) selects the text...
|
||
assert_eq!(
|
||
t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 421.0, 339.0)),
|
||
vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))]
|
||
);
|
||
// ...and drags that corner.
|
||
let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 461.0, 399.0));
|
||
assert_eq!(a[0], Action::Begin("Reshape Type Area".into()));
|
||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 40.0, "dy": 60.0})));
|
||
assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit]);
|
||
// The top edge drags both its ends; a click alone changes nothing.
|
||
let mut s = Selection::default();
|
||
s.add(text);
|
||
let cx2 = cx(&d, &s, &p);
|
||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 350.0, 301.0)).is_empty());
|
||
let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 350.0, 281.0));
|
||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 0], [0, 1]], "dx": 0.0, "dy": -20.0})));
|
||
t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 350.0, 281.0));
|
||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)).is_empty());
|
||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 300.0, 340.0)).is_empty());
|
||
// The selected frame's corners show.
|
||
assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn click_anchor_selects_it() {
|
||
let (d, id) = doc_with_rect();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
|
||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]);
|
||
}
|
||
|
||
/// Pressing an anchor of a path whose anchors are all selected (stored as the whole path)
|
||
/// keeps them all selected, so the drag moves them all; Shift still deselects that anchor.
|
||
#[test]
|
||
fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() {
|
||
let (d, id) = doc_with_rect();
|
||
let mut s = Selection::default();
|
||
s.set([id]);
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty());
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0));
|
||
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0));
|
||
let shift = Mods { shift: true, ..Default::default() };
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift));
|
||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]);
|
||
}
|
||
|
||
/// Alt-pressing an anchor picks the whole path, as Alt on a segment does, and the drag copies
|
||
/// it as the Selection tool's Alt-drag does.
|
||
#[test]
|
||
fn alt_dragging_an_anchor_copies_the_whole_path() {
|
||
let (d, id) = doc_with_rect();
|
||
let alt = Mods { alt: true, ..Default::default() };
|
||
let mut whole = Selection::default();
|
||
whole.set([id]);
|
||
let p = paint();
|
||
for (s, pick) in [(Selection::default(), true), (anchors_of(id, &[(0, 0)]), true), (whole, false)] {
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(alt));
|
||
let set = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
|
||
assert_eq!(a, if pick { set } else { vec![] }, "{s:?}");
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt));
|
||
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn marquee_selects_anchors() {
|
||
let (d, id) = doc_with_rect();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0));
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0));
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
|
||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"}))]);
|
||
}
|
||
/// #927: a marquee across type selects the type object whole, beside the anchors it holds.
|
||
#[test]
|
||
fn marquee_selects_type_it_touches() {
|
||
// The rectangle at (100, 100)–(200, 200) and area type at (300, 300).
|
||
let (d, text) = doc_with_area_type();
|
||
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
// From empty canvas across the type to inside the rectangle (its bottom-right corner).
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0));
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0));
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0));
|
||
let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]);
|
||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))]);
|
||
// Away from the type, only anchors.
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0));
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
|
||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": rect.0, "anchors": [[0, 1]]}], "mode": "set"}))]);
|
||
}
|
||
|
||
/// Shift-drag a marquee: the anchors inside toggle (#483), with Group Selection too.
|
||
#[test]
|
||
fn shift_marquee_toggles_anchors() {
|
||
let (d, id) = doc_with_rect();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let shift = Mods { shift: true, ..Default::default() };
|
||
for group in [false, true] {
|
||
let mut t = DirectSelectionTool::new(group);
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0).with_mods(shift));
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0).with_mods(shift));
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(shift));
|
||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "toggle"}))]);
|
||
}
|
||
}
|
||
|
||
/// An open path from (100, 300) to (200, 300) arching up through (150, 262.5).
|
||
fn arch_doc() -> (vectorcraft_doc::Document, NodeId) {
|
||
let (mut d, _) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let id = d.alloc_id();
|
||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false);
|
||
sp.anchors[0].h_out = Point::new(100.0, 250.0);
|
||
sp.anchors[1].h_in = Point::new(200.0, 250.0);
|
||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
(d, id)
|
||
}
|
||
|
||
#[test]
|
||
fn dragging_a_curved_segment_reshapes_it_and_a_straight_one_moves_its_anchors() {
|
||
let (d, arch) = arch_doc();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
// The arch: its two anchors get selected, and the drag bends it.
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5));
|
||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": arch.0, "anchors": [[0, 0], [0, 1]], "mode": "set"}))]);
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 242.5));
|
||
assert_eq!(a[0], Action::Begin("Reshape".into()));
|
||
let Action::Preview(cmd, v) = &a[1] else { panic!("{a:?}") };
|
||
assert_eq!(
|
||
(cmd.as_str(), v["id"].clone(), v["segment"].clone(), v["dx"].clone(), v["dy"].clone()),
|
||
("path.reshapeSegment", json!(arch.0), json!(0), json!(0.0), json!(-20.0))
|
||
);
|
||
assert!((v["t"].as_f64().unwrap() - 0.5).abs() < 0.01, "{v}");
|
||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 242.5)), vec![Action::Commit]);
|
||
// A straight edge of the rectangle: its two anchors get selected and move.
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0));
|
||
assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "select.anchors" && v["anchors"] == json!([[0, 0], [0, 1]])), "{a:?}");
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0));
|
||
assert_eq!(a[1], Action::Preview("path.moveAnchors".into(), json!({"dx": 0.0, "dy": -20.0})));
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0));
|
||
// Alt on the stroke picks the whole path, and Alt-drag copies it.
|
||
let alt = Mods { alt: true, ..Default::default() };
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0).with_mods(alt));
|
||
assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}");
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0).with_mods(alt));
|
||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}");
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0).with_mods(alt));
|
||
// The fill still selects the whole path, and a path selected as a whole still moves whole.
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
|
||
assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}");
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0));
|
||
let rect = d.layers[0].children().unwrap()[0].id;
|
||
let mut whole = Selection::default();
|
||
whole.set([rect]);
|
||
let cx = crate::testutil::cx(&d, &whole, &p);
|
||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)), vec![]);
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0));
|
||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
|
||
}
|
||
|
||
/// #494: the handles shown are the handles dragged. A path selected as a whole (the one the
|
||
/// Pen just drew) shows all its handles, and they drag; the direct-selected anchors' handles
|
||
/// only otherwise; none with Show handles when multiple anchors are selected off and several
|
||
/// anchors selected. A handle shorter than the tolerance leaves its anchor to be picked, and
|
||
/// the tolerance is the preference's, in screen pixels at any zoom.
|
||
#[test]
|
||
fn the_handles_shown_are_the_handles_dragged() {
|
||
let (d, arch) = arch_doc();
|
||
let p = paint();
|
||
// Does a press at (x, y) take a handle?
|
||
let handle = |c: &ToolContext, x: f64, y: f64| {
|
||
DirectSelectionTool::new(false).pointer(c, &PointerEvent::new(PointerKind::Down, x, y)) == vec![Action::Begin("Reshape".into())]
|
||
};
|
||
let mut whole = Selection::default();
|
||
whole.set([arch]);
|
||
let c = cx(&d, &whole, &p);
|
||
assert!(handle(&c, 101.0, 251.0), "the first anchor's handle, the path selected whole");
|
||
let mut t = DirectSelectionTool::new(false);
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 251.0));
|
||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 240.0));
|
||
assert!(matches!(&a[..], [Action::Preview(c, v)] if c == "path.setHandle" && v["anchor"] == 0 && v["which"] == "out"), "{a:?}");
|
||
// Only the second anchor direct-selected: the first one's handle is hidden.
|
||
let second = anchors_of(arch, &[(0, 1)]);
|
||
assert!(!handle(&cx(&d, &second, &p), 101.0, 251.0));
|
||
assert!(handle(&cx(&d, &second, &p), 201.0, 251.0));
|
||
// Show handles when multiple anchors are selected off: the whole path's are hidden.
|
||
assert!(!handle(&ToolContext { handles_multiple: false, ..cx(&d, &whole, &p) }, 101.0, 251.0));
|
||
assert!(handle(&ToolContext { handles_multiple: false, ..cx(&d, &second, &p) }, 201.0, 251.0));
|
||
// The tolerance is the preference's, in screen pixels: 3 px is 0.75 pt at 400%.
|
||
let zoomed = ToolContext { zoom: 4.0, ..cx(&d, &whole, &p) };
|
||
assert!(!handle(&zoomed, 101.0, 251.0));
|
||
assert!(handle(&zoomed, 100.5, 250.5));
|
||
assert!(handle(&ToolContext { selection_tolerance: 8.0, ..cx(&d, &whole, &p) }, 106.0, 254.0));
|
||
}
|
||
|
||
/// #494: a handle 2 pt long, inside the tolerance round its anchor, doesn't hide the anchor.
|
||
#[test]
|
||
fn a_short_handle_leaves_its_anchor_to_be_picked() {
|
||
let (mut d, _) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let id = d.alloc_id();
|
||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false);
|
||
sp.anchors[0].h_out = Point::new(102.0, 300.0);
|
||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
let s = anchors_of(id, &[(0, 0)]);
|
||
let p = paint();
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), vec![], "the anchor, already selected");
|
||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 290.0));
|
||
assert_eq!(a[0], Action::Begin("Move".into()));
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 290.0));
|
||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle");
|
||
}
|
||
|
||
/// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B).
|
||
fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) {
|
||
let (mut d, a) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let b = d.alloc_id();
|
||
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0));
|
||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
(d, a, b)
|
||
}
|
||
|
||
/// The anchors `anchors` of path `id` direct-selected.
|
||
fn anchors_of(id: NodeId, anchors: &[AnchorRef]) -> Selection {
|
||
let mut s = Selection::default();
|
||
s.set([id]);
|
||
s.anchors.insert(id, anchors.iter().copied().collect());
|
||
s
|
||
}
|
||
|
||
/// Press at `from`, drag to `to`: the move the drag previews, and the smart guide labels shown.
|
||
fn drag(t: &mut DirectSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), mods: Mods) -> (Value, Vec<String>) {
|
||
t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(mods));
|
||
let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(mods));
|
||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||
let labels = t
|
||
.overlays(cx)
|
||
.into_iter()
|
||
.filter_map(|o| match o {
|
||
Overlay::Label { text, .. } => Some(text),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(t.pointer(cx, &PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![Action::Commit]);
|
||
assert!(t.overlays(cx).iter().all(|o| !matches!(o, Overlay::Label { .. })), "the guides go with the drag");
|
||
(v.clone(), labels)
|
||
}
|
||
|
||
/// Alt-dragging a path by an anchor copies it, and that anchor snaps as a dragged anchor does:
|
||
/// onto another path's anchor, or onto an anchor of the original the copy leaves behind.
|
||
#[test]
|
||
fn alt_dragged_copies_snap_by_the_anchor_pressed() {
|
||
let (d, _, b) = two_squares();
|
||
let p = paint();
|
||
let alt = Mods { alt: true, ..Default::default() };
|
||
let mut whole = Selection::default();
|
||
whole.set([b]);
|
||
let c = cx(&d, &whole, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
let copy =
|
||
|dx: f64, dy: f64| json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": true});
|
||
// B's top-left corner, pressed 1 pt off, onto A's bottom-right one.
|
||
let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), alt);
|
||
assert_eq!((v, labels), (copy(-100.0, -100.0), vec!["anchor".to_string()]));
|
||
// B's top-left corner onto the original B's top-right one: the copy sits beside it.
|
||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (402.0, 301.0), alt);
|
||
assert_eq!((v, labels), (copy(100.0, 0.0), vec!["anchor".to_string()]));
|
||
// The history says what the drag did.
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0).with_mods(alt));
|
||
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0).with_mods(alt));
|
||
assert_eq!(out.first(), Some(&Action::Begin("Copy".into())));
|
||
}
|
||
|
||
/// Dragging a fully selected path by an anchor moves it whole, so Alt pressed or released
|
||
/// during the drag turns the copy on and off, as with the Selection tool; the anchor pressed
|
||
/// snaps all along, onto the original only while it is left behind.
|
||
#[test]
|
||
fn alt_during_a_drag_by_an_anchor_toggles_the_copy() {
|
||
let (d, _, b) = two_squares();
|
||
let p = paint();
|
||
let (none, alt) = (Mods::default(), Mods { alt: true, ..Default::default() });
|
||
let mut whole = Selection::default();
|
||
whole.set([b]);
|
||
let c = cx(&d, &whole, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
let preview = |dx: f64, dy: f64, copy: bool| {
|
||
Action::Preview(
|
||
"object.transform".into(),
|
||
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": copy}),
|
||
)
|
||
};
|
||
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty());
|
||
assert_eq!(
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)),
|
||
vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)]
|
||
);
|
||
// The tool sees the document as previewed: B moved along, not where the copy leaves it.
|
||
let (mut moved, _, b2) = two_squares();
|
||
let l = moved.layers[0].id;
|
||
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 350.0, 350.0));
|
||
moved.remove(b2).unwrap();
|
||
moved.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
let c = cx(&moved, &whole, &p);
|
||
// Alt down mid-drag: a copy, whose pressed corner can land on the original's top-right one.
|
||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(alt)), vec![preview(100.0, 0.0, true)]);
|
||
// Alt up: a move again, which leaves nothing behind to snap to.
|
||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(none)), vec![preview(102.0, 1.0, false)]);
|
||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 402.0, 301.0)), vec![Action::Commit]);
|
||
// With only some anchors selected, the drag still moves just those.
|
||
let one = anchors_of(b, &[(0, 0)]);
|
||
let c = cx(&d, &one, &p);
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
|
||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(alt));
|
||
assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}");
|
||
}
|
||
|
||
/// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors
|
||
/// of its own path, and with several selected the one pressed on snaps, the others following.
|
||
#[test]
|
||
fn dragged_anchors_snap_to_points_segments_and_smart_guides() {
|
||
let (d, _, b) = two_squares();
|
||
let p = paint();
|
||
let none = Mods::default();
|
||
let one = anchors_of(b, &[(0, 0)]);
|
||
let c = cx(&d, &one, &p);
|
||
let mut t = DirectSelectionTool::new(false);
|
||
// B's top-left corner, pressed 1 pt off, onto A's bottom-right one.
|
||
let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), none);
|
||
assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -100.0}), vec!["anchor".to_string()]));
|
||
// Onto A's right side.
|
||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 150.0), none);
|
||
assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -150.0}), vec!["path".to_string()]));
|
||
// In line with B's own bottom-left corner, which stays put.
|
||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (303.0, 330.0), none);
|
||
assert_eq!((v, labels), (json!({"dx": 0.0, "dy": 30.0}), vec!["align".to_string()]));
|
||
// Shift keeps the move at 45° steps and still snaps along that line (#886): on the
|
||
// diagonal from B's corner, onto A's bottom-right corner…
|
||
let shift = Mods { shift: true, ..none };
|
||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), shift);
|
||
let near = |v: &serde_json::Value, (x, y): (f64, f64)| (v["dx"].as_f64().unwrap() - x).hypot(v["dy"].as_f64().unwrap() - y) < 1e-9;
|
||
assert!(near(&v, (-100.0, -100.0)) && labels == ["anchor"], "{v} {labels:?}");
|
||
// …and with nothing in reach, at the pointer's distance along its angle.
|
||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (380.0, 302.0), shift);
|
||
let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true);
|
||
assert!(near(&v, (s.x, s.y)) && labels.is_empty(), "{v} {labels:?}");
|
||
// Two anchors: the one pressed on (B's top-right) lands on A's corner.
|
||
let two = anchors_of(b, &[(0, 0), (0, 1)]);
|
||
let c = cx(&d, &two, &p);
|
||
let (v, _) = drag(&mut t, &c, (400.0, 300.0), (202.0, 198.0), none);
|
||
assert_eq!(v, json!({"dx": -200.0, "dy": -100.0}));
|
||
// Smart Guides off: Snap to Point pulls within its distance (2 px) only.
|
||
let c = ToolContext { smart_guides: false, ..cx(&d, &one, &p) };
|
||
assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -100.0, "dy": -100.0}));
|
||
assert_eq!(drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), none).0, json!({"dx": -97.0, "dy": -101.0}));
|
||
let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &one, &p) };
|
||
assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -99.0, "dy": -101.0}));
|
||
}
|
||
|
||
/// Group Selection (and Direct Selection on a whole path) moves objects snapping as the
|
||
/// Selection tool does: B's left side lines up with A's right one.
|
||
#[test]
|
||
fn moved_objects_snap_with_smart_guides() {
|
||
let (d, _, b) = two_squares();
|
||
let p = paint();
|
||
let mut s = Selection::default();
|
||
s.set([b]);
|
||
let c = cx(&d, &s, &p);
|
||
for group in [true, false] {
|
||
let mut t = DirectSelectionTool::new(group);
|
||
let (v, _) = drag(&mut t, &c, (350.0, 350.0), (253.0, 351.0), Mods::default());
|
||
assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, -100.0, 1.0]), "group: {group}");
|
||
}
|
||
}
|
||
}
|