diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 177697839..2a9fb1628 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -203,6 +203,12 @@ pub struct Session { /// The pointer button is held: the last pointer event was a press or a drag, not a release or /// a move ([`Session::pointer_held`]). pub(crate) pointer_held: bool, + /// The id and persistent options of the tool when the press under way began, which a dropped + /// press puts back ([`Session::drop_tool_press`]). + pub(crate) press_options: Option<(String, serde_json::Map)>, + /// The freeform gradient point selected once the tool took the press under way, which a + /// dropped press selects again ([`Session::drop_tool_press`]). + pub(crate) press_point: Option>, /// A guide being dragged out of a ruler ([`Session::ruler_guide`]). pub(crate) ruler_guide: Option, /// Which selected type overflows its frame, for the ports every tool but the Type tools shows. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 51b5b5776..ba15d3175 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -92,6 +92,8 @@ impl Session { liquify_stroke: None, plane_widget_press: false, pointer_held: false, + press_options: None, + press_point: None, ruler_guide: None, port_marks: Default::default(), font_search: None, diff --git a/crates/engine/src/tests/freeform.rs b/crates/engine/src/tests/freeform.rs index df9ed3e4b..4e65c8689 100644 --- a/crates/engine/src/tests/freeform.rs +++ b/crates/engine/src/tests/freeform.rs @@ -331,3 +331,29 @@ fn type_objects_keep_points_in_text_space() { assert!(close(f.points[i].at, Point::new(90.0, 150.0)), "back in document coordinates: {:?}", f.points[i].at); assert_eq!(f.points[i].color, Color::rgb(1.0, 0.0, 1.0)); } + +/// A dropped press (a long touch, a canceled touch) of the Gradient tool that dragged a selected +/// point out of the object, and out and back in, keeps the point and its selection. +#[test] +fn a_dropped_drag_of_a_point_keeps_it_selected() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let (mut s, id) = freeform_rect(); + let v = ViewInfo::default(); + s.select_tool("gradient", v).unwrap(); + let count = points(&s, id).points.len(); + assert!(count > 1, "a point can be dragged out"); + let at = s.proxy_freeform(false).unwrap().0.points[0].at; + for back in [false, true] { + run(&mut s, "paint.freeform.selectPoint", json!({"index": 0})); + s.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 400.0, 400.0), v).unwrap(); + assert_eq!(s.selected_freeform_point(), None, "the removal the drag previews deselects the point"); + if back { + s.pointer(&PointerEvent::new(PointerKind::Drag, at.x + 5.0, at.y + 5.0), v).unwrap(); + } + s.drop_tool_press(v).unwrap(); + assert!(!s.tool_busy(), "the tool is at rest"); + assert_eq!(points(&s, id).points.len(), count, "back inside: {back}"); + assert_eq!(s.selected_freeform_point(), Some(0), "back inside: {back}"); + } +} diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index d1224e05d..3e522a552 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -1,6 +1,7 @@ //! Tool options that persist across tool switches, documents and launches (`Prefs::tool_settings`). use serde_json::json; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use crate::*; @@ -90,6 +91,115 @@ fn a_gesture_keeps_what_it_changed_and_a_document_switch_keeps_options() { assert_eq!(s.tool_options()["segments"], json!(14)); } +/// A dropped press (a long touch, a canceled touch, a second finger) keeps nothing it began: the +/// tool ends at rest with the options it had before the press, and the options the drag changed +/// are not stored. A release stores them. +#[test] +fn a_dropped_press_does_not_keep_what_its_drag_changed() { + let mut s = session(); + select(&mut s, "polygon"); + let v = ViewInfo::default(); + let drag_with_one_more_side = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 150.0), v).unwrap(); + s.tool_key(ToolKey::Up, Mods::default(), v).unwrap(); + }; + assert_eq!(s.tool_options()["sides"], json!(6)); + drag_with_one_more_side(&mut s); + assert_eq!(s.tool_options()["sides"], json!(7)); + s.drop_tool_press(v).unwrap(); + assert!(!s.tool_busy(), "the tool is at rest"); + let st = s.active().unwrap(); + assert!(st.interaction.is_none() && st.doc.art_bounds().is_none(), "the polygon is not drawn"); + assert_eq!(s.tool_options()["sides"], json!(6), "the tool has its sides from before the press"); + assert_eq!(s.prefs.tool_settings.get("polygon").and_then(|o| o.get("sides")), None, "the dropped drag's sides are not stored"); + drag_with_one_more_side(&mut s); + s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0), v).unwrap(); + assert!(s.active().unwrap().doc.art_bounds().is_some(), "a release draws the polygon"); + assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(7), "and stores its sides"); +} + +/// A dropped Pen press drops only the anchor it was placing: the path being drawn goes on with +/// the next click. +#[test] +fn a_dropped_pen_press_keeps_the_path_being_drawn() { + let mut s = session(); + select(&mut s, "pen"); + let v = ViewInfo::default(); + let click = |s: &mut Session, x: f64, y: f64| { + s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap(); + }; + click(&mut s, 10.0, 10.0); + click(&mut s, 100.0, 10.0); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 140.0, 120.0), v).unwrap(); + s.drop_tool_press(v).unwrap(); + click(&mut s, 10.0, 100.0); + let d = &s.active().unwrap().doc; + let paths = d.layers[0].children().unwrap(); + assert_eq!(paths.len(), 1, "no new path starts"); + assert_eq!(paths[0].path_data().unwrap().anchor_count(), 3, "the dropped anchor is not placed"); +} + +/// A dropped Pen press on the first anchor does not close the path or end it. +#[test] +fn a_dropped_pen_close_keeps_the_path_open_and_being_drawn() { + let mut s = session(); + select(&mut s, "pen"); + let v = ViewInfo::default(); + let click = |s: &mut Session, x: f64, y: f64| { + s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap(); + }; + click(&mut s, 10.0, 10.0); + click(&mut s, 100.0, 10.0); + click(&mut s, 100.0, 100.0); + s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 40.0, 30.0), v).unwrap(); + s.drop_tool_press(v).unwrap(); + click(&mut s, 10.0, 100.0); + let d = &s.active().unwrap().doc; + let paths = d.layers[0].children().unwrap(); + assert_eq!(paths.len(), 1, "no new path starts"); + let pd = paths[0].path_data().unwrap(); + assert_eq!(pd.anchor_count(), 4, "the next click goes on the path"); + assert!(!pd.subpaths[0].closed, "the path is not closed"); +} + +/// A dropped marquee selects nothing: the selection stays as it was before the press. +#[test] +fn a_dropped_marquee_leaves_the_selection_alone() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 600, "y": 400, "width": 100, "height": 100})).unwrap(); + let before = s.doc().unwrap().selection.objects.clone(); + assert_eq!(before.len(), 1); + select(&mut s, "selection"); + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 350.0, 350.0), v).unwrap(); + s.drop_tool_press(v).unwrap(); + assert!(!s.tool_busy(), "the tool is at rest"); + assert_eq!(s.doc().unwrap().selection.objects, before); +} + +/// A dropped Shift-drag marquee of the Artboard tool toggles no artboard: the tool takes Escape, +/// as during a drag, before its release. +#[test] +fn a_dropped_artboard_marquee_toggles_nothing() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap(); + select(&mut s, "artboard"); + let before = s.tool_options()["selected"].clone(); + let v = ViewInfo::default(); + let shift = Mods { shift: true, ..Mods::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 1200.0, 500.0).with_mods(shift), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 1250.0, 550.0).with_mods(shift), v).unwrap(); + s.drop_tool_press(v).unwrap(); + assert!(!s.tool_busy(), "the tool is at rest"); + assert_eq!(s.tool_options()["selected"], before, "no artboard is toggled"); +} + #[test] fn options_are_saved_with_the_preferences() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index d5c03a651..4eb191229 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -358,26 +358,87 @@ impl Session { /// Feed a pointer event to the active tool. Returns requests for the UI. pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result> { + self.pointer_event(ev, view, true) + } + + /// Drop the press in progress (a long touch, a canceled touch, a second finger): nothing it + /// began is kept. Its interaction is undone, the tool drops the press ([`Tool::drop_press`]: + /// Escape, as during a drag, for most tools), and a release returns the tool to rest. Of what + /// the tool asks for along the way, only a cancel and a notify run, so a marquee selects + /// nothing and a click opens no size dialog. The tool gets back the persistent options it had + /// when the press began, and none of the press's changes to them are stored. The freeform + /// gradient point selected once the tool took the press is selected again. + pub fn drop_tool_press(&mut self, view: ViewInfo) -> Result<()> { + // Nothing to roll back without a document; the tool still drops the press and gets the release. + let _ = self.cancel_interaction(); + let before = self.press_options.take(); + let point = self.press_point.take(); + let mut acts = self.with_tool_cx(view, |t, cx| t.drop_press(cx)); + acts.retain(|a| matches!(a, Action::Cancel | Action::Notify(_))); + let dropped = self.take_tool_panic().and_then(|()| self.apply_actions(acts)); + let up = PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), backquote: false, pressure: 1.0, pen: None }; + let r = dropped.and(self.pointer_event(&up, view, false)); + // A drag out of the object previews the point's removal, which deselects it. + if let Some(p) = point { + self.freeform_point = p; + } + if let Some((id, opts)) = before + && id == self.tool.id() + { + let now = self.tool.options(); + for (k, v) in &opts { + if now.get(k) != Some(v) { + self.tool.set_option(k, v); + } + } + } + r.map(drop) + } + + /// The options of `t` that [`Self::store_tool_settings`] stores. + fn persistent_options(t: &dyn Tool) -> Map { + let opts = t.options(); + let radius = (t.id() == ROUNDED_RECTANGLE).then_some("cornerRadius"); + let keys = settings::stores(t.id()).flat_map(|(_, keys)| keys.iter().copied()).chain(radius); + keys.filter_map(|k| opts.get(k).map(|v| (k.to_string(), v.clone()))).collect() + } + + /// [`Self::pointer`]. Without `keep` (a dropped press's release), the tool's options are not + /// stored and only the tool's `Cancel` and `Notify` actions run. + fn pointer_event(&mut self, ev: &PointerEvent, view: ViewInfo, keep: bool) -> Result> { self.last_view = view; match ev.kind { PointerKind::Down | PointerKind::Drag => self.pointer_held = true, PointerKind::Up | PointerKind::Move => self.pointer_held = false, PointerKind::DoubleClick => {} } + match ev.kind { + PointerKind::Down => self.press_options = Some((self.tool.id().to_string(), Self::persistent_options(self.tool.as_ref()))), + PointerKind::Up => self.press_options = None, + _ => {} + } // The Plane Switching Widget takes its clicks whatever the tool. if let Some(r) = self.plane_widget_pointer(ev, view) { return r; } self.leave_draw_inside(ev, view)?; let ev = &self.lend_selection_tool(ev); - let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev)); + let mut acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev)); + if !keep { + acts.retain(|a| matches!(a, Action::Cancel | Action::Notify(_))); + } let r = self.take_tool_panic().and_then(|()| { // A gesture may change options (Alt-drag sizes a Liquify brush): keep them. - if ev.kind == PointerKind::Up { + if keep && ev.kind == PointerKind::Up { self.keep_tool_settings(); } self.apply_actions(acts) }); + match ev.kind { + PointerKind::Down => self.press_point = Some(self.freeform_point), + PointerKind::Up => self.press_point = None, + _ => {} + } if ev.kind == PointerKind::Up { self.give_back_tool(view)?; } diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index f2b52219e..90c73620a 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -534,6 +534,15 @@ impl Tool for SmartPenTool { } } + /// A dropped press does not end the path being drawn, as Escape would: the press and its + /// marquee are forgotten, so the release does nothing. + fn drop_press(&mut self, _cx: &ToolContext) -> Vec { + self.press = None; + self.marquee = None; + self.snap.clear(); + vec![] + } + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { key == ToolKey::Tab && (self.press.is_some() || self.active(cx).is_some()) } diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs index 52f347e91..ff716bf17 100644 --- a/crates/tools/src/cropimage.rs +++ b/crates/tools/src/cropimage.rs @@ -184,6 +184,16 @@ impl Tool for CropImageTool { } } + /// A dropped press puts the box back as it was at the press; earlier changes to it stay. + fn drop_press(&mut self, cx: &ToolContext) -> Vec { + if let Some(Drag::Handle(_, from) | Drag::Move(_, from)) = self.drag.take() + && let Some((id, _, _)) = self.current(cx) + { + self.rect = Some((Some(id), from)); + } + vec![] + } + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { matches!(key, ToolKey::Enter | ToolKey::Escape) && self.current(cx).is_some() } @@ -368,6 +378,27 @@ mod tests { assert!(t.overlays(&cx).is_empty() && !t.claims_key(&cx, ToolKey::Enter)); } + #[test] + fn a_dropped_press_puts_the_box_back_as_it_was_at_the_press() { + let (d, s) = doc_with_image((50.0, 60.0)); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = CropImageTool::default(); + drag(&mut t, &cx, (50.0, 60.0), (80.0, 70.0)); + assert_eq!(t.options()["rect"], json!([80.0, 70.0, 170.0, 90.0])); + // A handle pressed and dragged, then the press dropped: the box is the adjusted one. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 250.0, 160.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 130.0)); + assert!(t.drop_press(&cx).is_empty()); + assert!(!t.busy()); + assert_eq!(t.options()["rect"], json!([80.0, 70.0, 170.0, 90.0])); + // So is a moved box. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 110.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0)); + assert!(t.drop_press(&cx).is_empty()); + assert_eq!(t.options()["rect"], json!([80.0, 70.0, 170.0, 90.0])); + } + #[test] fn crop_box_option_requires_exactly_four_finite_numeric_values() { let mut tool = CropImageTool::default(); 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/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index 8301c108c..70ad9a7e4 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -35,6 +35,9 @@ pub struct CurvatureTool { id: Option, /// The point being dragged, the pointer at the press, and whether the drag began. drag: Option<(Grab, Point, bool)>, + /// Where the point of the path being drawn that the press grabbed was at the press, and the + /// current point before the press. + grabbed: Option<(Point, Option)>, /// Last touched point of the path being drawn (Backspace removes it). current: Option, hover: Option, @@ -564,7 +567,7 @@ impl Tool for CurvatureTool { PointerKind::Down => { if let Some(id) = active { if let Some(i) = self.hit_point(cx, p) { - self.current = Some(i); + let was_current = self.current.replace(i); if ev.mods.alt { self.pts[i].1 = !self.pts[i].1; return vec![Action::Exec("path.curvature".into(), self.params())]; @@ -576,6 +579,7 @@ impl Tool for CurvatureTool { // The point dragged and the curve through it move: the rest pull. hold_targets(&mut self.snap, cx, id, 0, &[i]); self.drag = Some((Grab::Drawn(i), p, false)); + self.grabbed = self.pts.get(i).map(|pt| (pt.0, was_current)); return vec![]; } if !self.closed { @@ -650,6 +654,18 @@ impl Tool for CurvatureTool { && self.drag.is_none() && if self.active(cx).is_some() { self.pts.len() > 1 } else { current_point(cx).is_some() } } + /// A dropped press does not end the path being drawn, as Escape would: a point it grabbed + /// goes back to where it was at the press, and the current point to the one before the press. + fn drop_press(&mut self, _cx: &ToolContext) -> Vec { + if let (Some((Grab::Drawn(i), _, _)), Some((at, current))) = (self.drag.take(), self.grabbed.take()) + && let Some(pt) = self.pts.get_mut(i) + { + pt.0 = at; + self.current = current; + } + self.snap.clear(); + vec![] + } fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { match key { ToolKey::Escape | ToolKey::Enter => { diff --git a/crates/tools/src/draw2/curvature/tests.rs b/crates/tools/src/draw2/curvature/tests.rs index 412292ef9..05593a3dc 100644 --- a/crates/tools/src/draw2/curvature/tests.rs +++ b/crates/tools/src/draw2/curvature/tests.rs @@ -370,6 +370,34 @@ fn a_press_on_a_point_selects_it_and_a_drag_moves_it() { assert_eq!(press(&mut t, &cx, 100.0, 0.0, false).len(), 1); } +/// A dropped drag of a point of the path being drawn puts the point back where it was at the +/// press, and the path goes on: the next click adds a point to it. +#[test] +fn a_dropped_drag_puts_the_point_back_and_keeps_drawing() { + let (d, id) = doc_with(SubPath::polyline(&[pt(100.0, 100.0), pt(200.0, 100.0), pt(300.0, 100.0)], false)); + let (s, p) = (selected(id, None), paint()); + let cx = quiet(&d, &s, &p); + let mut t = CurvatureTool { + pts: vec![(pt(100.0, 100.0), false), (pt(200.0, 100.0), false), (pt(300.0, 100.0), false)], + id: Some(id), + current: Some(2), + ..Default::default() + }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 100.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 220.0, 160.0)); + assert_eq!(a[0], Action::Begin("Curvature".into())); + assert_eq!(t.pts[1].0, pt(220.0, 160.0)); + assert!(t.drop_press(&cx).is_empty(), "the engine has undone the preview"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.0, 0.0)), vec![]); + assert_eq!(t.pts[1].0, pt(200.0, 100.0), "the point is back"); + assert_eq!(t.current, Some(2), "the current point is the one before the press"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 200.0)); + assert!( + matches!(&a[0], Action::Exec(_, v) if v["id"] == id.0 && v["points"][1]["y"] == 100.0 && v["points"].as_array().unwrap().len() == 4), + "{a:?}" + ); +} + /// #2120: the dragged end of a slanted straight path keeps to its extension (Smart Guides). #[test] fn a_dragged_end_of_a_straight_path_keeps_to_its_extension() { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index dd5977f5a..7aa338ab4 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -124,6 +124,12 @@ pub trait Tool: Send { fn key(&mut self, _cx: &ToolContext, _key: ToolKey, _mods: Mods) -> Vec { vec![] } + /// The press in progress is dropped (a long touch, a canceled touch, a second finger): the + /// engine has undone its interaction and sends a release after this. By default the tool takes + /// Escape, as during a drag. + fn drop_press(&mut self, cx: &ToolContext) -> Vec { + self.key(cx, ToolKey::Escape, Mods::default()) + } fn overlays(&self, _cx: &ToolContext) -> Vec { vec![] } diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 0790407d2..a9f3928ec 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 picked an object or 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, self.before_press) = (Some((top, start)), None); + 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. An object the press + /// picked stays picked and selected, and 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,51 @@ mod tests { assert!(esc(&mut BlendTool::default()).is_empty(), "nothing to drop"); } + /// A dropped press (a long touch, a canceled touch) keeps the pick an earlier click made and + /// keeps adding to the blend just made: the next click blends with them. A dropped press + /// that picked an object keeps that pick, as the object stays selected. + #[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(); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)), vec![Action::Exec("select.set".into(), json!({"ids": [a.0]}))]); + assert!(t.drop_press(&c).is_empty()); + t.pointer(&c, &ev(PointerKind::Up, 150.0, 150.0)); + assert_eq!(t.first(&c), Some((a, None)), "a dropped first press keeps the object it picked and selected"); + // 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/pen.rs b/crates/tools/src/pen.rs index a2e9d3881..e5906db59 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -445,6 +445,15 @@ impl Tool for PenTool { _ => vec![], } } + /// A dropped press drops only the anchor it was placing, a handle it was dragging or the + /// close it was making: the path being drawn goes on with the next click. + fn drop_press(&mut self, _cx: &ToolContext) -> Vec { + self.drag = None; + self.handle = None; + self.convert = None; + self.snap.clear(); + vec![] + } /// Deactivate the pen tool, committing in-flight operations and restoring compound path selection. fn deactivate(&mut self, cx: &ToolContext) -> Vec { let path = self.path; 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/text.rs b/crates/tools/src/text.rs index 3e854231d..b1394c81d 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -189,6 +189,9 @@ pub struct TypeTool { snap: DrawSnap, /// The press landed in the edited text: dragging selects. selecting: bool, + /// The caret and anchor from before a press in type, kept until its release; a dropped press + /// puts them back. + before_press: Option<(usize, usize)>, /// Click counting for double/triple click (position of the last click, count). clicks: (Option, u8), /// Styled copy of the last copied range (pasting the same plain text keeps its styles). diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index ab3229809..3c1741172 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -603,6 +603,41 @@ fn a_drag_across_type_not_being_edited_selects_its_text() { assert_eq!((tool.editing, tool.sel()), (Some(id), (7, 7))); } +#[test] +fn a_dropped_press_keeps_the_type_in_editing() { + let (d, id, mut tool) = editing("Hello world"); + let (sel, p) = (Selection::default(), paint()); + let c = cx(&d, &sel, &p); + let t = TypeTool::text(&c, id).unwrap().clone(); + let lay = vectorcraft_text::layout(FontDb::global(), &t); + let pt = |i: usize| { + let (a, b) = vectorcraft_text::caret_position(&lay, i); + t.xf * a.midpoint(b) + }; + let (at, to) = (pt(3), pt(7)); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x + 0.1, at.y)); + tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x + 0.1, to.y)); + assert!(tool.busy()); + assert_eq!(tool.sel(), (3, 7)); + // The press is dropped: the caret and selection are as they were before it. + let out = tool.drop_press(&c); + assert!(!out.iter().any(|a| matches!(a, Action::SwitchTool(_)))); + assert!(!tool.busy()); + assert_eq!((tool.editing, tool.sel()), (Some(id), (0, 0))); + // The dropped press does not count toward a double or triple click. + assert_eq!(tool.clicks, (None, 0)); + tool.pointer(&c, &PointerEvent::new(PointerKind::Up, at.x + 0.1, at.y)); + assert_eq!((tool.editing, tool.sel()), (Some(id), (0, 0))); + // A Shift-press that extends the selection, dropped, leaves the caret where a click put it. + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x + 0.1, at.y)); + tool.pointer(&c, &PointerEvent::new(PointerKind::Up, at.x + 0.1, at.y)); + assert_eq!(tool.sel(), (3, 3)); + tool.pointer(&c, &PointerEvent { mods: SHIFT, ..PointerEvent::new(PointerKind::Down, to.x + 0.1, to.y) }); + assert_eq!(tool.sel(), (3, 7)); + tool.drop_press(&c); + assert_eq!((tool.editing, tool.sel()), (Some(id), (3, 3))); +} + #[test] fn select_all_and_styled_paste() { let (d, _, mut tool) = editing("abc"); diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index c983205da..9014f540e 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -29,6 +29,20 @@ impl Tool for TypeTool { fn claims_key(&self, _: &ToolContext, key: ToolKey) -> bool { key == ToolKey::Escape && self.editing.is_some() } + /// A dropped press drops only the selection drag or the type it was placing: the type being + /// edited stays in editing, with the caret and selection it had before the press. + fn drop_press(&mut self, _cx: &ToolContext) -> Vec { + if let Some((caret, anchor)) = self.before_press.take() { + (self.caret, self.anchor) = (caret, anchor); + } + self.selecting = false; + self.clicks = (None, 0); + self.press = None; + self.held = None; + self.drag = None; + self.snap.clear(); + vec![] + } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { @@ -36,6 +50,7 @@ impl Tool for TypeTool { let same = self.clicks.0.is_some_and(|q| (q - ev.pos).hypot() <= cx.tol(4.0)); let n = if same { self.clicks.1 + 1 } else { 1 }; self.clicks = (Some(ev.pos), n); + self.before_press = Some((self.caret, self.anchor)); let out = self.move_to(byte, ev.mods.shift); self.goal_x = None; if n >= 3 { @@ -51,6 +66,7 @@ impl Tool for TypeTool { // as it does in the type being edited. if let Some(out) = self.edit_at(cx, ev.pos) { self.selecting = true; + self.before_press = Some((self.caret, self.anchor)); return out; } self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None))); @@ -71,6 +87,7 @@ impl Tool for TypeTool { vec![] } PointerKind::Up => { + self.before_press = None; if self.selecting { self.selecting = false; return vec![]; diff --git a/crates/tools/src/xform/freeform.rs b/crates/tools/src/xform/freeform.rs index d2c04aa2a..89c6faa93 100644 --- a/crates/tools/src/xform/freeform.rs +++ b/crates/tools/src/xform/freeform.rs @@ -274,10 +274,11 @@ pub(super) fn release(cx: &ToolContext, a: Option<&Annotator>, g: Gesture, lines } } -/// Escape during a drag: the point a drag-out deselected is selected again. +/// Escape during a drag: the point a drag-out deselected is selected again, also when the drag +/// came back inside the object. pub(super) fn cancel(g: &Gesture) -> Vec { match g.grab { - Grab::Point(i) if g.out => vec![Action::Cancel, select(Some(i))], + Grab::Point(i) if g.left => vec![Action::Cancel, select(Some(i))], _ => vec![Action::Cancel], } } @@ -451,6 +452,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); @@ -474,6 +500,11 @@ mod tests { t.pointer(&cx, &ev(PointerKind::Down, 120.0, 120.0)); t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0)); assert_eq!(t.key(&cx, ToolKey::Escape, Default::default()), vec![Action::Cancel, select(Some(0))]); + // Also after the drag came back inside. + t.pointer(&cx, &ev(PointerKind::Down, 120.0, 120.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 110.0, 110.0)); + assert_eq!(t.key(&cx, ToolKey::Escape, Default::default()), vec![Action::Cancel, select(Some(0))]); } #[test] 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; diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 88293880a..3f3c156ed 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -230,15 +230,11 @@ pub fn mods(m: egui::Modifiers, space: bool) -> Mods { pub(super) fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewInfo) { match ui.data(|d| d.get_temp::(drag_id())) { Some(Drag::Tool | Drag::Art) => { - // The tool's mouse-up returns it to rest. A move waiting over a panel is still live - // and would otherwise outlive the drag that owns it (#2826). - // Nothing to roll back without a document; the tool still gets its mouse-up. - let _ = app.session.cancel_interaction(); - dispatch( - app, - &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), backquote: false, pressure: 1.0, pen: None }, - view, - ); + // The tool drops the press, and a mouse-up returns it to rest with the options it had + // when the press began. A move waiting over a panel is still live and would otherwise outlive the drag that + // owns it (#2826). + let r = app.session.drop_tool_press(view); + apply_requests(app, r.map(|()| vec![])); egui::DragAndDrop::clear_payload(ui.ctx()); } Some(_) => {} diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 3e184bef4..efb608e74 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -6723,10 +6723,41 @@ fn long_touch_drops_a_tool_press_without_committing_art() { tick(&mut app, 1.5, vec![]); assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Gesture)), "long touch drops the tool drag"); assert!(app.session.active().unwrap().interaction.is_none()); + assert!(app.ui.dialog.is_none(), "the dropped press is not a click that opens the size dialog"); tick(&mut app, 1.6, pen(egui::TouchPhase::End, pos, 0.5)); assert!(app.session.active().unwrap().doc.art_bounds().is_none(), "releasing cannot commit the cancelled press"); } +/// A canceled touch drops the tool's drag: the options the drag changed (the Polygon tool's +/// sides) are put back and not stored, and nothing is drawn. +#[test] +fn a_canceled_touch_drops_the_drag_without_keeping_its_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.touch_gestures = true; + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + app.select_tool("polygon"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let start = xf.to_screen(Point::new(200.0, 200.0)); + frame(&mut app, &ctx, pen(egui::TouchPhase::Start, start, 0.5)); + frame(&mut app, &ctx, pen(egui::TouchPhase::Move, start + vec2(60.0, 40.0), 0.5)); + assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Tool))); + assert!(app.session.active().unwrap().interaction.is_some(), "the drag previews the polygon"); + let view = app.view_info(); + assert_eq!(app.session.tool_options()["sides"], json!(6)); + app.session.tool_key(vectorcraft_tools::ToolKey::Up, Mods::default(), view).unwrap(); + assert_eq!(app.session.tool_options()["sides"], json!(7)); + frame(&mut app, &ctx, pen(egui::TouchPhase::Cancel, start + vec2(60.0, 40.0), 0.5)); + assert!(!app.session.tool_busy(), "the tool is at rest"); + let st = app.session.active().unwrap(); + assert!(st.interaction.is_none() && st.doc.art_bounds().is_none(), "nothing is drawn"); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.tool_options()["sides"], json!(6), "the tool has its sides from before the press"); + let stored = app.session.prefs.tool_settings.get("polygon").and_then(|o| o.get("sides")); + assert_eq!(stored, None, "the dropped drag's sides are not stored"); +} + /// #2826: a second finger drops a move waiting over a panel, so its interaction doesn't /// outlive the drag that began it. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b9c9ad6df..6337b50d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -840,6 +840,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the view rotated, a second Escape less than a second after the first resets the rotation, as Reset Rotate View does. The first Escape may end something else, such as isolation mode or a path being drawn. The second resets the view only when it has nothing else to end, and an Escape that closes a menu does not count. After a click, or a press of another key that is not a modifier (Shift, Control, Alt/Option or Command), the next Escape counts as the first. With the Rotate View tool chosen, one Escape resets the view. A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5° (Preferences › General › Trackpad Gesture to Rotate View, on by default). On a touch screen a second finger cancels the tool press the first finger began, in the frame it lands or later: the Pencil's stroke or a Selection move is rolled back with no undo entry, and a finger that lands while another is down presses nothing, so a pinch or two-finger pan only zooms and pans (#4198; canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`). + A tool press dropped by a second finger, a long touch or a canceled touch cancels the tool's drag as Escape does: the tool gets back the options it had when the press began (the Polygon tool's sides, set with ↑ and ↓) and they are not stored, a press held still opens no size dialog, a Selection or Artboard tool marquee selects nothing, and the Pen goes on drawing its path without the dropped anchor (canvas tests `a_canceled_touch_drops_the_drag_without_keeping_its_options` and `long_touch_drops_a_tool_press_without_committing_art`; engine tests in `tests::toolsettings`). The status bar navigator's field shows the active artboard's number and name, and its drop-down lists every artboard by number and name, as the Artboards panel does. Choosing an artboard in the list makes it the active artboard and fits it in the window (`view.goToArtboard`). The list opens on the active artboard's row and lays out only the rows in view and at most one more. - **Geometry and effects:** - **Open-path anchor Delete:** Delete/Backspace removes selected anchors and their adjoining segments, leaving drawable runs as separate open subpaths of the original object. Surviving curves and appearance stay intact; one Undo restores the document and selection, and a second Delete removes the selected remainder. Explicit Remove Anchor Points still reconnects/refits. Four core regressions, one full-frame Delete/Backspace regression and three existing removal/segment controls pass, covering these distinctions, multiple deletions, transformed geometry and mixed whole-object selection. Behaviour references: the legacy public [Illustrator reference guide, Add or delete an anchor point](https://helpx.adobe.com/content/dam/help/en/pdf/illustrator_reference.pdf) distinguishes keyboard deletion from dedicated removal; the current [Add or remove anchor points](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/add-or-remove-anchor-points.html) describes the dedicated tools. Other G11 gaps and scores/hours remain unchanged.