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.
This commit is contained in:
dotCooCoo
2026-11-03 16:03:38 +07:00
committed by bflatastic
parent 2d6cf9020b
commit 641ac7e301
12 changed files with 213 additions and 29 deletions
+3
View File
@@ -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<String, serde_json::Value>)>,
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
/// Which selected type overflows its frame, for the ports every tool but the Type tools shows.
+1
View File
@@ -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,
+89 -9
View File
@@ -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]
+42 -10
View File
@@ -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<String, Value> {
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<Vec<UiRequest>> {
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 {
+9
View File
@@ -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<Action> {
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())
}
+17 -1
View File
@@ -35,6 +35,9 @@ pub struct CurvatureTool {
id: Option<NodeId>,
/// 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<usize>)>,
/// Last touched point of the path being drawn (Backspace removes it).
current: Option<usize>,
hover: Option<Point>,
@@ -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<Action> {
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<Action> {
match key {
ToolKey::Escape | ToolKey::Enter => {
+28
View File
@@ -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() {
+6
View File
@@ -124,6 +124,12 @@ pub trait Tool: Send {
fn key(&mut self, _cx: &ToolContext, _key: ToolKey, _mods: Mods) -> Vec<Action> {
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<Action> {
self.key(cx, ToolKey::Escape, Mods::default())
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
vec![]
}
+9
View File
@@ -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<Action> {
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<Action> {
let path = self.path;
+2 -2
View File
@@ -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>(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![]));
+6 -6
View File
@@ -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>(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
+1 -1
View File
@@ -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.