mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
Separate selection tool pointer phases and clarify imports
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use super::lookup::{drawn_outlines, faces_of, shape_builder_shapes, shapes_for};
|
||||
use super::map::{BuilderMap, sorted_roots};
|
||||
use super::*;
|
||||
use super::{Arc, Document, NodeId, PathData, Point, Receiver, ToolContext, TryRecvError, po};
|
||||
|
||||
/// What a [`RegionCache`] map was built for: the document's address and revision, the selected ids
|
||||
/// and whether filled open paths count as closed.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::cache::RegionCache;
|
||||
use super::lookup::{is_live_paint, live_paint_at, visible_edge_near, visible_face_at};
|
||||
use super::map::{HIGHLIGHT_RED, sorted_roots};
|
||||
use super::*;
|
||||
use super::{
|
||||
Action, Arc, BezPath, Cursor, Document, Mods, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Tool, ToolContext, Value, json, json_ids,
|
||||
};
|
||||
|
||||
// ---------- Live Paint Bucket ----------
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Document, NodeId, Paint, Point, Selection, Unit, Vec2, distort, select, xform};
|
||||
|
||||
/// Paint defaults for new art (the fill/stroke proxy) and the rest of the new-art template.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Point, Serialize, Vec2};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Cursor {
|
||||
|
||||
+266
-252
@@ -487,66 +487,9 @@ impl Tool for DirectSelectionTool {
|
||||
// Anchors and handle ends are picked from a little further than segments and objects.
|
||||
let (tol, point) = (cx.pick_tol(), cx.point_tol());
|
||||
match (ev.kind, self.state.clone()) {
|
||||
(PointerKind::Down, _) if self.group => {
|
||||
if let Some(out) = self.guide.press(cx, ev) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
}
|
||||
let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) else {
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
|
||||
return vec![];
|
||||
};
|
||||
// A compound path's paths are picked one at a time, below the compound path (#2441).
|
||||
if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) {
|
||||
h.ancestry.push(part);
|
||||
h.leaf = part;
|
||||
}
|
||||
// Walk up from the leaf: select the first ancestor not yet selected (below the layer).
|
||||
// Stop at the isolated object: the next group up is outside isolation mode.
|
||||
let chain: Vec<NodeId> = h.ancestry.iter().skip(1).rev().copied().take_while(|id| in_scope(cx.doc, cx.isolation, *id)).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
|
||||
};
|
||||
let mut deselect = None;
|
||||
let mut out = vec![];
|
||||
if ev.mods.shift {
|
||||
// Shift on a selected member deselects what was hit (a compound path's part),
|
||||
// not the next ancestor a plain click would add (#2441).
|
||||
if cx.selection.contains(h.leaf) {
|
||||
deselect = Some(h.leaf);
|
||||
} else {
|
||||
out.push(Action::Exec("select.toggle".into(), json!({"id": target.0})));
|
||||
}
|
||||
} else if !cx.selection.contains(target) {
|
||||
if cx.selection.contains(h.leaf) {
|
||||
out.push(Action::Exec("select.add".into(), json!({"ids": [target.0]})));
|
||||
} else {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [target.0]})));
|
||||
}
|
||||
}
|
||||
self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect };
|
||||
out
|
||||
}
|
||||
(PointerKind::Down, _) if self.group => self.press_group(cx, ev, p, tol),
|
||||
(PointerKind::DoubleClick, _) if self.group => release_guide_at(cx, p, ev.mods).into_iter().collect(),
|
||||
(PointerKind::DoubleClick, _) if !self.group => {
|
||||
self.state = State::Idle;
|
||||
if let Some(a) = corners::double_click(cx, p, true) {
|
||||
return vec![a];
|
||||
}
|
||||
if let Some(a) = release_guide_at(cx, p, ev.mods) {
|
||||
return vec![a];
|
||||
}
|
||||
if crate::rulerguide::guide_at(cx, p).is_some() {
|
||||
return vec![];
|
||||
}
|
||||
// A double-click edits what is drawn: not a clipping path's outline.
|
||||
if hit_test(cx.doc, p, cx.hit_options()).and_then(|h| cx.doc.node(h.leaf)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) {
|
||||
return vec![Action::SwitchTool("type".into())];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::DoubleClick, _) if !self.group => self.double_click(cx, ev, p),
|
||||
(PointerKind::Move, State::Idle) => {
|
||||
self.hover = if self.group || !cx.highlight_anchors { None } else { hovered_anchor(cx, p) };
|
||||
vec![]
|
||||
@@ -588,36 +531,7 @@ impl Tool for DirectSelectionTool {
|
||||
return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
|
||||
}
|
||||
if let Some((id, si, ai, grab)) = hit_anchor(cx, p, point) {
|
||||
if cx.doc.node(id).is_some_and(|n| is_area_type(n) || is_path_type(n)) {
|
||||
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
|
||||
}
|
||||
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
|
||||
if ev.mods.alt {
|
||||
self.state = State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None };
|
||||
let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none();
|
||||
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
|
||||
}
|
||||
// A path with all its anchors selected is stored whole (no partial entry).
|
||||
let already = cx.selection.contains(id)
|
||||
&& !cx.selection.segments.contains_key(&id)
|
||||
&& cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai)));
|
||||
// Paths all selected whole move as objects, held by this anchor, so Alt can turn
|
||||
// the copy on and off during the drag as with the Selection tool.
|
||||
self.state = if already && cx.selection.anchors.is_empty() {
|
||||
State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None }
|
||||
} else {
|
||||
State::MoveAnchors { start: p, grab, began: false }
|
||||
};
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))];
|
||||
}
|
||||
// Pressed among other selected anchors: a click selects this one alone (the
|
||||
// drag, which moves them all, keeps them).
|
||||
self.click = already.then_some((id, si, ai));
|
||||
if !already {
|
||||
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))];
|
||||
}
|
||||
return vec![];
|
||||
return self.press_anchor(cx, ev, p, (id, si, ai, grab));
|
||||
}
|
||||
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
|
||||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||||
@@ -631,79 +545,13 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
}
|
||||
if let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) {
|
||||
if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) {
|
||||
h.ancestry.push(part);
|
||||
h.leaf = part;
|
||||
}
|
||||
// A segment pick keeps its endpoints available for movement without selecting
|
||||
// the anchors themselves. Dragging reshapes a curve, 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 !ev.mods.alt
|
||||
&& matches!(h.kind, HitKind::Stroke | HitKind::Outline)
|
||||
&& let Some(s) = segment_at(cx, h.leaf, p)
|
||||
{
|
||||
if whole && !ev.mods.shift {
|
||||
self.click_segment = Some((h.leaf, s.si, s.seg));
|
||||
} else {
|
||||
let selected = cx.selection.segments.get(&h.leaf).is_some_and(|sel| sel.contains(&(s.si, s.seg)));
|
||||
self.state = if s.curved && !ev.mods.shift {
|
||||
State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false }
|
||||
} else {
|
||||
// Held by the segment itself, so the segment is what lands on a
|
||||
// ruler guide or lines up, not the pressed spot beside it (#3701).
|
||||
State::MoveAnchors { start: p, grab: hold_point(cx, &h, p), began: false }
|
||||
};
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec(
|
||||
"select.segments".into(),
|
||||
json!({"id": h.leaf.0, "segments": [[s.si, s.seg]], "mode": "toggle"}),
|
||||
)];
|
||||
}
|
||||
if selected {
|
||||
return vec![];
|
||||
}
|
||||
return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]]}))];
|
||||
}
|
||||
}
|
||||
// Clicking the fill selects the whole leaf path (all anchors).
|
||||
self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None };
|
||||
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![];
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) {
|
||||
return self.press_object(cx, ev, p, tol, h);
|
||||
}
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, State::MoveAnchors { start, grab, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Move".into()));
|
||||
self.state = State::MoveAnchors { start, grab, began: true };
|
||||
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
|
||||
}
|
||||
let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
|
||||
let unsnapped = d;
|
||||
// The grabbed anchor snaps and the others follow it. Shift keeps the move at its
|
||||
// angle, and the anchor slides along it onto what Smart Guides find there (#886).
|
||||
self.guides.clear();
|
||||
if let Some(snap) = &self.anchor_snap {
|
||||
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
|
||||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||||
(d, self.guides) = (q - grab, guides);
|
||||
}
|
||||
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
|
||||
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::MoveAnchors { start, grab, began }) => self.drag_move_anchors(cx, ev, p, (start, grab, began)),
|
||||
(PointerKind::Drag, State::Segment { id, si, seg, t, start, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
@@ -720,40 +568,7 @@ impl Tool for DirectSelectionTool {
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::MoveObject { start, grab, hold, began, .. }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() }));
|
||||
self.state = State::MoveObject { start, grab, hold, began: true, deselect: None };
|
||||
if grab.is_some() {
|
||||
// Alt can go down or up at any time: gather both while nothing has moved.
|
||||
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
|
||||
self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default())));
|
||||
} else {
|
||||
self.move_snap = Some(MoveSnap::new(cx));
|
||||
}
|
||||
}
|
||||
let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
|
||||
let unsnapped = d;
|
||||
self.guides.clear();
|
||||
let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap };
|
||||
if let (Some(grab), Some(snap)) = (grab, snap) {
|
||||
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
|
||||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||||
(d, self.guides) = (q - grab, guides);
|
||||
} else if let Some(snap) = &self.move_snap {
|
||||
(d, self.guides) = if ev.mods.shift { snap.snap_along(cx, hold, d, ev.mods.alt) } else { snap.snap(cx, hold, d, ev.mods.alt) };
|
||||
}
|
||||
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
|
||||
out.push(Action::Preview(
|
||||
"object.transform".into(),
|
||||
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}),
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::MoveObject { start, grab, hold, began, .. }) => self.drag_move_object(cx, ev, p, (start, grab, hold, began)),
|
||||
(PointerKind::Drag, State::TypeArea { id, anchors, start, began }) => {
|
||||
let mut out = vec![];
|
||||
let on_path = cx.doc.node(id).is_some_and(is_path_type);
|
||||
@@ -846,71 +661,13 @@ impl Tool for DirectSelectionTool {
|
||||
| State::SpinePoint { began, .. }
|
||||
| State::SpineSegment { began, .. }
|
||||
| State::TypeArea { began, .. },
|
||||
) => {
|
||||
let deselect = match self.state {
|
||||
State::MoveObject { deselect, .. } => deselect,
|
||||
_ => None,
|
||||
};
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
|
||||
match (began, deselect, self.click.take(), self.click_segment.take()) {
|
||||
(true, ..) => vec![Action::Commit],
|
||||
(false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))],
|
||||
(false, None, Some((id, si, ai)), _) => {
|
||||
vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))]
|
||||
}
|
||||
(false, None, None, Some((id, si, seg))) => {
|
||||
vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))]
|
||||
}
|
||||
(false, None, None, None) => vec![],
|
||||
}
|
||||
}
|
||||
) => self.release_move(began),
|
||||
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
vec![Action::Commit]
|
||||
}
|
||||
(PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => {
|
||||
self.state = State::Idle;
|
||||
let r = Rect::from_points(start, p);
|
||||
if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) {
|
||||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
}
|
||||
// With Group Selection's enclosed marquee, the tool selects the objects inside it
|
||||
// (members of groups too), each whole, as a click selects one.
|
||||
if enclosed {
|
||||
let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true);
|
||||
let editable = cx.doc.editable();
|
||||
touched.retain(|id| editable(*id));
|
||||
let ids: Vec<Value> = crate::select::inside(cx, r, touched).into_iter().map(|id| json!(id.0)).collect();
|
||||
let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": ids }));
|
||||
return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, true)).collect();
|
||||
}
|
||||
// Anchors inside the rect, on editable paths inside isolation mode.
|
||||
let mut sel: Vec<(NodeId, Vec<AnchorRef>)> = vec![];
|
||||
walk_pickable(cx.doc, cx.isolation, |n| {
|
||||
if let NodeKind::Path { path, .. } = &n.kind {
|
||||
let v: Vec<AnchorRef> = path.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect();
|
||||
if !v.is_empty() {
|
||||
sel.push((n.id, v));
|
||||
}
|
||||
}
|
||||
});
|
||||
// Objects without anchors to pick (type, images, symbols…) are selected whole when
|
||||
// the marquee touches them, as with the Selection tool (#927).
|
||||
for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) {
|
||||
let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
|
||||
if whole && !sel.iter().any(|(s, _)| *s == id) {
|
||||
sel.push((id, vec![]));
|
||||
}
|
||||
}
|
||||
let editable = cx.doc.editable();
|
||||
sel.retain(|(id, _)| editable(*id));
|
||||
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
|
||||
let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}));
|
||||
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect()
|
||||
}
|
||||
(PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => self.release_marquee(cx, p, (start, toggle, enclosed)),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
@@ -1077,3 +834,260 @@ impl DirectSelectionTool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl DirectSelectionTool {
|
||||
fn press_group(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, tol: f64) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.press(cx, ev) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
}
|
||||
let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) else {
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
|
||||
return vec![];
|
||||
};
|
||||
// A compound path's paths are picked one at a time, below the compound path (#2441).
|
||||
if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) {
|
||||
h.ancestry.push(part);
|
||||
h.leaf = part;
|
||||
}
|
||||
// Walk up from the leaf: select the first ancestor not yet selected (below the layer).
|
||||
// Stop at the isolated object: the next group up is outside isolation mode.
|
||||
let chain: Vec<NodeId> = h.ancestry.iter().skip(1).rev().copied().take_while(|id| in_scope(cx.doc, cx.isolation, *id)).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 };
|
||||
let mut deselect = None;
|
||||
let mut out = vec![];
|
||||
if ev.mods.shift {
|
||||
// Shift on a selected member deselects what was hit (a compound path's part),
|
||||
// not the next ancestor a plain click would add (#2441).
|
||||
if cx.selection.contains(h.leaf) {
|
||||
deselect = Some(h.leaf);
|
||||
} else {
|
||||
out.push(Action::Exec("select.toggle".into(), json!({"id": target.0})));
|
||||
}
|
||||
} else if !cx.selection.contains(target) {
|
||||
if cx.selection.contains(h.leaf) {
|
||||
out.push(Action::Exec("select.add".into(), json!({"ids": [target.0]})));
|
||||
} else {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [target.0]})));
|
||||
}
|
||||
}
|
||||
self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect };
|
||||
out
|
||||
}
|
||||
fn double_click(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point) -> Vec<Action> {
|
||||
self.state = State::Idle;
|
||||
if let Some(a) = corners::double_click(cx, p, true) {
|
||||
return vec![a];
|
||||
}
|
||||
if let Some(a) = release_guide_at(cx, p, ev.mods) {
|
||||
return vec![a];
|
||||
}
|
||||
if crate::rulerguide::guide_at(cx, p).is_some() {
|
||||
return vec![];
|
||||
}
|
||||
// A double-click edits what is drawn: not a clipping path's outline.
|
||||
if hit_test(cx.doc, p, cx.hit_options()).and_then(|h| cx.doc.node(h.leaf)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) {
|
||||
return vec![Action::SwitchTool("type".into())];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
fn press_anchor(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, (id, si, ai, grab): (NodeId, usize, usize, Point)) -> Vec<Action> {
|
||||
if cx.doc.node(id).is_some_and(|n| is_area_type(n) || is_path_type(n)) {
|
||||
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
|
||||
}
|
||||
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
|
||||
if ev.mods.alt {
|
||||
self.state = State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None };
|
||||
let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none();
|
||||
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
|
||||
}
|
||||
// A path with all its anchors selected is stored whole (no partial entry).
|
||||
let already =
|
||||
cx.selection.contains(id) && !cx.selection.segments.contains_key(&id) && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai)));
|
||||
// Paths all selected whole move as objects, held by this anchor, so Alt can turn
|
||||
// the copy on and off during the drag as with the Selection tool.
|
||||
self.state = if already && cx.selection.anchors.is_empty() {
|
||||
State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None }
|
||||
} else {
|
||||
State::MoveAnchors { start: p, grab, began: false }
|
||||
};
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))];
|
||||
}
|
||||
// Pressed among other selected anchors: a click selects this one alone (the
|
||||
// drag, which moves them all, keeps them).
|
||||
self.click = already.then_some((id, si, ai));
|
||||
if !already {
|
||||
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
fn press_object(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, tol: f64, mut h: vectorcraft_doc::hit::Hit) -> Vec<Action> {
|
||||
if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) {
|
||||
h.ancestry.push(part);
|
||||
h.leaf = part;
|
||||
}
|
||||
// A segment pick keeps its endpoints available for movement without selecting
|
||||
// the anchors themselves. Dragging reshapes a curve, 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 !ev.mods.alt
|
||||
&& matches!(h.kind, HitKind::Stroke | HitKind::Outline)
|
||||
&& let Some(s) = segment_at(cx, h.leaf, p)
|
||||
{
|
||||
if whole && !ev.mods.shift {
|
||||
self.click_segment = Some((h.leaf, s.si, s.seg));
|
||||
} else {
|
||||
let selected = cx.selection.segments.get(&h.leaf).is_some_and(|sel| sel.contains(&(s.si, s.seg)));
|
||||
self.state = if s.curved && !ev.mods.shift {
|
||||
State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false }
|
||||
} else {
|
||||
// Held by the segment itself, so the segment is what lands on a
|
||||
// ruler guide or lines up, not the pressed spot beside it (#3701).
|
||||
State::MoveAnchors { start: p, grab: hold_point(cx, &h, p), began: false }
|
||||
};
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]], "mode": "toggle"}))];
|
||||
}
|
||||
if selected {
|
||||
return vec![];
|
||||
}
|
||||
return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]]}))];
|
||||
}
|
||||
}
|
||||
// Clicking the fill selects the whole leaf path (all anchors).
|
||||
self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None };
|
||||
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]}))];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
fn drag_move_anchors(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, (start, grab, began): (Point, Point, bool)) -> Vec<Action> {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Move".into()));
|
||||
self.state = State::MoveAnchors { start, grab, began: true };
|
||||
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
|
||||
}
|
||||
let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
|
||||
let unsnapped = d;
|
||||
// The grabbed anchor snaps and the others follow it. Shift keeps the move at its
|
||||
// angle, and the anchor slides along it onto what Smart Guides find there (#886).
|
||||
self.guides.clear();
|
||||
if let Some(snap) = &self.anchor_snap {
|
||||
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
|
||||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||||
(d, self.guides) = (q - grab, guides);
|
||||
}
|
||||
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
|
||||
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
|
||||
out
|
||||
}
|
||||
fn drag_move_object(
|
||||
&mut self,
|
||||
cx: &ToolContext,
|
||||
ev: &PointerEvent,
|
||||
p: Point,
|
||||
(start, grab, hold, began): (Point, Option<Point>, Point, bool),
|
||||
) -> Vec<Action> {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() }));
|
||||
self.state = State::MoveObject { start, grab, hold, began: true, deselect: None };
|
||||
if grab.is_some() {
|
||||
// Alt can go down or up at any time: gather both while nothing has moved.
|
||||
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
|
||||
self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default())));
|
||||
} else {
|
||||
self.move_snap = Some(MoveSnap::new(cx));
|
||||
}
|
||||
}
|
||||
let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
|
||||
let unsnapped = d;
|
||||
self.guides.clear();
|
||||
let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap };
|
||||
if let (Some(grab), Some(snap)) = (grab, snap) {
|
||||
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
|
||||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||||
(d, self.guides) = (q - grab, guides);
|
||||
} else if let Some(snap) = &self.move_snap {
|
||||
(d, self.guides) = if ev.mods.shift { snap.snap_along(cx, hold, d, ev.mods.alt) } else { snap.snap(cx, hold, d, ev.mods.alt) };
|
||||
}
|
||||
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
|
||||
out.push(Action::Preview(
|
||||
"object.transform".into(),
|
||||
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}),
|
||||
));
|
||||
out
|
||||
}
|
||||
fn release_move(&mut self, began: bool) -> Vec<Action> {
|
||||
let deselect = match self.state {
|
||||
State::MoveObject { deselect, .. } => deselect,
|
||||
_ => None,
|
||||
};
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
|
||||
match (began, deselect, self.click.take(), self.click_segment.take()) {
|
||||
(true, ..) => vec![Action::Commit],
|
||||
(false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))],
|
||||
(false, None, Some((id, si, ai)), _) => {
|
||||
vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))]
|
||||
}
|
||||
(false, None, None, Some((id, si, seg))) => {
|
||||
vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))]
|
||||
}
|
||||
(false, None, None, None) => vec![],
|
||||
}
|
||||
}
|
||||
fn release_marquee(&mut self, cx: &ToolContext, p: Point, (start, toggle, enclosed): (Point, bool, bool)) -> Vec<Action> {
|
||||
self.state = State::Idle;
|
||||
let r = Rect::from_points(start, p);
|
||||
if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) {
|
||||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
}
|
||||
// With Group Selection's enclosed marquee, the tool selects the objects inside it
|
||||
// (members of groups too), each whole, as a click selects one.
|
||||
if enclosed {
|
||||
let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true);
|
||||
let editable = cx.doc.editable();
|
||||
touched.retain(|id| editable(*id));
|
||||
let ids: Vec<Value> = crate::select::inside(cx, r, touched).into_iter().map(|id| json!(id.0)).collect();
|
||||
let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": ids }));
|
||||
return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, true)).collect();
|
||||
}
|
||||
// Anchors inside the rect, on editable paths inside isolation mode.
|
||||
let mut sel: Vec<(NodeId, Vec<AnchorRef>)> = vec![];
|
||||
walk_pickable(cx.doc, cx.isolation, |n| {
|
||||
if let NodeKind::Path { path, .. } = &n.kind {
|
||||
let v: Vec<AnchorRef> = path.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect();
|
||||
if !v.is_empty() {
|
||||
sel.push((n.id, v));
|
||||
}
|
||||
}
|
||||
});
|
||||
// Objects without anchors to pick (type, images, symbols…) are selected whole when
|
||||
// the marquee touches them, as with the Selection tool (#927).
|
||||
for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) {
|
||||
let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
|
||||
if whole && !sel.iter().any(|(s, _)| *s == id) {
|
||||
sel.push((id, vec![]));
|
||||
}
|
||||
}
|
||||
let editable = cx.doc.editable();
|
||||
sel.retain(|(id, _)| editable(*id));
|
||||
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
|
||||
let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}));
|
||||
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Action, Affine, BoxHandle, Cursor, Handle, Homography, Mods, NodeId, NodeKind, Overlay, PerspectiveGrid, Plane, PlaneDrag, Point, PointerEvent,
|
||||
PointerKind, Press, Rect, Tool, ToolContext, ToolKey, Vec2, cell_steps, drag_handle, handles, json, scale_for_drag,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PerspectiveGridTool {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::targets::{Targets, span};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, GuideStyle, Handle, HitOptions, Line, NodeId, OrientedBox, Overlay, ParamCurve, ParamCurveDeriv, ParamCurveNearest, PathSeg, Point, Rect,
|
||||
ScreenFrame, ToolContext, Vec2, constrain_angle_from, hit_test,
|
||||
};
|
||||
|
||||
/// Where curves `a` and `b` cross near `p`: a point projected from one onto the other and back
|
||||
/// settles where they meet (none when they don't nearby).
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::snap::{Leave, meeting, seg_direction, touch_params};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, Arc, BezPath, Document, FontDb, GuideStyle, Handle, HashSet, Line, Node, NodeId, NodeKind, OrientedBox, Overlay, ParamCurve,
|
||||
ParamCurveNearest, PathSeg, Point, Rect, Ruler, Selection, Shape, SubPath, ToolContext, Vec2, layout_text,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Kind {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Point, Serialize, Value};
|
||||
|
||||
/// Modifier keys.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
+179
-158
@@ -628,51 +628,8 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
// Mirror and grid repeat controls take precedence over art and ruler guides; the mirror
|
||||
// axis line and the grid's controls yield to the bounding-box handles they overlap.
|
||||
if let State::Mirror(mut d) = self.state.clone() {
|
||||
match ev.kind {
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
let mut out = d.drag(cx, ev.pos, ev.mods);
|
||||
if ev.kind == PointerKind::Up {
|
||||
out.extend(d.finish());
|
||||
self.state = State::Idle;
|
||||
} else {
|
||||
self.state = State::Mirror(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
_ => return vec![],
|
||||
}
|
||||
}
|
||||
if let State::GridRepeat(mut d) = self.state.clone() {
|
||||
match ev.kind {
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
let mut out = d.drag(ev.pos);
|
||||
if ev.kind == PointerKind::Up {
|
||||
out.extend(d.finish());
|
||||
self.state = State::Idle;
|
||||
} else {
|
||||
self.state = State::GridRepeat(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
_ => return vec![],
|
||||
}
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& ev.kind == PointerKind::Down
|
||||
&& let Some(d) = crate::mirror::MirrorDrag::hit(cx, ev.pos, ev.mods)
|
||||
{
|
||||
self.state = State::Mirror(d);
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& ev.kind == PointerKind::Down
|
||||
&& let Some(d) = crate::repeat_grid::GridDrag::hit(cx, ev.pos, ev.mods)
|
||||
{
|
||||
self.state = State::GridRepeat(d);
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
if let Some(out) = self.repeat_controls(cx, ev) {
|
||||
return out;
|
||||
}
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
@@ -687,99 +644,10 @@ impl Tool for SelectionTool {
|
||||
self.hover = Self::hover_point(cx, p);
|
||||
}
|
||||
match (ev.kind, self.state.clone()) {
|
||||
(PointerKind::DoubleClick, _) => {
|
||||
self.state = State::Idle;
|
||||
// A threaded port: the thread breaks there. Any other port takes the double-click
|
||||
// too, so it neither edits the type nor isolates.
|
||||
if let Some((id, port)) = crate::text::port_at(cx, p) {
|
||||
let (input, output) = crate::text::port_states(cx.doc, id, false);
|
||||
let (state, name) = if port == crate::text::Port::In { (input, "in") } else { (output, "out") };
|
||||
if state == crate::text::PortState::Threaded {
|
||||
return vec![Action::Exec("text.thread.break".into(), json!({ "id": id.0, "port": name }))];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
if let Some(a) = corners::double_click(cx, p, false).or_else(|| pie::double_click(cx, p)).or_else(|| typewidget::double_click(cx, p))
|
||||
{
|
||||
return vec![a];
|
||||
}
|
||||
// The Shaper Group's mode widget answers each click of a double-click on it.
|
||||
if shaperwidget::over_widget(cx, p) {
|
||||
return vec![];
|
||||
}
|
||||
if let Some(a) = release_guide_at(cx, p, m) {
|
||||
return vec![a];
|
||||
}
|
||||
if crate::rulerguide::guide_at(cx, p).is_some() {
|
||||
return vec![];
|
||||
}
|
||||
// A double-click edits what is drawn: not a clipping path's outline.
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||||
let top = h.top_object(cx.isolation);
|
||||
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) {
|
||||
return vec![Action::SwitchTool("type".into())];
|
||||
}
|
||||
// An object that [`isolates`], not the one already isolated.
|
||||
if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| isolates(&n.kind)) && cx.isolation != Some(top) {
|
||||
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
|
||||
}
|
||||
} else if cx.isolation.is_some() {
|
||||
return vec![Action::Exec("object.exitIsolation".into(), json!({}))];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::DoubleClick, _) => self.double_click(cx, p, m),
|
||||
(PointerKind::Down, _) => {
|
||||
// 0. A port of selected area type or type on a path: the pointer takes its text,
|
||||
// to thread on from the out port or back from the in port.
|
||||
if let Some((from, port)) = crate::text::port_at(cx, p) {
|
||||
self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None };
|
||||
return vec![];
|
||||
}
|
||||
// 1. Live Corners widgets, pie 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(d) = PieDrag::hit(cx, ev) {
|
||||
self.state = State::Pie(d);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(b) = BracketDrag::hit(cx, ev) {
|
||||
self.state = State::Bracket(b);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(a) = shaperwidget::click(cx, p) {
|
||||
self.state = State::Idle;
|
||||
return vec![a];
|
||||
}
|
||||
// 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" };
|
||||
// What the handle moves snaps as a dragged point does: to pixels or the
|
||||
// grid, else with Smart Guides to the other objects, else with Snap to
|
||||
// Point to their anchors; the ruler guides with Snap to Point.
|
||||
self.targets = Some(PointSnap::new(cx, || Targets::for_scale(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 => {}
|
||||
}
|
||||
if let Some(out) = self.press_widgets(cx, ev, p) {
|
||||
return out;
|
||||
}
|
||||
// 2. Ruler guides (over the art, as they are drawn).
|
||||
if let Some(out) = self.guide.press(cx, ev) {
|
||||
@@ -824,27 +692,7 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
(PointerKind::Drag, State::Moving { start, press, hold, began, .. }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(press) < 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, cx.constrain_angle);
|
||||
let unsnapped = d;
|
||||
if let Some(snap) = &self.moving {
|
||||
(d, self.guides) = if m.shift { snap.snap_along(cx, hold, d, m.alt) } else { snap.snap(cx, hold, d, m.alt) };
|
||||
}
|
||||
// Shift has already constrained `d`: any change is a snap.
|
||||
self.snapped = (d - unsnapped).hypot() > 1e-9;
|
||||
self.state = State::Moving { start, press, hold, 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::Moving { start, press, hold, began, .. }) => self.drag_moving(cx, p, m, (start, press, hold, began)),
|
||||
(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);
|
||||
@@ -1122,3 +970,176 @@ pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl SelectionTool {
|
||||
fn repeat_controls(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
|
||||
if let State::Mirror(mut d) = self.state.clone() {
|
||||
match ev.kind {
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
let mut out = d.drag(cx, ev.pos, ev.mods);
|
||||
if ev.kind == PointerKind::Up {
|
||||
out.extend(d.finish());
|
||||
self.state = State::Idle;
|
||||
} else {
|
||||
self.state = State::Mirror(d);
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
_ => return Some(vec![]),
|
||||
}
|
||||
}
|
||||
if let State::GridRepeat(mut d) = self.state.clone() {
|
||||
match ev.kind {
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
let mut out = d.drag(ev.pos);
|
||||
if ev.kind == PointerKind::Up {
|
||||
out.extend(d.finish());
|
||||
self.state = State::Idle;
|
||||
} else {
|
||||
self.state = State::GridRepeat(d);
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
_ => return Some(vec![]),
|
||||
}
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& ev.kind == PointerKind::Down
|
||||
&& let Some(d) = crate::mirror::MirrorDrag::hit(cx, ev.pos, ev.mods)
|
||||
{
|
||||
self.state = State::Mirror(d);
|
||||
self.hover = None;
|
||||
return Some(vec![]);
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& ev.kind == PointerKind::Down
|
||||
&& let Some(d) = crate::repeat_grid::GridDrag::hit(cx, ev.pos, ev.mods)
|
||||
{
|
||||
self.state = State::GridRepeat(d);
|
||||
self.hover = None;
|
||||
return Some(vec![]);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn double_click(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Vec<Action> {
|
||||
self.state = State::Idle;
|
||||
// A threaded port: the thread breaks there. Any other port takes the double-click
|
||||
// too, so it neither edits the type nor isolates.
|
||||
if let Some((id, port)) = crate::text::port_at(cx, p) {
|
||||
let (input, output) = crate::text::port_states(cx.doc, id, false);
|
||||
let (state, name) = if port == crate::text::Port::In { (input, "in") } else { (output, "out") };
|
||||
if state == crate::text::PortState::Threaded {
|
||||
return vec![Action::Exec("text.thread.break".into(), json!({ "id": id.0, "port": name }))];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
if let Some(a) = corners::double_click(cx, p, false).or_else(|| pie::double_click(cx, p)).or_else(|| typewidget::double_click(cx, p)) {
|
||||
return vec![a];
|
||||
}
|
||||
// The Shaper Group's mode widget answers each click of a double-click on it.
|
||||
if shaperwidget::over_widget(cx, p) {
|
||||
return vec![];
|
||||
}
|
||||
if let Some(a) = release_guide_at(cx, p, m) {
|
||||
return vec![a];
|
||||
}
|
||||
if crate::rulerguide::guide_at(cx, p).is_some() {
|
||||
return vec![];
|
||||
}
|
||||
// A double-click edits what is drawn: not a clipping path's outline.
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||||
let top = h.top_object(cx.isolation);
|
||||
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) {
|
||||
return vec![Action::SwitchTool("type".into())];
|
||||
}
|
||||
// An object that [`isolates`], not the one already isolated.
|
||||
if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| isolates(&n.kind)) && cx.isolation != Some(top) {
|
||||
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
|
||||
}
|
||||
} else if cx.isolation.is_some() {
|
||||
return vec![Action::Exec("object.exitIsolation".into(), json!({}))];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn press_widgets(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point) -> Option<Vec<Action>> {
|
||||
// 0. A port of selected area type or type on a path: the pointer takes its text,
|
||||
// to thread on from the out port or back from the in port.
|
||||
if let Some((from, port)) = crate::text::port_at(cx, p) {
|
||||
self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None };
|
||||
return Some(vec![]);
|
||||
}
|
||||
// 1. Live Corners widgets, pie 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 Some(vec![]);
|
||||
}
|
||||
if let Some(d) = PieDrag::hit(cx, ev) {
|
||||
self.state = State::Pie(d);
|
||||
return Some(vec![]);
|
||||
}
|
||||
if let Some(b) = BracketDrag::hit(cx, ev) {
|
||||
self.state = State::Bracket(b);
|
||||
return Some(vec![]);
|
||||
}
|
||||
if let Some(a) = shaperwidget::click(cx, p) {
|
||||
self.state = State::Idle;
|
||||
return Some(vec![a]);
|
||||
}
|
||||
// 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 Some(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" };
|
||||
// What the handle moves snaps as a dragged point does: to pixels or the
|
||||
// grid, else with Smart Guides to the other objects, else with Snap to
|
||||
// Point to their anchors; the ruler guides with Snap to Point.
|
||||
self.targets = Some(PointSnap::new(cx, || Targets::for_scale(cx)));
|
||||
self.state = State::Scaling { handle, bx, areas };
|
||||
return Some(vec![Action::Begin(label.into())]);
|
||||
}
|
||||
Some(BoxHit::Rotate) => {
|
||||
self.state = State::Rotating { center: bx.center(), start: p };
|
||||
return Some(vec![Action::Begin("Rotate".into())]);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectionTool {
|
||||
fn drag_moving(&mut self, cx: &ToolContext, p: Point, m: Mods, (start, press, hold, began): (Point, Point, Point, bool)) -> Vec<Action> {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(press) < 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, cx.constrain_angle);
|
||||
let unsnapped = d;
|
||||
if let Some(snap) = &self.moving {
|
||||
(d, self.guides) = if m.shift { snap.snap_along(cx, hold, d, m.alt) } else { snap.snap(cx, hold, d, m.alt) };
|
||||
}
|
||||
// Shift has already constrained `d`: any change is a snap.
|
||||
self.snapped = (d - unsnapped).hypot() > 1e-9;
|
||||
self.state = State::Moving { start, press, hold, 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{ImeSurrounding, Range, ToolKey};
|
||||
|
||||
/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes).
|
||||
/// `None` when `r` runs past the end of `s` or backwards.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{FontDb, NodeId, NodeKind, Overlay, Point, Rect, Shape, TextKind, TextObject, ToolContext};
|
||||
|
||||
/// How far (screen px) the ports of area type sit from the frame's corners, along its sides: the
|
||||
/// out port above the bottom-right corner on the right side, the in port below the top-left corner
|
||||
|
||||
@@ -107,6 +107,37 @@ fn type_arrows_keep_devanagari_clusters_with_an_rtl_suffix() {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_unwrapped_hindi_glyphs(
|
||||
db: &FontDb,
|
||||
face: &vectorcraft_text::FontFace,
|
||||
hindi_glyphs: &[&vectorcraft_text::PositionedGlyph],
|
||||
hindi: &str,
|
||||
text: &str,
|
||||
) {
|
||||
for glyph in hindi_glyphs {
|
||||
let used = db.face_by_id(glyph.font_id).expect("the Hindi glyph's loaded face");
|
||||
assert!(used.file_data() == face.file_data(), "setup: Hindi uses the selected face bytes");
|
||||
assert_eq!(used.face_index(), face.face_index(), "setup: Hindi uses the selected face index");
|
||||
assert_ne!(glyph.gid, 0, "setup: Hindi glyph is present");
|
||||
assert!(!glyph.rtl, "setup: Hindi prefix is LTR");
|
||||
assert!(glyph.len > 0 && glyph.byte + glyph.len <= hindi.len(), "setup: Hindi glyph source range");
|
||||
assert!(
|
||||
text.is_char_boundary(glyph.byte) && text.is_char_boundary(glyph.byte + glyph.len),
|
||||
"setup: glyph source ranges are UTF-8 boundaries"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_wrapped_hindi_glyphs(db: &FontDb, face: &vectorcraft_text::FontFace, lay: &vectorcraft_text::TextLayout, hindi: &str) {
|
||||
for glyph in lay.glyphs.iter().filter(|g| g.byte < hindi.len()) {
|
||||
let used = db.face_by_id(glyph.font_id).expect("the wrapped Hindi glyph's loaded face");
|
||||
assert!(used.file_data() == face.file_data(), "setup: wrapped Hindi uses the selected face bytes");
|
||||
assert_eq!(used.face_index(), face.face_index(), "setup: wrapped Hindi uses the selected face index");
|
||||
assert_ne!(glyph.gid, 0, "setup: wrapped Hindi glyph is present");
|
||||
assert!(!glyph.rtl, "setup: wrapped Hindi prefix stays LTR");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_arrows_keep_devanagari_clusters_across_wrapped_lines() {
|
||||
use vectorcraft_doc::{Composer, Justify};
|
||||
@@ -146,18 +177,7 @@ fn type_arrows_keep_devanagari_clusters_across_wrapped_lines() {
|
||||
assert!(!natural.vertical, "setup: horizontal text");
|
||||
let hindi_glyphs: Vec<_> = natural.glyphs.iter().filter(|g| g.byte < hindi.len()).collect();
|
||||
assert!(!hindi_glyphs.is_empty(), "setup: actual Hindi glyphs exist");
|
||||
for glyph in &hindi_glyphs {
|
||||
let used = db.face_by_id(glyph.font_id).expect("the Hindi glyph's loaded face");
|
||||
assert!(used.file_data() == face.file_data(), "setup: Hindi uses the selected face bytes");
|
||||
assert_eq!(used.face_index(), face.face_index(), "setup: Hindi uses the selected face index");
|
||||
assert_ne!(glyph.gid, 0, "setup: Hindi glyph is present");
|
||||
assert!(!glyph.rtl, "setup: Hindi prefix is LTR");
|
||||
assert!(glyph.len > 0 && glyph.byte + glyph.len <= hindi.len(), "setup: Hindi glyph source range");
|
||||
assert!(
|
||||
text.is_char_boundary(glyph.byte) && text.is_char_boundary(glyph.byte + glyph.len),
|
||||
"setup: glyph source ranges are UTF-8 boundaries"
|
||||
);
|
||||
}
|
||||
assert_unwrapped_hindi_glyphs(db, &face, &hindi_glyphs, hindi, text);
|
||||
let prefix_width: f64 = hindi_glyphs.iter().filter(|g| g.byte < split).map(|g| g.advance).sum();
|
||||
let split_width: f64 = hindi_glyphs.iter().filter(|g| g.byte == split).map(|g| g.advance).sum();
|
||||
assert!(prefix_width.is_finite() && prefix_width > 0.0, "setup: prefix has positive finite advance");
|
||||
@@ -179,13 +199,7 @@ fn type_arrows_keep_devanagari_clusters_across_wrapped_lines() {
|
||||
assert!(!lay.overflow, "setup: the full RTL suffix fits the tall frame");
|
||||
assert!(!lay.vertical, "setup: horizontal area text");
|
||||
assert!(lay.glyphs.iter().any(|g| g.rtl && g.byte > hindi.len()), "setup: actual RTL suffix selects Type's visual-arrow route");
|
||||
for glyph in lay.glyphs.iter().filter(|g| g.byte < hindi.len()) {
|
||||
let used = db.face_by_id(glyph.font_id).expect("the wrapped Hindi glyph's loaded face");
|
||||
assert!(used.file_data() == face.file_data(), "setup: wrapped Hindi uses the selected face bytes");
|
||||
assert_eq!(used.face_index(), face.face_index(), "setup: wrapped Hindi uses the selected face index");
|
||||
assert_ne!(glyph.gid, 0, "setup: wrapped Hindi glyph is present");
|
||||
assert!(!glyph.rtl, "setup: wrapped Hindi prefix stays LTR");
|
||||
}
|
||||
assert_wrapped_hindi_glyphs(db, &face, &lay, hindi);
|
||||
assert_eq!(edit::next_cluster(text, second), hindi.len(), "setup: logical cluster end is 18");
|
||||
assert_eq!(edit::prev_cluster(text, hindi.len()), second, "setup: logical cluster start is 6");
|
||||
assert_eq!(edit::prev_cluster(text, split), second, "setup: raw line endpoint 12 is inside the grapheme");
|
||||
|
||||
@@ -3,7 +3,10 @@ use super::plain::{
|
||||
plain_len, step_key,
|
||||
};
|
||||
use super::ports::{ported, ports, ports_shown};
|
||||
use super::*;
|
||||
use super::{
|
||||
Action, BezPath, Cursor, ImeSurrounding, Mode, Mods, NodeId, Overlay, Point, PointerEvent, PointerKind, Preedit, Range, Rect, Shape,
|
||||
SurroundingUnit, TextKind, TextRun, Tool, ToolContext, ToolKey, TypeTool, Value, edit, json,
|
||||
};
|
||||
|
||||
impl Tool for TypeTool {
|
||||
fn id(&self) -> &'static str {
|
||||
|
||||
Reference in New Issue
Block a user