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.
This commit is contained in:
dotCooCoo
2026-11-03 16:03:38 +07:00
committed by bflatastic
parent 49477f8be5
commit 2d6cf9020b
5 changed files with 86 additions and 10 deletions
+30
View File
@@ -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();
+19 -1
View File
@@ -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<Vec<UiRequest>> {
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<Vec<UiRequest>> {
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)
+5 -9
View File
@@ -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>(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(_) => {}
+31
View File
@@ -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>(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>(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]
+1
View File
@@ -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.