From 5d0cffbba126c6eb5467a0f990eb0f97ececa39f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 01:19:37 -0800 Subject: [PATCH] Canvas: a dropped press keeps what earlier clicks picked Escape also clears state that earlier clicks set up, so taking Escape for a dropped press lost it in five tools. Each now drops only the press: - Blend: the object picked first and the blend being added to are put back as they were at the press. - Dimension: a dropped click picks nothing, and the points or lines picked before stay; a dropped drag is canceled as before. - Width: the drag ends and the width points selected at the press are selected again. - Gradient: a dropped click on a freeform gradient leaves the line being drawn open. - Selection: with the pointer loaded with text, a dropped press threads nothing and the text stays loaded; a dropped press on a port loads none. --- crates/tools/src/dimension.rs | 43 ++++++++++++ crates/tools/src/distort/width.rs | 42 ++++++++++++ crates/tools/src/meshblend.rs | 105 +++++++++++++++++++++++------ crates/tools/src/select.rs | 31 +++++++++ crates/tools/src/select/tests.rs | 35 ++++++++++ crates/tools/src/xform/freeform.rs | 25 +++++++ crates/tools/src/xform/gradient.rs | 11 +++ 7 files changed, 271 insertions(+), 21 deletions(-) diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index d3c7bdf83..2290f5d2f 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -390,6 +390,15 @@ impl Tool for DimensionTool { } vec![] } + /// A dropped drag is canceled as by Escape. A dropped click picks nothing, and the points or + /// lines earlier clicks picked stay. + fn drop_press(&mut self, cx: &ToolContext) -> Vec { + if self.dragging { + return self.key(cx, ToolKey::Escape, Mods::default()); + } + self.press = None; + vec![] + } fn deactivate(&mut self, cx: &ToolContext) -> Vec { self.key(cx, ToolKey::Escape, Mods::default()) } @@ -465,3 +474,37 @@ impl Tool for DimensionTool { out } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use vectorcraft_doc::Selection; + + /// A dropped click picks nothing and keeps the point an earlier click picked. A dropped drag + /// out from a segment is canceled as by Escape. + #[test] + fn a_dropped_press_keeps_the_earlier_picks() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y); + let mut t = DimensionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 400.0, 400.0)); + t.pointer(&c, &ev(PointerKind::Up, 400.0, 400.0)); + assert_eq!(t.points.len(), 1); + // The engine sends the release after the drop. + t.pointer(&c, &ev(PointerKind::Down, 450.0, 420.0)); + assert!(t.drop_press(&c).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 450.0, 420.0)).is_empty()); + assert_eq!(t.points.len(), 1, "the earlier pick stays and the dropped click picks nothing"); + assert!(t.pending.is_none() && t.busy()); + let mut t = DimensionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 150.0, 100.0)); + let acts = t.pointer(&c, &ev(PointerKind::Drag, 150.0, 60.0)); + assert!(matches!(acts.first(), Some(Action::Begin(_))), "{acts:?}"); + assert_eq!(t.drop_press(&c), vec![Action::Cancel]); + assert!(!t.busy()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 150.0, 60.0)).is_empty(), "the release places nothing"); + } +} diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs index 27592035b..e7050d3a7 100644 --- a/crates/tools/src/distort/width.rs +++ b/crates/tools/src/distort/width.rs @@ -105,6 +105,8 @@ pub struct WidthTool { drag: Option, /// Selected width points: the stroke's owner and their positions t. selected: Option<(NodeId, Vec)>, + /// The selected width points when the button went down: a dropped press puts them back. + before_press: Option<(NodeId, Vec)>, } /// The object whose stroke path `id` draws with: its compound path, if it is a member of one. @@ -308,6 +310,7 @@ impl Tool for WidthTool { vec![Action::Dialog("widthPoint".into(), json!({"id": spot.owner.0, "index": w.index, "adjustAdjoining": ev.mods.shift}))] } PointerKind::Down => { + self.before_press = self.selected.clone(); let Some(spot) = Self::spot_at(cx, p) else { if !ev.mods.shift { self.selected = None; @@ -401,6 +404,13 @@ impl Tool for WidthTool { && self.drag.is_none() && self.selected.as_ref().is_some_and(|(id, _)| !self.selected_indices(cx, *id).is_empty()) } + /// A dropped press ends the drag, and the width points selected when the button went down are + /// selected again. + fn drop_press(&mut self, _cx: &ToolContext) -> Vec { + self.drag = None; + self.selected = self.before_press.take(); + vec![] + } fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape && self.drag.is_none() { self.selected = None; @@ -691,6 +701,38 @@ mod tests { assert!(t.selected.is_some()); } + /// A dropped press keeps the width points earlier clicks selected: a press away from the + /// path, dropped, deselects nothing, and a dropped drag of another point ends with the points + /// selected before it, and its release commits nothing. + #[test] + fn a_dropped_press_keeps_the_selected_width_points() { + let (d, id) = doc_three_points(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = WidthTool::default(); + let (none, shift) = (Mods::default(), Mods { shift: true, ..Default::default() }); + t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, none)); + t.pointer(&c, &ev(PointerKind::Up, 150.0, 200.0, none)); + t.pointer(&c, &ev(PointerKind::Down, 250.0, 200.0, shift)); + t.pointer(&c, &ev(PointerKind::Up, 250.0, 200.0, shift)); + assert_eq!(t.selected_indices(&c, id), vec![1, 3]); + // The engine sends the release after the drop. + assert!(t.pointer(&c, &ev(PointerKind::Down, 200.0, 450.0, shift)).is_empty()); + assert!(t.drop_press(&c).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 200.0, 450.0, shift)).is_empty()); + assert_eq!(t.selected_indices(&c, id), vec![1, 3]); + assert!(t.pointer(&c, &ev(PointerKind::Down, 200.0, 450.0, none)).is_empty()); + assert!(t.drop_press(&c).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 200.0, 450.0, none)).is_empty()); + assert_eq!(t.selected_indices(&c, id), vec![1, 3]); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 200.0, 200.0, none)), vec![Action::Begin("Width Point".into())]); + t.pointer(&c, &ev(PointerKind::Drag, 220.0, 200.0, none)); + assert!(t.drop_press(&c).is_empty()); + assert!(!t.busy()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 220.0, 200.0, none)).is_empty(), "the release commits nothing"); + assert_eq!(t.selected_indices(&c, id), vec![1, 3]); + } + #[test] fn width_points_show_on_every_subpath() { let (d, c, _) = doc_compound(); diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 0790407d2..b32986150 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -48,6 +48,9 @@ pub struct BlendTool { first: Option, /// A blend was just made: the next click adds to it (the selected blend). chained: bool, + /// `first` and `chained` when the button went down: a dropped press puts them back, unless + /// the press made a blend. + before_press: Option<(Option, bool)>, } /// The anchor of the first subpath of path or compound path `id` (its first member's) nearest @@ -98,25 +101,32 @@ impl Tool for BlendTool { match ev.kind { PointerKind::DoubleClick => options(self), PointerKind::Down if ev.mods.alt => options(self), - PointerKind::Down => match (self.first(cx), pick(cx, ev.pos)) { - (_, None) => { - (self.first, self.chained) = (None, false); - vec![] - } - (None, Some((top, start))) => { - self.first = Some((top, start)); - vec![Action::Exec("select.set".into(), json!({"ids": [top.0]}))] - } - (Some((a, sa)), Some((b, sb))) if a != b && cx.doc.node(a).is_some() => { - (self.first, self.chained) = (None, true); - let mut p = json!({"ids": [a.0, b.0]}); - if sa.is_some() || sb.is_some() { - p["starts"] = json!([sa, sb]); + PointerKind::Down => { + self.before_press = Some((self.first, self.chained)); + match (self.first(cx), pick(cx, ev.pos)) { + (_, None) => { + (self.first, self.chained) = (None, false); + vec![] } - vec![Action::Exec("object.blend.make".into(), p)] + (None, Some((top, start))) => { + self.first = Some((top, start)); + vec![Action::Exec("select.set".into(), json!({"ids": [top.0]}))] + } + (Some((a, sa)), Some((b, sb))) if a != b && cx.doc.node(a).is_some() => { + (self.first, self.chained, self.before_press) = (None, true, None); + let mut p = json!({"ids": [a.0, b.0]}); + if sa.is_some() || sb.is_some() { + p["starts"] = json!([sa, sb]); + } + vec![Action::Exec("object.blend.make".into(), p)] + } + (Some(_), Some(_)) => vec![], } - (Some(_), Some(_)) => vec![], - }, + } + PointerKind::Up => { + self.before_press = None; + vec![] + } _ => vec![], } } @@ -151,6 +161,15 @@ impl Tool for BlendTool { } vec![] } + /// A dropped press puts back the object picked first and the blend being added to as they + /// were when the button went down, so the picks of earlier clicks stay and the press picks + /// nothing. A blend the press made stays made. + fn drop_press(&mut self, _cx: &ToolContext) -> Vec { + if let Some((first, chained)) = self.before_press.take() { + (self.first, self.chained) = (first, chained); + } + vec![] + } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { (self.first, self.chained) = (None, false); vec![] @@ -354,7 +373,7 @@ mod tests { let mut s = Selection::default(); s.set([g]); let c = cx(&d, &s, &p); - let mut t = BlendTool { first: None, chained: true }; + let mut t = BlendTool { first: None, chained: true, ..Default::default() }; assert!(t.first(&c).is_some()); assert!(t.chosen_again(&c).is_empty()); assert!(t.first(&c).is_none(), "choosing the tool again ends adding to the blend just made"); @@ -385,7 +404,7 @@ mod tests { // After a blend is made, a click while the selected blend is gone picks the object it hits. let mut s = Selection::default(); s.set([NodeId(9999)]); - let mut t = BlendTool { first: None, chained: true }; + let mut t = BlendTool { first: None, chained: true, ..Default::default() }; let acts = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 320.0, 120.0)); assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [b.0]}))]); } @@ -432,7 +451,7 @@ mod tests { d.insert(Some(l), 1, blend).unwrap(); let mut s = Selection::default(); let p = paint(); - let mut t = BlendTool { first: None, chained: true }; + let mut t = BlendTool { first: None, chained: true, ..Default::default() }; s.set([g]); let c = cx(&d, &s, &p); let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); @@ -477,7 +496,7 @@ mod tests { let mut s = Selection::default(); s.set([g]); let c = cx(&d, &s, &p); - let mut t = BlendTool { first: None, chained: true }; + let mut t = BlendTool { first: None, chained: true, ..Default::default() }; assert!(t.first(&c).is_some()); assert!(t.key(&c, crate::ToolKey::Escape, Mods::default()).is_empty()); assert!(t.first(&c).is_none(), "Escape ends adding to the blend just made"); @@ -485,6 +504,50 @@ mod tests { assert!(esc(&mut BlendTool::default()).is_empty(), "nothing to drop"); } + /// A dropped press (a long touch, a canceled touch) picks nothing, keeps the pick an earlier + /// click made, and keeps adding to the blend just made: the next click blends with them. + #[test] + fn a_dropped_press_keeps_the_blend_tools_first_pick() { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let (b, g) = (d.alloc_id(), d.alloc_id()); + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (k1, k2) = (d.alloc_id(), d.alloc_id()); + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) + }; + let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() }); + d.insert(Some(l), 2, blend).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y); + // The engine sends the release after the drop. + let dropped = |t: &mut BlendTool, c: &ToolContext, x: f64, y: f64| { + t.pointer(c, &ev(PointerKind::Down, x, y)); + assert!(t.drop_press(c).is_empty()); + assert!(t.pointer(c, &ev(PointerKind::Up, x, y)).is_empty()); + }; + let mut t = BlendTool::default(); + dropped(&mut t, &c, 150.0, 150.0); + assert_eq!(t.first(&c), None, "a dropped first press picks nothing"); + t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)); + t.pointer(&c, &ev(PointerKind::Up, 150.0, 150.0)); + // A second press on a, and a press on empty canvas, each dropped. + dropped(&mut t, &c, 151.0, 151.0); + dropped(&mut t, &c, 450.0, 450.0); + assert_eq!(t.first(&c), Some((a, None)), "the pick stays"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 320.0, 120.0)), vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]); + // After a blend is made, a dropped press on empty canvas keeps adding to it. + let mut s = Selection::default(); + s.set([g]); + let c = cx(&d, &s, &p); + let mut t = BlendTool { first: None, chained: true, ..Default::default() }; + dropped(&mut t, &c, 450.0, 450.0); + assert_eq!(t.first(&c), Some((g, None)), "the blend just made is still added to"); + } + #[test] fn mesh_tool_click_path_creates_mesh() { let (d, a) = doc_with_rect(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index b0f8c812b..be7222f4f 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -150,6 +150,9 @@ pub struct SelectionTool { /// The last hover had Alt held: what it measures depends on what lies under the pointer /// ([`Tool::wants_rehover`]). alt_hover: bool, + /// The text the pointer was loaded with when the button went down: a dropped press loads it + /// again. + loaded_at_press: Option<(NodeId, bool)>, } /// What Distance Guides measure the selection to. @@ -556,6 +559,21 @@ impl SelectionTool { }) } + /// On a press, notes the text the pointer is loaded with ([`Self::loaded_at_press`]); a + /// release clears it. + fn note_loaded_at_press(&mut self, kind: PointerKind) { + match kind { + PointerKind::Down => { + self.loaded_at_press = match self.state { + State::Loaded { from, before, .. } => Some((from, before)), + _ => None, + } + } + PointerKind::Up => self.loaded_at_press = None, + _ => {} + } + } + /// The pointer loaded with the text of area type or type on a path `from` ([`State::Loaded`]): /// a press starts placing it, and its release threads it (`text.thread.add`), into a frame put /// `before` `from` or after it. None when `from` can no longer thread: the press is an @@ -669,6 +687,18 @@ impl Tool for SelectionTool { if key == ToolKey::Escape { self.guide.cancel() } else { vec![] } } + /// A dropped press is canceled as by Escape, so a dropped press on a port loads no text. With + /// the pointer loaded with text, the press threads nothing and the text stays loaded for the + /// next click, a press on the port that loaded it included. + fn drop_press(&mut self, cx: &ToolContext) -> Vec { + let loaded = self.loaded_at_press.take(); + let out = if matches!(self.state, State::Loaded { press: Some(_), .. }) { vec![] } else { self.key(cx, ToolKey::Escape, Mods::default()) }; + if let Some((from, before)) = loaded { + self.state = State::Loaded { from, before, press: None }; + } + out + } + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { match self.state { State::Marquee { .. } => key == ToolKey::Letter('e'), @@ -703,6 +733,7 @@ impl Tool for SelectionTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + self.note_loaded_at_press(ev.kind); let hovering = ev.kind == PointerKind::Move && matches!(self.state, State::Idle); self.alt_hover = hovering && ev.mods.alt; self.distance = hovering.then(|| distance_target(cx, ev.pos, ev.mods)).flatten(); diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 8f6299f06..6638083ac 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -1871,6 +1871,41 @@ fn escape_drops_the_loaded_text() { assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), vec![Action::Exec("select.none".into(), json!({}))]); } +/// A dropped press with the pointer loaded with text threads nothing, and the text stays loaded: +/// the next click threads it. A dropped press on the port loads no text. +#[test] +fn a_dropped_press_keeps_the_text_loaded() { + let (d, id, _, port) = overflowing_area_type(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); + t.drop_press(&cx); + t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); + assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText, "the dropped press loads no text"); + t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); + t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); + // The engine sends the release after the drop. + t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0)); + assert!(t.drop_press(&cx).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)).is_empty(), "the release threads nothing"); + assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Marquee(_))), "the new frame's outline is removed"); + assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + // A press on the port that loaded the text puts it down; dropped, it leaves the text loaded. + t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); + assert!(t.drop_press(&cx).is_empty()); + t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); + assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText, "a dropped press on the port"); + t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), + vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "at": [30.0, 420.0]}))] + ); +} + /// [`doc_with_area_type`], its text fitting its frame; where its in and out ports are. fn fitting_area_type() -> (vectorcraft_doc::Document, NodeId, Point, Point) { let (d, id) = doc_with_area_type(); diff --git a/crates/tools/src/xform/freeform.rs b/crates/tools/src/xform/freeform.rs index d2c04aa2a..fe77ce607 100644 --- a/crates/tools/src/xform/freeform.rs +++ b/crates/tools/src/xform/freeform.rs @@ -451,6 +451,31 @@ mod tests { assert!(!t.claims_key(&cx, ToolKey::Escape)); } + /// A dropped click adds no point and leaves the line being drawn open: the next click still + /// joins it. A dropped drag of a point is canceled as by Escape. + #[test] + fn a_dropped_click_keeps_the_line_open() { + let (d, s) = doc(FreeformMode::Lines); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + let add = |x: f64, y: f64, line: bool| Action::Exec("paint.freeform.addPoint".into(), json!({"at": [x, y], "line": line, "stroke": false})); + assert_eq!(click(&mut t, &cx, 140.0, 150.0), vec![add(140.0, 150.0, false)]); + cx.freeform_point = Some(2); + assert!(t.claims_key(&cx, ToolKey::Escape)); + // The engine sends the release after the drop. + t.pointer(&cx, &ev(PointerKind::Down, 170.0, 160.0)); + assert!(t.drop_press(&cx).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 160.0)).is_empty(), "the dropped click adds no point"); + assert!(t.claims_key(&cx, ToolKey::Escape), "the line stays open"); + assert_eq!(click(&mut t, &cx, 160.0, 140.0), vec![add(160.0, 140.0, true)]); + t.pointer(&cx, &ev(PointerKind::Down, 120.0, 121.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Drag, 130.0, 140.0))[0], Action::Begin("Gradient".into())); + assert_eq!(t.drop_press(&cx), vec![Action::Cancel]); + assert!(!t.busy()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 130.0, 140.0)).is_empty()); + } + #[test] fn a_point_dragged_out_of_the_object_is_removed() { let (d, s) = doc(FreeformMode::Points); diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 263151434..7a9b59e2d 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -511,6 +511,17 @@ impl Tool for GradientTool { && cx.gradient_stop.is_some_and(|i| Annotator::of(cx).is_some_and(|a| i < a.gradient.stops.len())) } + /// A dropped drag is canceled as by Escape. A dropped click does nothing, and the line Lines + /// mode is drawing stays open. + fn drop_press(&mut self, cx: &ToolContext) -> Vec { + if self.busy() { + return self.key(cx, ToolKey::Escape, Mods::default()); + } + (self.gesture, self.free) = (None, None); + self.guides.clear(); + vec![] + } + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { if key == ToolKey::Escape && self.busy() { self.gesture = None;