//! 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, marquee to select anchors, drag a live rectangle's corner widget to round its corners. //! Group Selection: click selects the leaf; each further click on it adds the next enclosing group. //! Both pick the key objects of a blend. 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. use std::borrow::Cow; use serde_json::{Value, json}; use vectorcraft_doc::hit::hit_test; use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, Point, Rect}; use crate::bbox::move_delta; use crate::corners::{CornerDrag, over_widget}; use crate::meshedit::MeshEdit; 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, }, 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, 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, } impl DirectSelectionTool { pub fn new(group: bool) -> Self { Self { group, state: State::Idle, spine: None, mesh: MeshEdit::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 { 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 { 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> { 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 { 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)> { 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)])) }) } /// 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)> { 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()) } impl Tool for DirectSelectionTool { fn id(&self) -> &'static str { if self.group { "groupSelection" } else { "directSelection" } } fn busy(&self) -> bool { !matches!(self.state, State::Idle) } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; let tol = cx.tol(4.0); match (ev.kind, self.state.clone()) { (PointerKind::Down, _) if self.group => { 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 = 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::Down, _) => { if let Some(c) = CornerDrag::hit(cx, p) { 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(h) = hit_test(cx.doc, p, cx.hit_options()) { // Clicking a segment/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::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 }) => { vec![Action::Preview( "path.setHandle".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": p.x, "y": p.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::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; 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)> = vec![]; cx.doc.walk(|n| { if let NodeKind::Path { path, .. } = &n.kind { let v: Vec = 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 = 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 { 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 } } } 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; } Cursor::ArrowHollow } } /// The frame anchors of the selected area type, which Direct Selection drags. fn frame_overlays(cx: &ToolContext) -> Vec { 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, p: Point, shift: bool) -> Vec { 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 { 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; /// 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) } #[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}))]); } }