1122 lines
55 KiB
Rust
1122 lines
55 KiB
Rust
//! The Selection tool (V): click/shift-click, marquee (Shift-drag toggles the objects it reaches:
|
||
//! the selected ones are deselected, the others selected), move (Alt copies, Shift constrains; Smart
|
||
//! Guides, or with them off View › Snap to Point, snap it),
|
||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
||
//! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind,
|
||
//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To
|
||
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
|
||
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
|
||
//! ([`crate::pathtype`]), double-click the type widget to convert point type to area type and back
|
||
//! ([`crate::typewidget`]).
|
||
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
|
||
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
|
||
//! scaling its type: the text reflows at its size.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::hit::{hit_test, marquee, objects_at};
|
||
use vectorcraft_doc::{NodeId, OrientedBox};
|
||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||
|
||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||
use crate::corners::{self, CornerDrag, over_widget};
|
||
use crate::guides::Targets;
|
||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||
use crate::rulerguide::GuideEdit;
|
||
use crate::typewidget;
|
||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
enum State {
|
||
#[default]
|
||
Idle,
|
||
/// Pressed on an object; becomes Moving after the drag threshold.
|
||
Moving {
|
||
start: Point,
|
||
began: bool,
|
||
/// Shift-pressed on this selected object: released without a drag, it leaves the
|
||
/// selection.
|
||
deselect: Option<NodeId>,
|
||
/// Pressed (no modifier) on this object of a selection of several: released without a
|
||
/// drag, it becomes the key object that Align aligns to if `can_be_key` allows it (the key
|
||
/// again: no key).
|
||
key: Option<NodeId>,
|
||
},
|
||
Scaling {
|
||
handle: Handle,
|
||
bx: OrientedBox,
|
||
/// Area type is selected: its frame resizes (`typeAreas`), its type keeps its size.
|
||
areas: bool,
|
||
},
|
||
Rotating {
|
||
center: Point,
|
||
start: Point,
|
||
},
|
||
Marquee {
|
||
start: Point,
|
||
cur: Point,
|
||
/// Shift: the objects inside leave the selection if selected, else join it.
|
||
toggle: bool,
|
||
},
|
||
/// Dragging a Live Corners widget.
|
||
Corner(CornerDrag),
|
||
/// Dragging a bracket of type on a path.
|
||
Bracket(BracketDrag),
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct SelectionTool {
|
||
state: State,
|
||
measure: Option<(Point, String)>,
|
||
guides: Vec<Overlay>,
|
||
/// What a bounding-box handle being dragged snaps to.
|
||
targets: Option<Targets>,
|
||
moving: Option<MoveSnap>,
|
||
guide: GuideEdit,
|
||
/// Smart Guides: the centre or anchor under the pointer, and its label ([`Self::hover_point`]).
|
||
hover: Option<(Point, &'static str)>,
|
||
}
|
||
|
||
/// Snapping for the selection moved as a whole, its targets gathered when the move begins: View →
|
||
/// Snap to Grid lands its bounds on the grid (#740), taking over from the rest; otherwise Smart
|
||
/// Guides line its bounds up with the other art, and with them off, View → Snap to Point lands the
|
||
/// point it was grabbed by (an anchor pressed near, else the pressed point) on an anchor or a ruler
|
||
/// guide. Snap to Pixel puts its top-left on whole pixels. A copy (Alt, which can go down or up at
|
||
/// any time during the drag) leaves the original behind, so it snaps to the original too.
|
||
pub(crate) struct MoveSnap {
|
||
bounds: Option<Rect>,
|
||
targets: Option<Targets>,
|
||
points: Option<Targets>,
|
||
/// The targets and points for a copy, gathered with the others before the previews move the
|
||
/// original.
|
||
copy_targets: Option<Targets>,
|
||
copy_points: Option<Targets>,
|
||
}
|
||
|
||
impl MoveSnap {
|
||
pub(crate) fn new(cx: &ToolContext) -> Self {
|
||
let guides = cx.smart_guides && !cx.snap_to_grid;
|
||
let points = |exclude: &[NodeId]| if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, exclude) };
|
||
Self {
|
||
bounds: selection_bounds(cx),
|
||
targets: guides.then(|| Targets::for_move(cx, &cx.selection.objects)),
|
||
points: points(&cx.selection.objects),
|
||
copy_targets: guides.then(|| Targets::for_move(cx, &[])),
|
||
copy_points: points(&[]),
|
||
}
|
||
}
|
||
|
||
/// The move (`copy`: the copy) by `d` of the selection grabbed at `start`, snapped, and its
|
||
/// guides.
|
||
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec<Overlay>) {
|
||
let (targets, points) = if copy { (&self.copy_targets, &self.copy_points) } else { (&self.targets, &self.points) };
|
||
let mut guides = vec![];
|
||
if cx.snap_to_grid
|
||
&& let Some(b) = self.bounds
|
||
{
|
||
d += grid_pull(b + d, cx.grid_step());
|
||
}
|
||
if let Some(t) = points {
|
||
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
|
||
d = q - start;
|
||
guides = ov;
|
||
}
|
||
if let (Some(t), Some(b)) = (targets, self.bounds) {
|
||
// The point the selection was grabbed by, near another object's anchor or centre,
|
||
// lands on it, rather than the bounds lining up a little beside it (#955).
|
||
if t.has_point_near(start + d, cx.snap_tol()) {
|
||
let (q, ov) = t.snap_point(start + d, cx.snap_tol());
|
||
d = q - start;
|
||
guides = ov;
|
||
} else {
|
||
let (adj, ov) = t.snap_rect(b + d, cx.snap_tol());
|
||
d += adj;
|
||
guides = ov;
|
||
}
|
||
}
|
||
if cx.snap_to_pixel
|
||
&& let Some(b) = self.bounds
|
||
{
|
||
d = Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0);
|
||
guides.clear();
|
||
}
|
||
(d, guides)
|
||
}
|
||
}
|
||
|
||
/// The shift that lands `r` on the grid (a gridline every `step`), on each axis by whichever of its
|
||
/// two edges and its centre is nearest a gridline, however far that is.
|
||
fn grid_pull(r: Rect, step: f64) -> Vec2 {
|
||
let pull = |vs: [f64; 3]| {
|
||
vs.iter().map(|v| vectorcraft_geom::snap::snap_to_grid(*v, step, 0.0) - v).min_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0)
|
||
};
|
||
Vec2::new(pull([r.x0, r.center().x, r.x1]), pull([r.y0, r.center().y, r.y1]))
|
||
}
|
||
|
||
pub fn matrix_json(a: Affine) -> Value {
|
||
let c = a.as_coeffs();
|
||
json!([c[0], c[1], c[2], c[3], c[4], c[5]])
|
||
}
|
||
|
||
/// Selection bounds used for the bounding box: visual bounds with Use Preview Bounds, else
|
||
/// geometric bounds.
|
||
pub fn selection_bounds(cx: &ToolContext) -> Option<Rect> {
|
||
cx.doc.bounds_of(&cx.selection.objects, cx.preview_bounds)
|
||
}
|
||
|
||
/// [`selection_bounds`] square to the selection's own angle: the bounding box the Selection tool
|
||
/// shows and drags (rotated after a rotation).
|
||
pub fn selection_box(cx: &ToolContext) -> Option<OrientedBox> {
|
||
cx.doc.oriented_bounds(&cx.selection.objects, cx.preview_bounds)
|
||
}
|
||
|
||
/// What of the bounding box is under the pointer.
|
||
enum BoxHit {
|
||
Handle(Handle),
|
||
/// Just outside a corner.
|
||
Rotate,
|
||
}
|
||
|
||
/// The bounding-box handle or rotate zone under `p`.
|
||
fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option<BoxHit> {
|
||
let (tol, lp) = (cx.tol(5.0), b.to_local(p));
|
||
if let Some(h) = hit_handle(b.rect, lp, tol) {
|
||
return Some(BoxHit::Handle(h));
|
||
}
|
||
in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate)
|
||
}
|
||
|
||
/// How near (screen points) the pointer must come to an object's centre or anchor for Smart Guides
|
||
/// to label it while hovering.
|
||
const HOVER_REACH: f64 = 6.0;
|
||
|
||
impl SelectionTool {
|
||
fn drag_threshold(cx: &ToolContext) -> f64 {
|
||
cx.tol(3.0)
|
||
}
|
||
|
||
/// The anchor of an object near `p` (the one under the pointer, else a selected one when a
|
||
/// press would keep the selection), or with Smart Guides on its centre, and its label,
|
||
/// "anchor" or "center", as Illustrator shows them while hovering with Smart Guides (#812). A
|
||
/// press there grabs the object by that point, so with Smart Guides or View → Snap to Point
|
||
/// that point is the one that snaps (#1274).
|
||
fn hover_point(cx: &ToolContext, p: Point) -> Option<(Point, &'static str)> {
|
||
if !cx.smart_guides && !cx.snap_to_point {
|
||
return None;
|
||
}
|
||
let reach = cx.tol(HOVER_REACH);
|
||
let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation));
|
||
let nearest = |points: &mut dyn Iterator<Item = Point>| {
|
||
points.filter(|q| q.distance(p) <= reach).min_by(|a, b| a.distance(p).total_cmp(&b.distance(p)))
|
||
};
|
||
// A press on an unselected object selects it alone: the selected ones stay behind.
|
||
let selected = if under.is_none_or(|u| cx.selection.contains(u)) { &cx.selection.objects[..] } else { &[] };
|
||
under.into_iter().chain(selected.iter().copied()).find_map(|id| {
|
||
let n = cx.doc.node(id)?;
|
||
let anchor = n.path_data().and_then(|pd| nearest(&mut pd.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))));
|
||
anchor.map(|q| (q, "anchor")).or_else(|| {
|
||
if !cx.smart_guides {
|
||
return None;
|
||
}
|
||
let c = cx.doc.bounds_of(&[id], cx.preview_bounds)?.center();
|
||
(c.distance(p) <= reach).then_some((c, "center"))
|
||
})
|
||
})
|
||
}
|
||
}
|
||
|
||
impl Tool for SelectionTool {
|
||
fn id(&self) -> &'static str {
|
||
"selection"
|
||
}
|
||
|
||
fn busy(&self) -> bool {
|
||
!matches!(self.state, State::Idle) || self.guide.busy()
|
||
}
|
||
|
||
fn transforming(&self) -> bool {
|
||
matches!(self.state, State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. })
|
||
}
|
||
|
||
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;
|
||
let m = ev.mods;
|
||
// A press near a centre or an anchor grabs the object by it.
|
||
self.hover = None;
|
||
let grab = if matches!(ev.kind, PointerKind::Down) { Self::hover_point(cx, p).map_or(p, |(q, _)| q) } else { p };
|
||
if matches!(ev.kind, PointerKind::Move) && matches!(self.state, State::Idle) {
|
||
self.hover = Self::hover_point(cx, p);
|
||
}
|
||
match (ev.kind, self.state.clone()) {
|
||
(PointerKind::DoubleClick, _) => {
|
||
self.state = State::Idle;
|
||
if let Some(a) = corners::double_click(cx, p, false).or_else(|| typewidget::double_click(cx, p)) {
|
||
return vec![a];
|
||
}
|
||
if crate::rulerguide::guide_at(cx, p).is_some() {
|
||
return vec![];
|
||
}
|
||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||
let top = h.top_object(cx.isolation);
|
||
if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) {
|
||
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
|
||
}
|
||
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) {
|
||
return vec![Action::SwitchTool("type".into())];
|
||
}
|
||
} else if cx.isolation.is_some() {
|
||
return vec![Action::Exec("object.exitIsolation".into(), json!({}))];
|
||
}
|
||
vec![]
|
||
}
|
||
(PointerKind::Down, _) => {
|
||
// 1. Live Corners widgets and type on a path's brackets, then the bounding-box
|
||
// handles of the current selection.
|
||
if let Some(c) = CornerDrag::hit(cx, ev, false) {
|
||
self.state = State::Corner(c);
|
||
return vec![];
|
||
}
|
||
if let Some(b) = BracketDrag::hit(cx, ev) {
|
||
self.state = State::Bracket(b);
|
||
return vec![];
|
||
}
|
||
// The type widget answers a double-click only: its clicks neither drag nor deselect.
|
||
if typewidget::over_widget(cx, p) {
|
||
self.state = State::Idle;
|
||
return vec![];
|
||
}
|
||
if cx.show_bbox
|
||
&& let Some(bx) = selection_box(cx)
|
||
{
|
||
match box_hit(cx, &bx, p) {
|
||
Some(BoxHit::Handle(handle)) => {
|
||
let is_area = |id: &NodeId| cx.doc.node(*id).is_some_and(is_area_type);
|
||
let areas = cx.selection.objects.iter().any(is_area);
|
||
// Only area type: the step resizes type areas; anything else scales.
|
||
let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" };
|
||
// Smart Guides align what the handle moves with the other objects.
|
||
self.targets = cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx));
|
||
self.state = State::Scaling { handle, bx, areas };
|
||
return vec![Action::Begin(label.into())];
|
||
}
|
||
Some(BoxHit::Rotate) => {
|
||
self.state = State::Rotating { center: bx.center(), start: p };
|
||
return vec![Action::Begin("Rotate".into())];
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
// 2. Ruler guides (over the art, as they are drawn).
|
||
if let Some(out) = self.guide.press(cx, ev) {
|
||
self.state = State::Idle;
|
||
return out;
|
||
}
|
||
// 3. Objects: Cmd/Ctrl-click selects the one under the selected one (cycling).
|
||
if m.cmd
|
||
&& cx.select_behind
|
||
&& let Some(behind) = object_behind(cx, p)
|
||
{
|
||
self.state = State::Moving { start: p, began: false, deselect: None, key: None };
|
||
return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))];
|
||
}
|
||
match hit_test(cx.doc, p, cx.hit_options()) {
|
||
Some(h) => {
|
||
let top = h.top_object(cx.isolation);
|
||
let mut out = vec![];
|
||
let (mut deselect, mut key) = (None, None);
|
||
if m.shift {
|
||
// A Shift-click takes a selected object out of the selection when it
|
||
// is released; a Shift-drag moves the selection, constrained.
|
||
if cx.selection.contains(top) {
|
||
deselect = Some(top);
|
||
} else {
|
||
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
|
||
}
|
||
} else if !cx.selection.contains(top) {
|
||
out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] })));
|
||
} else if cx.selection.objects.len() > 1 && !m.cmd && !m.alt {
|
||
key = Some(top);
|
||
}
|
||
self.state = State::Moving { start: grab, began: false, deselect, key };
|
||
out
|
||
}
|
||
None => {
|
||
self.state = State::Marquee { start: p, cur: p, toggle: m.shift };
|
||
vec![]
|
||
}
|
||
}
|
||
}
|
||
(PointerKind::Drag, State::Moving { start, began, .. }) => {
|
||
let mut out = vec![];
|
||
if !began {
|
||
if p.distance(start) < Self::drag_threshold(cx) {
|
||
return out;
|
||
}
|
||
out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() }));
|
||
self.moving = Some(MoveSnap::new(cx));
|
||
}
|
||
let mut d = move_delta(start, p, m.shift);
|
||
if let Some(snap) = &self.moving {
|
||
(d, self.guides) = snap.snap(cx, start, d, m.alt);
|
||
}
|
||
self.state = State::Moving { start, began: true, deselect: None, key: None };
|
||
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y)));
|
||
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
|
||
out
|
||
}
|
||
(PointerKind::Drag, State::Scaling { handle, bx, areas }) => {
|
||
// Scale in the box's own frame: along the objects' axes when it is rotated.
|
||
let mut a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
|
||
self.guides.clear();
|
||
if let Some(t) = &self.targets {
|
||
(a, self.guides) = t.snap_scale(&bx, handle, a, m.shift, m.alt, cx.snap_tol());
|
||
}
|
||
let nr = a.transform_rect_bbox(bx.rect);
|
||
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
|
||
let mut params = json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false });
|
||
if areas {
|
||
params["typeAreas"] = json!(true);
|
||
}
|
||
vec![Action::Preview("object.transform".into(), params)]
|
||
}
|
||
(PointerKind::Drag, State::Rotating { center, start, .. }) => {
|
||
let (a, deg) = rotate_for_drag(center, start, p, m.shift);
|
||
self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg)));
|
||
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))]
|
||
}
|
||
(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, m.cmd);
|
||
self.state = State::Bracket(b);
|
||
out
|
||
}
|
||
(PointerKind::Up, State::Bracket(b)) => {
|
||
self.state = State::Idle;
|
||
b.finish()
|
||
}
|
||
(PointerKind::Up, State::Moving { began, deselect, key, .. }) => {
|
||
self.state = State::Idle;
|
||
self.measure = None;
|
||
self.guides.clear();
|
||
self.moving = None;
|
||
match (began, deselect, key) {
|
||
(true, ..) => vec![Action::Commit],
|
||
(false, Some(id), _) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
|
||
// A click on the key object again: no key.
|
||
(false, None, Some(id)) if cx.selection.key == Some(id) => vec![Action::Exec("select.key".into(), json!({}))],
|
||
(false, None, Some(id)) if can_be_key(cx, id) => vec![Action::Exec("select.key".into(), json!({ "id": id.0 }))],
|
||
(false, None, _) => vec![],
|
||
}
|
||
}
|
||
(PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => {
|
||
self.state = State::Idle;
|
||
self.measure = None;
|
||
self.guides.clear();
|
||
self.targets = None;
|
||
vec![Action::Commit]
|
||
}
|
||
(PointerKind::Up, State::Marquee { start, toggle, .. }) => {
|
||
self.state = State::Idle;
|
||
let r = Rect::from_points(start, p);
|
||
if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) {
|
||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||
}
|
||
let ids: Vec<NodeId> = marquee(cx.doc, r, cx.isolation, false);
|
||
vec![Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) }))]
|
||
}
|
||
_ => vec![],
|
||
}
|
||
}
|
||
|
||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
let mut o = pathtype::overlays(cx);
|
||
if let Some(source) = cx.isolation.and_then(|id| cx.doc.node(id)).filter(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)) {
|
||
for n in source.children().into_iter().flatten() {
|
||
if let Some(path) = n.path_data() {
|
||
o.push(Overlay::Path { path: path.to_bezpath(), color: [0x80, 0x80, 0x80], width: 1.0, dashed: true });
|
||
}
|
||
}
|
||
}
|
||
match &self.state {
|
||
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
||
State::Corner(c) => o.extend(c.overlays(cx)),
|
||
_ => {}
|
||
}
|
||
o.extend(self.guides.iter().cloned());
|
||
o.extend(self.guide.overlays(cx));
|
||
if let (Some((q, label)), State::Idle, true) = (self.hover, &self.state, cx.smart_guides) {
|
||
let style = crate::guides::GuideStyle::of(cx);
|
||
if style.labels {
|
||
o.push(Overlay::Label { p: q, text: label.into(), color: style.color });
|
||
}
|
||
}
|
||
if let Some((p, t)) = &self.measure {
|
||
o.push(Overlay::Measure { p: *p, text: t.clone() });
|
||
}
|
||
o
|
||
}
|
||
|
||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||
match self.state {
|
||
State::Rotating { center, .. } => return Cursor::rotate_about(center, p),
|
||
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
|
||
State::Moving { began: true, .. } => return Cursor::Arrow,
|
||
State::Corner(_) => return Cursor::CornerRadius,
|
||
State::Bracket(_) => return Cursor::PathBracket,
|
||
_ => {}
|
||
}
|
||
if self.guide.busy() {
|
||
return self.guide.cursor(cx, p).unwrap_or_default();
|
||
}
|
||
if over_widget(cx, p, false) {
|
||
return Cursor::CornerRadius;
|
||
}
|
||
if over_bracket(cx, p) {
|
||
return Cursor::PathBracket;
|
||
}
|
||
if typewidget::over_widget(cx, p) {
|
||
return Cursor::TypeWidget;
|
||
}
|
||
if cx.show_bbox
|
||
&& let Some(bx) = selection_box(cx)
|
||
{
|
||
match box_hit(cx, &bx, p) {
|
||
Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle),
|
||
Some(BoxHit::Rotate) => return Cursor::rotate_about(bx.center(), p),
|
||
None => {}
|
||
}
|
||
}
|
||
if let Some(c) = self.guide.cursor(cx, p) {
|
||
return c;
|
||
}
|
||
// View → Snap to Point: over an anchor a press would grab the object by, the arrow turns
|
||
// hollow.
|
||
if cx.snap_to_point && matches!(Self::hover_point(cx, p), Some((_, "anchor"))) {
|
||
return Cursor::ArrowHollow;
|
||
}
|
||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||
if cx.selection.contains(h.top_object(cx.isolation)) || m.alt {
|
||
return Cursor::Move;
|
||
}
|
||
return Cursor::Arrow;
|
||
}
|
||
Cursor::Arrow
|
||
}
|
||
}
|
||
|
||
/// Select Behind: of the objects under `p` (topmost first), the one below the lowest selected
|
||
/// one, back to the topmost after the bottom one; the topmost when none is selected.
|
||
fn object_behind(cx: &ToolContext, p: Point) -> Option<NodeId> {
|
||
let stack = objects_at(cx.doc, p, cx.hit_options(), cx.isolation);
|
||
let next = stack.iter().rposition(|id| cx.selection.contains(*id)).map_or(0, |i| i + 1);
|
||
stack.get(next).or(stack.first()).copied()
|
||
}
|
||
|
||
/// Can a click make `id` the key object? `select.key` takes only a selected top-level object. In
|
||
/// isolation mode a click can pick a sublayer (inside an isolated layer), a compound-path member
|
||
/// (inside an isolated compound path) or an object inside another selected object (inside an
|
||
/// isolated group that is also selected), and `select.key` returns an error for all three.
|
||
fn can_be_key(cx: &ToolContext, id: NodeId) -> bool {
|
||
let inside_selected = cx.doc.ancestry(id).unwrap_or_default().iter().any(|a| *a != id && cx.selection.contains(*a));
|
||
// `paint_owner` returns a compound-path member's compound path.
|
||
let member = crate::xform::paint_owner(cx.doc, id) != id;
|
||
cx.doc.node(id).is_some_and(|n| !n.is_layer()) && !inside_selected && !member
|
||
}
|
||
|
||
/// Is `n` area type (text in a frame) whose frame the tools reshape? Type in perspective isn't:
|
||
/// it transforms whole.
|
||
pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool {
|
||
n.extras().perspective.is_none()
|
||
&& matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }))
|
||
}
|
||
|
||
/// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the
|
||
/// handle that sits where `h` appears on screen, in 45° steps.
|
||
fn handle_cursor(h: Handle, angle: f64) -> Cursor {
|
||
let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 };
|
||
let h = Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h);
|
||
match h {
|
||
Handle::Top | Handle::Bottom => Cursor::ResizeV,
|
||
Handle::Left | Handle::Right => Cursor::ResizeH,
|
||
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
|
||
Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::testutil::*;
|
||
use vectorcraft_doc::Selection;
|
||
|
||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||
PointerEvent::new(kind, x, y)
|
||
}
|
||
|
||
/// #955: grabbed by an anchor and dropped near another object's anchor, the anchor lands
|
||
/// exactly on it with Smart Guides, even where lining the bounds up would leave it beside it.
|
||
#[test]
|
||
fn a_move_grabbed_by_an_anchor_lands_it_on_another_anchor() {
|
||
let (mut d, id) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let tri = d.alloc_id();
|
||
let pts = [Point::new(350.3, 250.0), Point::new(300.0, 320.0), Point::new(420.0, 330.0)];
|
||
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true));
|
||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(tri, path, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
let (none, mut picked) = (Selection::default(), Selection::default());
|
||
picked.add(id);
|
||
let p = paint();
|
||
let mut t = SelectionTool::default();
|
||
// Grab the rectangle (not selected yet: no bounding box) by its bottom-right corner
|
||
// (200, 200); the press selects it, then drop it beside the tip.
|
||
let before = cx(&d, &none, &p);
|
||
t.pointer(&before, &ev(PointerKind::Move, 201.0, 201.0));
|
||
t.pointer(&before, &ev(PointerKind::Down, 201.0, 201.0));
|
||
let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Drag, 350.0, 249.8));
|
||
let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") };
|
||
let (x, y) = (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap());
|
||
assert!((200.0 + x - 350.3).abs() < 1e-9 && (200.0 + y - 250.0).abs() < 1e-9, "the corner lands at ({}, {})", 200.0 + x, 200.0 + y);
|
||
}
|
||
|
||
/// #812: with Smart Guides on, hovering near an object's centre or an anchor labels it, and a
|
||
/// press there grabs the object by that point.
|
||
#[test]
|
||
fn hovering_labels_the_centre_and_anchors_and_a_press_grabs_by_them() {
|
||
let (d, _) = doc_with_rect();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let label = |t: &SelectionTool| {
|
||
t.overlays(&cx).iter().find_map(|o| match o {
|
||
Overlay::Label { p, text, .. } => Some((*p, text.clone())),
|
||
_ => None,
|
||
})
|
||
};
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0));
|
||
assert_eq!(label(&t), Some((Point::new(150.0, 150.0), "center".to_string())));
|
||
t.pointer(&cx, &ev(PointerKind::Move, 102.0, 101.0));
|
||
assert_eq!(label(&t), Some((Point::new(100.0, 100.0), "anchor".to_string())));
|
||
t.pointer(&cx, &ev(PointerKind::Move, 130.0, 170.0));
|
||
assert_eq!(label(&t), None, "away from both");
|
||
// Grabbed by its centre, the object's centre follows the pointer.
|
||
t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0));
|
||
t.pointer(&cx, &ev(PointerKind::Down, 152.0, 151.0));
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 250.0));
|
||
assert!(a.iter().any(|a| matches!(a, Action::Preview(_, v) if v["matrix"][4] == 100.0 && v["matrix"][5] == 100.0)), "{a:?}");
|
||
// With Smart Guides off, nothing is labelled.
|
||
let off = ToolContext { smart_guides: false, ..cx };
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&off, &ev(PointerKind::Move, 152.0, 151.0));
|
||
assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Label { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn click_selects_object() {
|
||
let (d, id) = doc_with_rect();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
let a = t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
|
||
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)).is_empty());
|
||
}
|
||
|
||
/// #414: a press on a ruler guide picks it over the art, and drags it.
|
||
#[test]
|
||
fn a_press_on_a_ruler_guide_picks_it_over_the_art() {
|
||
let (mut d, _) = doc_with_rect();
|
||
d.guides.push(vectorcraft_doc::Guide::new(false, 150.0));
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
assert_eq!(t.cursor(&cx, Point::new(160.0, 151.0), Mods::default()), Cursor::ResizeV);
|
||
let a = t.pointer(&cx, &ev(PointerKind::Down, 160.0, 151.0));
|
||
assert_eq!(a, vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]);
|
||
assert!(t.busy());
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 171.0));
|
||
assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 0.0, "dy": 20.0, "copy": false})));
|
||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 160.0, 171.0)), vec![Action::Commit]);
|
||
assert!(!t.busy());
|
||
// A double-click there doesn't go through to the art.
|
||
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 160.0, 150.0)).is_empty());
|
||
}
|
||
|
||
/// Just outside each corner of the box the rotate cursor bows toward that corner, its arc
|
||
/// opening toward the box: at the top right it opens to the bottom left.
|
||
#[test]
|
||
fn the_rotate_cursor_wraps_the_corner_it_is_at() {
|
||
let (d, id) = doc_with_rect();
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let t = SelectionTool::default();
|
||
// The box runs from (100, 100) to (200, 200); 10 pt out from each corner, diagonally.
|
||
for ((x, y), octant) in [((93.0, 93.0), 7), ((207.0, 93.0), 1), ((207.0, 207.0), 3), ((93.0, 207.0), 5)] {
|
||
assert_eq!(t.cursor(&cx, Point::new(x, y), Mods::default()), Cursor::RotateToward(octant), "at ({x}, {y})");
|
||
}
|
||
assert_eq!(Cursor::rotate_about(Point::new(1.0, 1.0), Point::new(1.0, 1.0)), Cursor::Rotate, "on the centre");
|
||
assert_eq!(Cursor::rotate_about(Point::ORIGIN, Point::new(0.0, -5.0)), Cursor::RotateToward(0), "straight up");
|
||
}
|
||
|
||
#[test]
|
||
fn drag_moves_with_single_undo() {
|
||
let (d, id) = doc_with_rect();
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)).is_empty());
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0));
|
||
assert_eq!(a[0], Action::Begin("Move".into()));
|
||
assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["matrix"][4] == 10.0));
|
||
let a = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 150.0));
|
||
assert_eq!(a, vec![Action::Commit]);
|
||
}
|
||
|
||
/// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as
|
||
/// gathered before the previews moved it); released, the move doesn't.
|
||
#[test]
|
||
fn an_alt_copy_snaps_to_the_original_left_behind() {
|
||
use vectorcraft_doc::{Appearance, Node};
|
||
let (mut d, id) = doc_with_rect();
|
||
// An artboard whose edges and centre are out of reach.
|
||
d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0);
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let p = paint();
|
||
let mut t = SelectionTool::default();
|
||
let dx = |a: &[Action]| {
|
||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||
v["matrix"][4].as_f64().unwrap()
|
||
};
|
||
t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 150.0, 150.0));
|
||
assert_eq!(dx(&t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Drag, 200.0, 150.0))), 50.0);
|
||
// The previews moved the rectangle: the tool sees it 50 pt along.
|
||
let mut moved = d.clone();
|
||
let l = moved.layers[0].id;
|
||
moved.remove(id).unwrap();
|
||
let r = vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0));
|
||
moved.insert(Some(l), 0, Node::path(id, r, Appearance::default_art())).unwrap();
|
||
let c = cx(&moved, &s, &p);
|
||
let alt = Mods { alt: true, ..Mods::default() };
|
||
// The copy's left edge, 3 pt past the original's right edge (x = 200), lands on it.
|
||
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0).with_mods(alt))), 100.0);
|
||
// Without Alt nothing stays behind there.
|
||
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0);
|
||
}
|
||
|
||
/// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by
|
||
/// whichever of its edges or its centre is nearest a gridline, on each axis.
|
||
#[test]
|
||
fn a_move_lands_the_nearest_edge_or_centre_on_the_grid() {
|
||
let (d, id) = doc_with_rect();
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let p = paint();
|
||
let cx = ToolContext { snap_to_grid: true, smart_guides: true, ..cx(&d, &s, &p) };
|
||
let moved = |to: (f64, f64)| {
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1));
|
||
match a.get(1) {
|
||
Some(Action::Preview(_, v)) => (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()),
|
||
other => panic!("{other:?}"),
|
||
}
|
||
};
|
||
// Bounds 100–200 moved by (13, 4): the centres (163, 154) are nearest gridlines (162, 153).
|
||
let (x, y) = moved((163.0, 154.0));
|
||
assert!((x - 12.0).abs() < 1e-9 && (y - 3.0).abs() < 1e-9, "{x} {y}");
|
||
// Moved by (7, 0): the right edge is on a gridline (207) and the top goes up to one (99).
|
||
let (x, y) = moved((157.0, 150.0));
|
||
assert!((x - 7.0).abs() < 1e-9 && (y + 1.0).abs() < 1e-9, "{x} {y}");
|
||
}
|
||
|
||
#[test]
|
||
fn shift_drag_on_a_selected_object_moves_it_constrained() {
|
||
let (d, id) = doc_with_rect();
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() });
|
||
// Pressing keeps the selection: nothing is toggled yet.
|
||
assert!(t.pointer(&cx, &shift(PointerKind::Down, 150.0, 150.0)).is_empty());
|
||
let a = t.pointer(&cx, &shift(PointerKind::Drag, 190.0, 160.0));
|
||
assert_eq!(a[0], Action::Begin("Move".into()));
|
||
assert!(
|
||
matches!(&a[1], Action::Preview(c, v) if c == "object.transform"
|
||
&& v["matrix"][4].as_f64().is_some_and(|x| x > 39.0) && v["matrix"][5] == 0.0),
|
||
"moved right, constrained to horizontal: {a:?}"
|
||
);
|
||
assert_eq!(t.pointer(&cx, &shift(PointerKind::Up, 190.0, 160.0)), vec![Action::Commit]);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_click_toggles_an_object_on_release() {
|
||
let (d, id) = doc_with_rect();
|
||
let p = paint();
|
||
let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() });
|
||
let toggle = vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))];
|
||
// Selected: it leaves the selection when the click is released.
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let cx1 = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
assert!(t.pointer(&cx1, &shift(PointerKind::Down, 150.0, 150.0)).is_empty());
|
||
assert_eq!(t.pointer(&cx1, &shift(PointerKind::Up, 150.0, 150.0)), toggle);
|
||
// Not selected: it joins the selection at once, ready to be dragged.
|
||
let s = Selection::default();
|
||
let cx2 = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 150.0, 150.0)), toggle);
|
||
assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty());
|
||
}
|
||
|
||
/// #541: a click on one object of a selection of several makes it the key object; a click on
|
||
/// the key again lets it go. A drag moves them all, a lone object has no key.
|
||
#[test]
|
||
fn a_click_on_a_selected_object_makes_it_the_key() {
|
||
let (d, id) = doc_with_area_type();
|
||
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||
let p = paint();
|
||
let click = |s: &Selection, x, y| {
|
||
let c = cx(&d, s, &p);
|
||
let mut t = SelectionTool::default();
|
||
assert!(t.pointer(&c, &ev(PointerKind::Down, x, y)).is_empty());
|
||
t.pointer(&c, &ev(PointerKind::Up, x, y))
|
||
};
|
||
let mut s = Selection::default();
|
||
s.add(rect);
|
||
s.add(id);
|
||
assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]);
|
||
s.key = Some(rect);
|
||
assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({}))], "the key again: no key");
|
||
// A drag moves the selection and leaves the key alone.
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0));
|
||
t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0));
|
||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 150.0)), vec![Action::Commit]);
|
||
// One object selected: a click on it does nothing.
|
||
let mut one = Selection::default();
|
||
one.add(rect);
|
||
assert!(click(&one, 150.0, 150.0).is_empty());
|
||
}
|
||
|
||
/// In isolation mode a click makes only a selected top-level object the key, as `select.key`
|
||
/// takes no other: a click on a sublayer's art inside an isolated layer picks the sublayer,
|
||
/// a click inside an isolated group that is also selected picks an object inside it, and a
|
||
/// click inside an isolated compound path picks a member.
|
||
#[test]
|
||
fn a_click_makes_only_a_selected_top_level_object_the_key() {
|
||
use vectorcraft_doc::{Appearance, Document, LayerColor, Node, NodeKind};
|
||
use vectorcraft_geom::shapes;
|
||
let p = paint();
|
||
let click = |d: &Document, s: &Selection, isolation: NodeId| {
|
||
let c = ToolContext { isolation: Some(isolation), ..cx(d, s, &p) };
|
||
let mut t = SelectionTool::default();
|
||
assert!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)).is_empty());
|
||
t.pointer(&c, &ev(PointerKind::Up, 150.0, 150.0))
|
||
};
|
||
let path = |id: NodeId, r: Rect| Node::path(id, shapes::rectangle(r), Appearance::default_art());
|
||
let (under, beside) = (Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(300.0, 300.0, 340.0, 340.0));
|
||
// An isolated layer whose art under the click is in a sublayer.
|
||
let mut d = Document::new(500.0, 500.0);
|
||
let l = d.layers[0].id;
|
||
let (sub, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||
d.insert(Some(l), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap();
|
||
d.insert(Some(sub), 0, path(a, under)).unwrap();
|
||
d.insert(Some(l), 1, path(b, beside)).unwrap();
|
||
let mut s = Selection::default();
|
||
s.add(sub);
|
||
s.add(b);
|
||
assert!(click(&d, &s, l).is_empty(), "a sublayer is not a key object");
|
||
// An isolated group that is also selected.
|
||
let mut d = Document::new(500.0, 500.0);
|
||
let l = d.layers[0].id;
|
||
let (g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||
d.insert(Some(l), 0, Node::group(g, vec![])).unwrap();
|
||
d.insert(Some(g), 0, path(a, under)).unwrap();
|
||
d.insert(Some(g), 1, path(b, beside)).unwrap();
|
||
let mut s = Selection::default();
|
||
s.add(a);
|
||
s.add(g);
|
||
assert!(click(&d, &s, g).is_empty(), "an object inside the selected group is not a key object");
|
||
s.key = Some(a);
|
||
assert_eq!(click(&d, &s, g), vec![Action::Exec("select.key".into(), json!({}))], "the key again: no key");
|
||
// With the group not selected, the object becomes the key.
|
||
let mut s = Selection::default();
|
||
s.add(a);
|
||
s.add(b);
|
||
assert_eq!(click(&d, &s, g), vec![Action::Exec("select.key".into(), json!({"id": a.0}))]);
|
||
// An isolated compound path. Make Compound Path clears its members' appearance, so these
|
||
// members have none, and the click is on a's outline.
|
||
let mut d = Document::new(500.0, 500.0);
|
||
let l = d.layers[0].id;
|
||
let (c, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||
let member = |id: NodeId, r: Rect| std::sync::Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default()));
|
||
let children = vec![member(a, Rect::new(150.0, 100.0, 250.0, 200.0)), member(b, beside)];
|
||
let mut compound = Node::new(c, NodeKind::Compound { children, rule: Default::default() });
|
||
compound.appearance = Appearance::default_art().into();
|
||
d.insert(Some(l), 0, compound).unwrap();
|
||
let mut s = Selection::default();
|
||
s.add(a);
|
||
s.add(b);
|
||
assert!(click(&d, &s, c).is_empty(), "a compound-path member is not a key object");
|
||
}
|
||
|
||
#[test]
|
||
fn marquee_selects() {
|
||
let (d, id) = doc_with_rect();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
|
||
t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0));
|
||
assert_eq!(t.overlays(&cx).len(), 1);
|
||
let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0));
|
||
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||
}
|
||
|
||
/// Shift-drag a marquee: the objects it reaches toggle (#483), so selected ones can be
|
||
/// taken out of the selection; a Shift-click on empty canvas leaves the selection alone.
|
||
#[test]
|
||
fn shift_marquee_toggles() {
|
||
let (d, id) = doc_with_rect();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
let shift = Mods { shift: true, ..Default::default() };
|
||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(shift));
|
||
t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0).with_mods(shift));
|
||
let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0).with_mods(shift));
|
||
assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0]}))]);
|
||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(shift));
|
||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0).with_mods(shift)).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn handle_drag_scales() {
|
||
let (d, id) = doc_with_rect();
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
assert_eq!(t.cursor(&cx, Point::new(200.0, 200.0), Mods::default()), Cursor::ResizeNwSe);
|
||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0)), vec![Action::Begin("Scale".into())]);
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 300.0));
|
||
assert!(matches!(&a[0], Action::Preview(_, v) if v["matrix"][0] == 2.0));
|
||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]);
|
||
}
|
||
|
||
#[test]
|
||
fn handle_drag_snaps_to_the_other_objects_with_smart_guides() {
|
||
use vectorcraft_doc::{Appearance, Node};
|
||
let (mut d, _) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let id = d.alloc_id();
|
||
let r = Rect::new(250.0, 100.0, 300.0, 150.0);
|
||
d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap();
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let p = paint();
|
||
let mut c = cx(&d, &s, &p);
|
||
let height = |a: &[Action]| {
|
||
let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") };
|
||
v["matrix"][3].as_f64().unwrap() * 50.0
|
||
};
|
||
// The bottom handle, dragged near the neighbour's bottom edge (y = 200), lands on it.
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0));
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0));
|
||
assert!((height(&a) - 100.0).abs() < 1e-9, "{a:?}");
|
||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. })));
|
||
t.pointer(&c, &ev(PointerKind::Up, 275.0, 197.0));
|
||
assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. })));
|
||
// Smart Guides off: the edge follows the pointer.
|
||
c.smart_guides = false;
|
||
t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0));
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0));
|
||
assert!((height(&a) - 97.0).abs() < 1e-9, "{a:?}");
|
||
}
|
||
|
||
/// Smart Guides › Measurement Labels, Transform Tools and Snapping Tolerance (#394): the
|
||
/// readouts while moving and while scaling each follow their option; the tolerance sets how
|
||
/// far a dragged handle is pulled.
|
||
#[test]
|
||
fn smart_guide_display_preferences_drive_the_readouts_and_the_pull() {
|
||
use vectorcraft_doc::{Appearance, Node};
|
||
let (mut d, _) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let id = d.alloc_id();
|
||
d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 100.0, 300.0, 150.0)), Appearance::default_art()))
|
||
.unwrap();
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let p = paint();
|
||
let measures = |t: &SelectionTool, c: &ToolContext| t.overlays(c).iter().filter(|o| matches!(o, Overlay::Measure { .. })).count();
|
||
let height = |a: &[Action]| {
|
||
let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") };
|
||
v["matrix"][3].as_f64().unwrap() * 50.0
|
||
};
|
||
// Moving: the offset readout is a measurement label.
|
||
for on in [true, false] {
|
||
let c = ToolContext { measurement_labels: on, ..cx(&d, &s, &p) };
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, 275.0, 125.0));
|
||
t.pointer(&c, &ev(PointerKind::Drag, 285.0, 135.0));
|
||
assert_eq!(measures(&t, &c), usize::from(on), "measurement labels {on}");
|
||
t.pointer(&c, &ev(PointerKind::Up, 285.0, 135.0));
|
||
}
|
||
// Scaling by a handle: the size readout belongs to Transform Tools.
|
||
for on in [true, false] {
|
||
let c = ToolContext { transform_tools_guides: on, ..cx(&d, &s, &p) };
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0));
|
||
t.pointer(&c, &ev(PointerKind::Drag, 275.0, 180.0));
|
||
assert_eq!(measures(&t, &c), usize::from(on), "transform tools {on}");
|
||
t.pointer(&c, &ev(PointerKind::Up, 275.0, 180.0));
|
||
}
|
||
// Snapping Tolerance: 6 px from the neighbour's bottom edge is beyond 4, within 8.
|
||
for (tol, want) in [(4.0, 94.0), (8.0, 100.0), (0.0, 94.0)] {
|
||
let c = ToolContext { snapping_tolerance: tol, ..cx(&d, &s, &p) };
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0));
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 194.0));
|
||
assert!((height(&a) - want).abs() < 1e-9, "tolerance {tol}: {a:?}");
|
||
t.pointer(&c, &ev(PointerKind::Up, 275.0, 194.0));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn handle_drag_of_a_turned_box_snaps_on_the_page() {
|
||
use vectorcraft_doc::{Appearance, Node};
|
||
let matrix = |a: &[Action]| {
|
||
let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") };
|
||
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||
Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]])
|
||
};
|
||
// A 50 pt square beside the 100..200 rect, turned about its centre (275, 125).
|
||
for (turn, low) in [(90.0_f64, 150.0), (45.0, 125.0 + 25.0 * std::f64::consts::SQRT_2)] {
|
||
let (mut d, _) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let id = d.alloc_id();
|
||
let r = Rect::new(250.0, 100.0, 300.0, 150.0);
|
||
d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap();
|
||
let c = Point::new(275.0, 125.0);
|
||
d.node_mut(id)
|
||
.unwrap()
|
||
.transform(Affine::translate(c.to_vec2()) * Affine::rotate(turn.to_radians()) * Affine::translate(-c.to_vec2()), false);
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let bx = selection_box(&cx).unwrap();
|
||
assert_ne!(bx.angle, 0.0);
|
||
// The handle at the lowest point of the shape: a side at 90°, a corner at 45°.
|
||
let grab = Point::new(275.0, low);
|
||
assert!(Handle::ALL.iter().any(|h| (bx.to_doc() * h.pos(bx.rect)).distance(grab) < 1e-6), "{bx:?}");
|
||
let mut t = SelectionTool::default();
|
||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, grab.x, grab.y)), vec![Action::Begin("Scale".into())]);
|
||
// Dragged near the neighbour's bottom edge (y = 200), it lands on it.
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 275.0, 197.0));
|
||
let landed = matrix(&a) * grab;
|
||
assert!((landed.y - 200.0).abs() < 1e-6 && (landed.x - 275.0).abs() < 1e-6, "{turn}: {landed:?}");
|
||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Line { .. })));
|
||
t.pointer(&cx, &ev(PointerKind::Up, 275.0, 197.0));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn handle_drag_on_area_type_resizes_its_frame() {
|
||
let (d, text) = doc_with_area_type();
|
||
let p = paint();
|
||
let mut s = Selection::default();
|
||
s.add(text);
|
||
let cx1 = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
// Bottom-right handle of the 120 × 40 frame at (300, 300).
|
||
assert_eq!(t.pointer(&cx1, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Resize Type Area".into())]);
|
||
let a = t.pointer(&cx1, &ev(PointerKind::Drag, 460.0, 400.0));
|
||
assert!(matches!(&a[0], Action::Preview(c, v) if c == "object.transform" && v["typeAreas"] == true), "{a:?}");
|
||
assert_eq!(t.pointer(&cx1, &ev(PointerKind::Up, 460.0, 400.0)), vec![Action::Commit]);
|
||
// With another object the step is a scale, still resizing the type area.
|
||
let rect = d.layers[0].children().unwrap()[0].id;
|
||
s.add(rect);
|
||
let cx2 = cx(&d, &s, &p);
|
||
assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Scale".into())]);
|
||
let a = t.pointer(&cx2, &ev(PointerKind::Drag, 460.0, 400.0));
|
||
assert!(matches!(&a[0], Action::Preview(_, v) if v["typeAreas"] == true), "{a:?}");
|
||
t.pointer(&cx2, &ev(PointerKind::Up, 460.0, 400.0));
|
||
// Without area type the drag scales as before.
|
||
let mut s = Selection::default();
|
||
s.add(rect);
|
||
let cx3 = cx(&d, &s, &p);
|
||
t.pointer(&cx3, &ev(PointerKind::Down, 200.0, 200.0));
|
||
let a = t.pointer(&cx3, &ev(PointerKind::Drag, 300.0, 300.0));
|
||
assert!(matches!(&a[0], Action::Preview(_, v) if v.get("typeAreas").is_none()), "{a:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn turned_box_handles_turn_and_scale_along_the_object() {
|
||
let (mut d, id) = doc_with_rect();
|
||
let c = Point::new(150.0, 150.0);
|
||
let turn = Affine::translate(c.to_vec2()) * Affine::rotate(-std::f64::consts::FRAC_PI_4) * Affine::translate(-c.to_vec2());
|
||
d.node_mut(id).unwrap().transform(turn, false);
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
// The Right handle sits up and to the right, where a diagonal cursor fits it.
|
||
let k = 50.0 * std::f64::consts::FRAC_1_SQRT_2;
|
||
let right = Point::new(150.0 + k, 150.0 - k);
|
||
let mut t = SelectionTool::default();
|
||
assert_eq!(t.cursor(&cx, right, Mods::default()), Cursor::ResizeNeSw);
|
||
// The page box's corner is no handle any more.
|
||
assert_ne!(t.cursor(&cx, Point::new(150.0 + 2.0 * k, 150.0 - 2.0 * k), Mods::default()), Cursor::ResizeNeSw);
|
||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, right.x, right.y)), vec![Action::Begin("Scale".into())]);
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, right.x + k, right.y - k));
|
||
let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") };
|
||
// Doubling the width along 45° about the left side: the left handle stays put.
|
||
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||
let m = Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]);
|
||
let left = Point::new(150.0 - k, 150.0 + k);
|
||
assert!((m * left).distance(left) < 1e-9);
|
||
assert!((m * right).distance(Point::new(right.x + k, right.y - k)) < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn click_empty_deselects() {
|
||
let (d, _) = doc_with_rect();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0));
|
||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]);
|
||
}
|
||
}
|