mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 20:36:02 +00:00
Fourteen more Preferences options that were stored but read by nothing now
act, for the mouse and for agents (prefs.set, then pointer_gesture,
press_key or the commands):
- Selection & Anchor Display: Snap to Point and its distance (with Smart
Guides off, View > Snap to Point lands a dragged selection's grabbed
point, drawn points and reference points on anchors and ruler guides);
Move Locked and Hidden Artwork with Artboard (artboard.move, the Artboard
tool and Rearrange; off, hidden art now stays too); Enable Rubber Band for
the Pen and Curvature tools; Handles (solid, hollow, large);
Show handles when multiple anchors are selected (canvas and Direct
Selection's handle picking); Hide Corner Widget for angles greater than.
- General: Transform Pattern Tiles is the default of a new `patterns`
param on object.transform/move/rotate/scale/reflect/shear/transformEach
(journalled when the art uses a pattern), and the transform dialogs get a
Transform Patterns checkbox; Select Same Tint % (off, Select > Same
Fill/Stroke Color takes every tint of a global or spot swatch).
- Type: new type.step {attribute, by} steps size, leading, tracking, kerning
and baseline shift by the Size/Leading, Tracking and Baseline Shift
increments; Increase/Decrease Font Size (Cmd+Shift+. and Cmd+Shift+,);
the Type tool's Alt+arrows (kerning at a caret or tracking, leading,
Shift for baseline shift, Cmd/Ctrl for five steps). Enable Missing Glyph
Protection keeps characters a new font lacks in the font that had them
(text.setStyle, text.setRangeStyle). Fill New Type Objects With
Placeholder Text: type the Type tools place starts with placeholder text,
selected (text.create / text.createInPath take `placeholder`).
Units > General, Stroke and Type were already the single source of truth
(units.rs). Auto Size New Area Type, Show Font Names in English and
Highlight Alternate Glyphs need subsystems the app doesn't have yet and
stay open with the rest of #394.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
797 lines
38 KiB
Rust
797 lines
38 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, 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, drag a live rectangle's corner widget 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.
|
||
//! 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`]). Dragging a corner or an edge of area
|
||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size.
|
||
//!
|
||
//! 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};
|
||
use vectorcraft_geom::{PathData, Point, Rect};
|
||
|
||
use crate::bbox::move_delta;
|
||
use crate::corners::{self, CornerDrag, over_widget};
|
||
use crate::meshedit::MeshEdit;
|
||
use crate::rulerguide::GuideEdit;
|
||
use crate::select::{is_area_type, matrix_json};
|
||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||
|
||
#[derive(Clone, Debug)]
|
||
enum State {
|
||
Idle,
|
||
MoveAnchors {
|
||
start: Point,
|
||
began: bool,
|
||
},
|
||
MoveObject {
|
||
start: 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,
|
||
add: bool,
|
||
},
|
||
Corner(CornerDrag),
|
||
/// Dragging a point of a blend's spine from `from`.
|
||
SpinePoint {
|
||
id: NodeId,
|
||
anchor: usize,
|
||
from: Point,
|
||
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 being dragged.
|
||
guides: Vec<Overlay>,
|
||
/// 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![], 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 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
|
||
}
|
||
|
||
/// Anchor of any visible path or area type frame under `p`, topmost first.
|
||
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
|
||
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)?;
|
||
pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, _)| (id, si, ai))
|
||
})
|
||
}
|
||
|
||
/// 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)]))
|
||
})
|
||
}
|
||
|
||
/// Selection & Anchor Display → Show handles when multiple anchors are selected: off, handles show
|
||
/// (and drag) only while a single anchor is selected.
|
||
pub fn handles_shown(selection: &vectorcraft_doc::Selection, multiple: bool) -> bool {
|
||
multiple || selection.anchors.values().map(|set| set.len()).sum::<usize>() <= 1
|
||
}
|
||
|
||
/// Direction handle of a partially selected anchor under `p`: (id, si, ai, is_out).
|
||
fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
|
||
if !handles_shown(cx.selection, cx.handles_multiple) {
|
||
return None;
|
||
}
|
||
for (id, set) in &cx.selection.anchors {
|
||
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
|
||
for &(si, ai) in set {
|
||
let Some(a) = pd.subpaths.get(si).and_then(|s| s.anchors.get(ai)) else { continue };
|
||
if a.has_out() && a.h_out.distance(p) <= tol {
|
||
return Some((*id, si, ai, true));
|
||
}
|
||
if a.has_in() && a.h_in.distance(p) <= tol {
|
||
return Some((*id, si, ai, false));
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
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 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 tol = cx.pick_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, add: 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, 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).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) {
|
||
self.state = State::Corner(c);
|
||
return vec![];
|
||
}
|
||
if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id))
|
||
&& let Some(out) = hit_spine_handle(cx, sel, p, tol)
|
||
{
|
||
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, tol) {
|
||
self.state = State::Handle { id, si, ai, out };
|
||
return vec![Action::Begin("Reshape".into())];
|
||
}
|
||
if let Some((id, anchor, from)) = hit_spine_point(cx, p, tol) {
|
||
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)) = hit_anchor(cx, p, tol) {
|
||
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);
|
||
}
|
||
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
|
||
self.state = State::MoveAnchors { start: p, 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);
|
||
}
|
||
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, 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, 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, add: ev.mods.shift };
|
||
vec![]
|
||
}
|
||
(PointerKind::Drag, State::MoveAnchors { start, 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, began: true };
|
||
}
|
||
let d = move_delta(start, p, ev.mods.shift);
|
||
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, 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::MoveObject { start, began: true };
|
||
}
|
||
let d = move_delta(start, p, ev.mods.shift);
|
||
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) = crate::guides::snap_handle(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::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, add, .. }) => {
|
||
self.state = State::Marquee { start, cur: p, add };
|
||
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::Up,
|
||
State::MoveAnchors { began, .. }
|
||
| State::MoveObject { began, .. }
|
||
| State::Segment { began, .. }
|
||
| State::SpinePoint { began, .. }
|
||
| State::TypeArea { began, .. },
|
||
) => {
|
||
self.state = State::Idle;
|
||
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, add, .. }) => {
|
||
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 add { 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));
|
||
}
|
||
}
|
||
});
|
||
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, "add": add}))]
|
||
}
|
||
_ => 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));
|
||
}
|
||
if matches!(self.state, State::Handle { .. }) {
|
||
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)) {
|
||
return Cursor::CornerRadius;
|
||
}
|
||
// 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 the selection tolerance of `p`, 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.pick_tol();
|
||
cx.selection.objects.iter().copied().chain(under).find_map(|id| {
|
||
let path = cx.doc.node(id).and_then(editable_path)?;
|
||
path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(_, _, a)| (id, a.p))
|
||
})
|
||
}
|
||
|
||
/// 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 { vertical: true, pos: 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"}))]);
|
||
}
|
||
|
||
/// 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");
|
||
}
|
||
|
||
#[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"}))]);
|
||
}
|
||
|
||
#[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]]}], "add": false}))]);
|
||
}
|
||
/// 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:?}");
|
||
}
|
||
}
|