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.