From 2d6cf9020b9185c5d188fb10667038cd11b06e64 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 20:13:42 -0800 Subject: [PATCH 1/7] Canvas: a dropped press cancels the tool's drag instead of ending it A long touch, a canceled touch or a second finger drops the tool press in progress. The tool used to get a plain mouse-up, which it cannot tell from a release: the options the drag changed (the Polygon tool's sides set with the arrow keys) were stored, and a shape tool pressed and held still opened its size dialog. Session::drop_tool_press undoes the press's interaction, sends the tool Escape as during a drag, and then a release that returns it to rest without storing the tool's options. What the tool asks of the UI along the way is dropped. --- crates/engine/src/tests/toolsettings.rs | 30 ++++++++++++++++++++++++ crates/engine/src/tooling.rs | 20 +++++++++++++++- crates/ui-egui/src/canvas/drag.rs | 14 ++++------- crates/ui-egui/src/canvas/tests.rs | 31 +++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 5 files changed, 86 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index d1224e05d..34f85f822 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,35 @@ 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 cancelled touch, a second finger) keeps nothing it began: the +/// tool takes Escape and ends at rest, and the options the drag changed are not stored. A release +/// stores them. +#[test] +fn a_dropped_press_does_not_store_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(); + }; + let before = s.tool_options()["sides"].clone(); + 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"); + // A tool whose Escape puts back the options the drag changed stores those; nothing else is stored. + let stored = s.prefs.tool_settings.get("polygon").and_then(|o| o.get("sides")); + assert!(stored.is_none_or(|v| *v == before), "the dropped drag's sides are not stored: {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"], s.tool_options()["sides"], "and stores its sides"); +} + #[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..f8eb5c1f6 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -358,6 +358,24 @@ 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 cancelled touch, a second finger): nothing it + /// began is kept. Its interaction is undone, the tool takes Escape as it does during a drag, + /// and then a release that returns it to rest. The options the press changed are not stored, + /// and what the tool asks of the UI along the way (a size dialog for a click) is dropped. + pub fn drop_tool_press(&mut self, view: ViewInfo) -> Result<()> { + // Nothing to roll back without a document; the tool still takes Escape and the release. + let _ = self.cancel_interaction(); + let esc = self.tool_key(ToolKey::Escape, Mods::default(), view); + let up = PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), backquote: false, pressure: 1.0, pen: None }; + let up = self.pointer_event(&up, view, false); + esc.and(up).map(drop) + } + + /// [`Self::pointer`]; a release stores the tool's options only with `keep`. + 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, @@ -373,7 +391,7 @@ impl Session { let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev)); 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) diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 88293880a..ef4cc2ad2 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 takes Escape, as during a drag, and then a mouse-up that returns it to rest. + // 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..f0a594f2a 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 cancelled touch drops the tool's drag as Escape does: the options the drag changed (the +/// Polygon tool's sides) are not stored, and nothing is drawn. +#[test] +fn a_cancelled_touch_drops_the_drag_without_storing_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(); + let before = app.session.tool_options()["sides"].clone(); + 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()); + // A tool whose Escape puts back the options the drag changed stores those; nothing else is stored. + let stored = app.session.prefs.tool_settings.get("polygon").and_then(|o| o.get("sides")); + assert!(stored.is_none_or(|v| *v == before), "the dropped drag's sides are not stored: {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..1ce35dbe3 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 cancelled touch reaches the tool as Escape does during a drag, followed by a release: the options the drag changed (the Polygon tool's sides, set with ↑ and ↓) are not stored, and a press held still opens no size dialog (canvas tests `a_cancelled_touch_drops_the_drag_without_storing_its_options` and `long_touch_drops_a_tool_press_without_committing_art`). 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. From 641ac7e301289f7a250be26a51b0ae1c5a51639d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 00:10:25 -0800 Subject: [PATCH 2/7] Canvas: a dropped press puts back the tool's options and keeps the path being drawn A dropped press (a long touch, a canceled touch, a second finger) now goes to the tool through Tool::drop_press, which takes Escape as during a drag by default. The Pen, Smart Pen and Curvature tools override it so the path being drawn goes on: they forget the press, and a Curvature point the press dragged goes back where it was. The release that follows runs only the tool's Cancel and Notify actions, so a Selection marquee selects nothing and a Shift-drag Artboard marquee toggles nothing. The engine puts back the persistent options the tool had at the press (the Polygon tool's sides changed with the arrow keys), and stores none of them. --- crates/engine/src/lib.rs | 3 + crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/toolsettings.rs | 98 ++++++++++++++++++++--- crates/engine/src/tooling.rs | 52 +++++++++--- crates/tools/src/addons/smart_pen.rs | 9 +++ crates/tools/src/draw2/curvature.rs | 18 ++++- crates/tools/src/draw2/curvature/tests.rs | 28 +++++++ crates/tools/src/lib.rs | 6 ++ crates/tools/src/pen.rs | 9 +++ crates/ui-egui/src/canvas/drag.rs | 4 +- crates/ui-egui/src/canvas/tests.rs | 12 +-- docs/target-app-parity.md | 2 +- 12 files changed, 213 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 177697839..f0b7f4b60 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -203,6 +203,9 @@ 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)>, /// 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..da2c65988 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -92,6 +92,7 @@ impl Session { liquify_stroke: None, plane_widget_press: false, pointer_held: false, + press_options: None, ruler_guide: None, port_marks: Default::default(), font_search: None, diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index 34f85f822..3e522a552 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -91,11 +91,11 @@ 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 cancelled touch, a second finger) keeps nothing it began: the -/// tool takes Escape and ends at rest, and the options the drag changed are not stored. A release -/// stores them. +/// 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_store_what_its_drag_changed() { +fn a_dropped_press_does_not_keep_what_its_drag_changed() { let mut s = session(); select(&mut s, "polygon"); let v = ViewInfo::default(); @@ -104,20 +104,100 @@ fn a_dropped_press_does_not_store_what_its_drag_changed() { s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 150.0), v).unwrap(); s.tool_key(ToolKey::Up, Mods::default(), v).unwrap(); }; - let before = s.tool_options()["sides"].clone(); + 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"); - // A tool whose Escape puts back the options the drag changed stores those; nothing else is stored. - let stored = s.prefs.tool_settings.get("polygon").and_then(|o| o.get("sides")); - assert!(stored.is_none_or(|v| *v == before), "the dropped drag's sides are not stored: {stored:?}"); + 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"], s.tool_options()["sides"], "and stores its sides"); + 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] diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index f8eb5c1f6..0a959959e 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -361,20 +361,44 @@ impl Session { self.pointer_event(ev, view, true) } - /// Drop the press in progress (a long touch, a cancelled touch, a second finger): nothing it - /// began is kept. Its interaction is undone, the tool takes Escape as it does during a drag, - /// and then a release that returns it to rest. The options the press changed are not stored, - /// and what the tool asks of the UI along the way (a size dialog for a click) is dropped. + /// 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. pub fn drop_tool_press(&mut self, view: ViewInfo) -> Result<()> { - // Nothing to roll back without a document; the tool still takes Escape and the release. + // Nothing to roll back without a document; the tool still drops the press and gets the release. let _ = self.cancel_interaction(); - let esc = self.tool_key(ToolKey::Escape, Mods::default(), view); + let before = self.press_options.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 up = self.pointer_event(&up, view, false); - esc.and(up).map(drop) + let r = dropped.and(self.pointer_event(&up, view, false)); + 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) } - /// [`Self::pointer`]; a release stores the tool's options only with `keep`. + /// 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 { @@ -382,13 +406,21 @@ impl Session { 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 keep && ev.kind == PointerKind::Up { 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/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/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/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index ef4cc2ad2..3f3c156ed 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -230,8 +230,8 @@ 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 takes Escape, as during a drag, and then a mouse-up that returns it to rest. - // A move waiting over a panel is still live and would otherwise outlive the drag that + // 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![])); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index f0a594f2a..efb608e74 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -6728,10 +6728,10 @@ fn long_touch_drops_a_tool_press_without_committing_art() { assert!(app.session.active().unwrap().doc.art_bounds().is_none(), "releasing cannot commit the cancelled press"); } -/// A cancelled touch drops the tool's drag as Escape does: the options the drag changed (the -/// Polygon tool's sides) are not stored, and nothing is drawn. +/// 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_cancelled_touch_drops_the_drag_without_storing_its_options() { +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(); @@ -6745,7 +6745,7 @@ fn a_cancelled_touch_drops_the_drag_without_storing_its_options() { 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(); - let before = app.session.tool_options()["sides"].clone(); + 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)); @@ -6753,9 +6753,9 @@ fn a_cancelled_touch_drops_the_drag_without_storing_its_options() { 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()); - // A tool whose Escape puts back the options the drag changed stores those; nothing else is stored. + 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!(stored.is_none_or(|v| *v == before), "the dropped drag's sides are not stored: {stored:?}"); + 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ce35dbe3..8fd362eab 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -840,7 +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 cancelled touch reaches the tool as Escape does during a drag, followed by a release: the options the drag changed (the Polygon tool's sides, set with ↑ and ↓) are not stored, and a press held still opens no size dialog (canvas tests `a_cancelled_touch_drops_the_drag_without_storing_its_options` and `long_touch_drops_a_tool_press_without_committing_art`). + A tool press dropped by a second finger, a long touch or a canceled touch keeps nothing it began: 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. From 9932096122b9c56fb0aed9eb57859df55098164e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 00:37:29 -0800 Subject: [PATCH 3/7] Canvas: a dropped press keeps Type editing and the Crop Image box A dropped press on the Type tool no longer ends editing and switches tools: the type being edited stays in editing, with the caret and selection it had before the press, and the press does not count toward a double or triple click. A dropped handle or move press with Crop Image puts the box back as it was at the press and keeps earlier adjustments. --- crates/tools/src/cropimage.rs | 31 ++++++++++++++++++++++++++++++ crates/tools/src/text.rs | 3 +++ crates/tools/src/text/tests.rs | 35 ++++++++++++++++++++++++++++++++++ crates/tools/src/text/tool.rs | 17 +++++++++++++++++ 4 files changed, 86 insertions(+) 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/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![]; From a230ef854b3f9e3e77391b7848fceab2715ff512 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 00:41:01 -0800 Subject: [PATCH 4/7] Docs: a dropped tool press cancels the drag as Escape does The progress line said a dropped press keeps nothing it began. A Curvature press that adds a point and a press on another text object keep what they did on press, as with Escape during the drag, so the line now says the drag is canceled as Escape cancels it. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8fd362eab..6337b50d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -840,7 +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 keeps nothing it began: 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`). + 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. From 5d0cffbba126c6eb5467a0f990eb0f97ececa39f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 01:19:37 -0800 Subject: [PATCH 5/7] 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; From e01823c2eb94490617ed33464b4f798cdd7ad59d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 01:59:08 -0800 Subject: [PATCH 6/7] Gradient: a dropped or canceled point drag keeps the point selected A drag that takes a selected freeform gradient point out of the object previews its removal, which deselects it. A dropped press now selects again the point that was selected once the tool took the press, and Escape reselects a point dragged out and back inside the object. --- crates/engine/src/lib.rs | 3 +++ crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/freeform.rs | 26 ++++++++++++++++++++++++++ crates/engine/src/tooling.rs | 13 ++++++++++++- crates/tools/src/xform/freeform.rs | 10 ++++++++-- 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f0b7f4b60..2a9fb1628 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -206,6 +206,9 @@ pub struct Session { /// 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 da2c65988..ba15d3175 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -93,6 +93,7 @@ impl Session { 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/tooling.rs b/crates/engine/src/tooling.rs index 0a959959e..4eb191229 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -366,16 +366,22 @@ impl Session { /// 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. + /// 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() { @@ -428,6 +434,11 @@ impl Session { } 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/xform/freeform.rs b/crates/tools/src/xform/freeform.rs index fe77ce607..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], } } @@ -499,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] From a5870433738c925459a5df9ac1e901221be8198e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 02:03:47 -0800 Subject: [PATCH 7/7] Blend: a dropped press keeps the object it picked and selected The press picks the first object and selects it on the button-down. A dropped press now keeps that pick with the selection, and still puts back the picks of earlier clicks. --- crates/tools/src/meshblend.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index b32986150..a9f3928ec 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -49,7 +49,7 @@ pub struct BlendTool { /// 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. + /// the press picked an object or made a blend. before_press: Option<(Option, bool)>, } @@ -109,7 +109,7 @@ impl Tool for BlendTool { vec![] } (None, Some((top, start))) => { - self.first = 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() => { @@ -162,8 +162,8 @@ 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. + /// 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); @@ -504,8 +504,9 @@ 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. + /// 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(); @@ -530,10 +531,10 @@ mod tests { 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)); + 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);