From 62588fd0800f399bdc14522b8eefc7c6ae4b6a43 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 05:03:13 +0000 Subject: [PATCH] Separate selection tool pointer phases and clarify imports --- crates/tools/src/builder/cache.rs | 2 +- crates/tools/src/builder/live_paint.rs | 4 +- crates/tools/src/context.rs | 2 +- crates/tools/src/cursor.rs | 2 +- crates/tools/src/direct.rs | 518 +++++++++--------- crates/tools/src/distort/perspective/tools.rs | 5 +- crates/tools/src/guides/snap.rs | 5 +- crates/tools/src/guides/targets.rs | 5 +- crates/tools/src/input.rs | 2 +- crates/tools/src/select.rs | 337 ++++++------ crates/tools/src/text/plain.rs | 2 +- crates/tools/src/text/ports.rs | 2 +- crates/tools/src/text/tests.rs | 52 +- crates/tools/src/text/tool.rs | 5 +- 14 files changed, 503 insertions(+), 440 deletions(-) diff --git a/crates/tools/src/builder/cache.rs b/crates/tools/src/builder/cache.rs index 27487e4c3..f34a29000 100644 --- a/crates/tools/src/builder/cache.rs +++ b/crates/tools/src/builder/cache.rs @@ -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. diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 667a6e2e3..aef5bd087 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -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 ---------- diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 298714b50..f3a0e3c19 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -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)] diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index 7661f3d72..513f8ceb1 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Point, Serialize, Vec2}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Cursor { diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 77a96e8b2..d3cb7d3e8 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -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 = 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 = 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)> = vec![]; - walk_pickable(cx.doc, cx.isolation, |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)); - } - } - }); - // 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 = 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 { + 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 = 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 { + 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 { + 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 { + 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 { + 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, bool), + ) -> Vec { + 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 { + 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 { + 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 = 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)> = vec![]; + walk_pickable(cx.doc, cx.isolation, |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)); + } + } + }); + // 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 = 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() + } +} diff --git a/crates/tools/src/distort/perspective/tools.rs b/crates/tools/src/distort/perspective/tools.rs index 9e563f00c..07df302ff 100644 --- a/crates/tools/src/distort/perspective/tools.rs +++ b/crates/tools/src/distort/perspective/tools.rs @@ -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 { diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 673e9f078..c3401b010 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -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). diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 4d77ac652..5c5c061e9 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -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 { diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs index 804939840..f60ac96b5 100644 --- a/crates/tools/src/input.rs +++ b/crates/tools/src/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Point, Serialize, Value}; /// Modifier keys. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1ea9723b9..3dab80fa3 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -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> { + 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 { + 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> { + // 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 { + 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 + } +} diff --git a/crates/tools/src/text/plain.rs b/crates/tools/src/text/plain.rs index 1b058a4b6..32db456c3 100644 --- a/crates/tools/src/text/plain.rs +++ b/crates/tools/src/text/plain.rs @@ -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. diff --git a/crates/tools/src/text/ports.rs b/crates/tools/src/text/ports.rs index ace85694c..d9c027fbc 100644 --- a/crates/tools/src/text/ports.rs +++ b/crates/tools/src/text/ports.rs @@ -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 diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index f5a454d09..53746bbf5 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -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"); diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 55f114a3d..daf754213 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -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 {