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.
This commit is contained in:
dotCooCoo
2026-11-03 16:03:39 +07:00
committed by bflatastic
parent a230ef854b
commit 5d0cffbba1
7 changed files with 271 additions and 21 deletions
+43
View File
@@ -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<Action> {
if self.dragging {
return self.key(cx, ToolKey::Escape, Mods::default());
}
self.press = None;
vec![]
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
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");
}
}
+42
View File
@@ -105,6 +105,8 @@ pub struct WidthTool {
drag: Option<Drag>,
/// Selected width points: the stroke's owner and their positions t.
selected: Option<(NodeId, Vec<f64>)>,
/// The selected width points when the button went down: a dropped press puts them back.
before_press: Option<(NodeId, Vec<f64>)>,
}
/// 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<Action> {
self.drag = None;
self.selected = self.before_press.take();
vec![]
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
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();
+84 -21
View File
@@ -48,6 +48,9 @@ pub struct BlendTool {
first: Option<Pick>,
/// 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<Pick>, 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<Action> {
if let Some((first, chained)) = self.before_press.take() {
(self.first, self.chained) = (first, chained);
}
vec![]
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
(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();
+31
View File
@@ -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<Action> {
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<Action> {
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();
+35
View File
@@ -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();
+25
View File
@@ -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);
+11
View File
@@ -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<Action> {
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<Action> {
if key == ToolKey::Escape && self.busy() {
self.gesture = None;