Merge pull request #5234 from dotCooCoo/fix/dropped-press-cancels-tool

Canvas: a dropped press cancels the tool's drag instead of ending it
This commit is contained in:
BFlat
2026-11-03 16:32:35 +07:00
committed by GitHub
24 changed files with 679 additions and 35 deletions
+6
View File
@@ -203,6 +203,12 @@ pub struct Session {
/// The pointer button is held: the last pointer event was a press or a drag, not a release or
/// a move ([`Session::pointer_held`]).
pub(crate) pointer_held: bool,
/// The id and persistent options of the tool when the press under way began, which a dropped
/// press puts back ([`Session::drop_tool_press`]).
pub(crate) press_options: Option<(String, serde_json::Map<String, serde_json::Value>)>,
/// 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<Option<(usize, cmd::gradient::StopOwner)>>,
/// 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.
+2
View File
@@ -92,6 +92,8 @@ impl Session {
liquify_stroke: None,
plane_widget_press: false,
pointer_held: false,
press_options: None,
press_point: None,
ruler_guide: None,
port_marks: Default::default(),
font_search: None,
+26
View File
@@ -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}");
}
}
+110
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,115 @@ fn a_gesture_keeps_what_it_changed_and_a_document_switch_keeps_options() {
assert_eq!(s.tool_options()["segments"], json!(14));
}
/// A dropped press (a long touch, a canceled touch, a second finger) keeps nothing it began: the
/// tool ends at rest with the options it had before the press, and the options the drag changed
/// are not stored. A release stores them.
#[test]
fn a_dropped_press_does_not_keep_what_its_drag_changed() {
let mut s = session();
select(&mut s, "polygon");
let v = ViewInfo::default();
let drag_with_one_more_side = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 150.0), v).unwrap();
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
};
assert_eq!(s.tool_options()["sides"], json!(6));
drag_with_one_more_side(&mut s);
assert_eq!(s.tool_options()["sides"], json!(7));
s.drop_tool_press(v).unwrap();
assert!(!s.tool_busy(), "the tool is at rest");
let st = s.active().unwrap();
assert!(st.interaction.is_none() && st.doc.art_bounds().is_none(), "the polygon is not drawn");
assert_eq!(s.tool_options()["sides"], json!(6), "the tool has its sides from before the press");
assert_eq!(s.prefs.tool_settings.get("polygon").and_then(|o| o.get("sides")), None, "the dropped drag's sides are not stored");
drag_with_one_more_side(&mut s);
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0), v).unwrap();
assert!(s.active().unwrap().doc.art_bounds().is_some(), "a release draws the polygon");
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(7), "and stores its sides");
}
/// A dropped Pen press drops only the anchor it was placing: the path being drawn goes on with
/// the next click.
#[test]
fn a_dropped_pen_press_keeps_the_path_being_drawn() {
let mut s = session();
select(&mut s, "pen");
let v = ViewInfo::default();
let click = |s: &mut Session, x: f64, y: f64| {
s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap();
};
click(&mut s, 10.0, 10.0);
click(&mut s, 100.0, 10.0);
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 140.0, 120.0), v).unwrap();
s.drop_tool_press(v).unwrap();
click(&mut s, 10.0, 100.0);
let d = &s.active().unwrap().doc;
let paths = d.layers[0].children().unwrap();
assert_eq!(paths.len(), 1, "no new path starts");
assert_eq!(paths[0].path_data().unwrap().anchor_count(), 3, "the dropped anchor is not placed");
}
/// A dropped Pen press on the first anchor does not close the path or end it.
#[test]
fn a_dropped_pen_close_keeps_the_path_open_and_being_drawn() {
let mut s = session();
select(&mut s, "pen");
let v = ViewInfo::default();
let click = |s: &mut Session, x: f64, y: f64| {
s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap();
};
click(&mut s, 10.0, 10.0);
click(&mut s, 100.0, 10.0);
click(&mut s, 100.0, 100.0);
s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 40.0, 30.0), v).unwrap();
s.drop_tool_press(v).unwrap();
click(&mut s, 10.0, 100.0);
let d = &s.active().unwrap().doc;
let paths = d.layers[0].children().unwrap();
assert_eq!(paths.len(), 1, "no new path starts");
let pd = paths[0].path_data().unwrap();
assert_eq!(pd.anchor_count(), 4, "the next click goes on the path");
assert!(!pd.subpaths[0].closed, "the path is not closed");
}
/// A dropped marquee selects nothing: the selection stays as it was before the press.
#[test]
fn a_dropped_marquee_leaves_the_selection_alone() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 600, "y": 400, "width": 100, "height": 100})).unwrap();
let before = s.doc().unwrap().selection.objects.clone();
assert_eq!(before.len(), 1);
select(&mut s, "selection");
let v = ViewInfo::default();
s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 300.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 350.0, 350.0), v).unwrap();
s.drop_tool_press(v).unwrap();
assert!(!s.tool_busy(), "the tool is at rest");
assert_eq!(s.doc().unwrap().selection.objects, before);
}
/// A dropped Shift-drag marquee of the Artboard tool toggles no artboard: the tool takes Escape,
/// as during a drag, before its release.
#[test]
fn a_dropped_artboard_marquee_toggles_nothing() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
select(&mut s, "artboard");
let before = s.tool_options()["selected"].clone();
let v = ViewInfo::default();
let shift = Mods { shift: true, ..Mods::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, 1200.0, 500.0).with_mods(shift), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 1250.0, 550.0).with_mods(shift), v).unwrap();
s.drop_tool_press(v).unwrap();
assert!(!s.tool_busy(), "the tool is at rest");
assert_eq!(s.tool_options()["selected"], before, "no artboard is toggled");
}
#[test]
fn options_are_saved_with_the_preferences() {
let mut s = session();
+63 -2
View File
@@ -358,26 +358,87 @@ impl Session {
/// Feed a pointer event to the active tool. Returns requests for the UI.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
self.pointer_event(ev, view, true)
}
/// Drop the press in progress (a long touch, a canceled touch, a second finger): nothing it
/// began is kept. Its interaction is undone, the tool drops the press ([`Tool::drop_press`]:
/// Escape, as during a drag, for most tools), and a release returns the tool to rest. Of what
/// the tool asks for along the way, only a cancel and a notify run, so a marquee selects
/// nothing and a click opens no size dialog. The tool gets back the persistent options it had
/// when the press began, and none of the press's changes to them are stored. The freeform
/// gradient point selected once the tool took the press is selected again.
pub fn drop_tool_press(&mut self, view: ViewInfo) -> Result<()> {
// Nothing to roll back without a document; the tool still drops the press and gets the release.
let _ = self.cancel_interaction();
let before = self.press_options.take();
let point = self.press_point.take();
let mut acts = self.with_tool_cx(view, |t, cx| t.drop_press(cx));
acts.retain(|a| matches!(a, Action::Cancel | Action::Notify(_)));
let dropped = self.take_tool_panic().and_then(|()| self.apply_actions(acts));
let up = PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), backquote: false, pressure: 1.0, pen: None };
let r = dropped.and(self.pointer_event(&up, view, false));
// A drag out of the object previews the point's removal, which deselects it.
if let Some(p) = point {
self.freeform_point = p;
}
if let Some((id, opts)) = before
&& id == self.tool.id()
{
let now = self.tool.options();
for (k, v) in &opts {
if now.get(k) != Some(v) {
self.tool.set_option(k, v);
}
}
}
r.map(drop)
}
/// The options of `t` that [`Self::store_tool_settings`] stores.
fn persistent_options(t: &dyn Tool) -> Map<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 {
PointerKind::Down | PointerKind::Drag => self.pointer_held = true,
PointerKind::Up | PointerKind::Move => self.pointer_held = false,
PointerKind::DoubleClick => {}
}
match ev.kind {
PointerKind::Down => self.press_options = Some((self.tool.id().to_string(), Self::persistent_options(self.tool.as_ref()))),
PointerKind::Up => self.press_options = None,
_ => {}
}
// The Plane Switching Widget takes its clicks whatever the tool.
if let Some(r) = self.plane_widget_pointer(ev, view) {
return r;
}
self.leave_draw_inside(ev, view)?;
let ev = &self.lend_selection_tool(ev);
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
let mut acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
if !keep {
acts.retain(|a| matches!(a, Action::Cancel | Action::Notify(_)));
}
let r = self.take_tool_panic().and_then(|()| {
// A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
if ev.kind == PointerKind::Up {
if keep && ev.kind == PointerKind::Up {
self.keep_tool_settings();
}
self.apply_actions(acts)
});
match ev.kind {
PointerKind::Down => self.press_point = Some(self.freeform_point),
PointerKind::Up => self.press_point = None,
_ => {}
}
if ev.kind == PointerKind::Up {
self.give_back_tool(view)?;
}
+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())
}
+31
View File
@@ -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<Action> {
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();
+43
View File
@@ -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<Action> {
if self.dragging {
return self.key(cx, ToolKey::Escape, Mods::default());
}
self.press = None;
vec![]
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
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");
}
}
+42
View File
@@ -105,6 +105,8 @@ pub struct WidthTool {
drag: Option<Drag>,
/// Selected width points: the stroke's owner and their positions t.
selected: Option<(NodeId, Vec<f64>)>,
/// The selected width points when the button went down: a dropped press puts them back.
before_press: Option<(NodeId, Vec<f64>)>,
}
/// 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<Action> {
self.drag = None;
self.selected = self.before_press.take();
vec![]
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
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();
+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![]
}
+85 -21
View File
@@ -48,6 +48,9 @@ pub struct BlendTool {
first: Option<Pick>,
/// A blend was just made: the next click adds to it (the selected blend).
chained: bool,
/// `first` and `chained` when the button went down: a dropped press puts them back, unless
/// the press picked an object or made a blend.
before_press: Option<(Option<Pick>, bool)>,
}
/// The anchor of the first subpath of path or compound path `id` (its first member's) nearest
@@ -98,25 +101,32 @@ impl Tool for BlendTool {
match ev.kind {
PointerKind::DoubleClick => options(self),
PointerKind::Down if ev.mods.alt => options(self),
PointerKind::Down => match (self.first(cx), pick(cx, ev.pos)) {
(_, None) => {
(self.first, self.chained) = (None, false);
vec![]
}
(None, Some((top, start))) => {
self.first = Some((top, start));
vec![Action::Exec("select.set".into(), json!({"ids": [top.0]}))]
}
(Some((a, sa)), Some((b, sb))) if a != b && cx.doc.node(a).is_some() => {
(self.first, self.chained) = (None, true);
let mut p = json!({"ids": [a.0, b.0]});
if sa.is_some() || sb.is_some() {
p["starts"] = json!([sa, sb]);
PointerKind::Down => {
self.before_press = Some((self.first, self.chained));
match (self.first(cx), pick(cx, ev.pos)) {
(_, None) => {
(self.first, self.chained) = (None, false);
vec![]
}
vec![Action::Exec("object.blend.make".into(), p)]
(None, Some((top, start))) => {
(self.first, self.before_press) = (Some((top, start)), None);
vec![Action::Exec("select.set".into(), json!({"ids": [top.0]}))]
}
(Some((a, sa)), Some((b, sb))) if a != b && cx.doc.node(a).is_some() => {
(self.first, self.chained, self.before_press) = (None, true, None);
let mut p = json!({"ids": [a.0, b.0]});
if sa.is_some() || sb.is_some() {
p["starts"] = json!([sa, sb]);
}
vec![Action::Exec("object.blend.make".into(), p)]
}
(Some(_), Some(_)) => vec![],
}
(Some(_), Some(_)) => vec![],
},
}
PointerKind::Up => {
self.before_press = None;
vec![]
}
_ => vec![],
}
}
@@ -151,6 +161,15 @@ impl Tool for BlendTool {
}
vec![]
}
/// A dropped press puts back the object picked first and the blend being added to as they
/// were when the button went down, so the picks of earlier clicks stay. An object the press
/// picked stays picked and selected, and a blend the press made stays made.
fn drop_press(&mut self, _cx: &ToolContext) -> Vec<Action> {
if let Some((first, chained)) = self.before_press.take() {
(self.first, self.chained) = (first, chained);
}
vec![]
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
(self.first, self.chained) = (None, false);
vec![]
@@ -354,7 +373,7 @@ mod tests {
let mut s = Selection::default();
s.set([g]);
let c = cx(&d, &s, &p);
let mut t = BlendTool { first: None, chained: true };
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
assert!(t.first(&c).is_some());
assert!(t.chosen_again(&c).is_empty());
assert!(t.first(&c).is_none(), "choosing the tool again ends adding to the blend just made");
@@ -385,7 +404,7 @@ mod tests {
// After a blend is made, a click while the selected blend is gone picks the object it hits.
let mut s = Selection::default();
s.set([NodeId(9999)]);
let mut t = BlendTool { first: None, chained: true };
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
let acts = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 320.0, 120.0));
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [b.0]}))]);
}
@@ -432,7 +451,7 @@ mod tests {
d.insert(Some(l), 1, blend).unwrap();
let mut s = Selection::default();
let p = paint();
let mut t = BlendTool { first: None, chained: true };
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
s.set([g]);
let c = cx(&d, &s, &p);
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
@@ -477,7 +496,7 @@ mod tests {
let mut s = Selection::default();
s.set([g]);
let c = cx(&d, &s, &p);
let mut t = BlendTool { first: None, chained: true };
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
assert!(t.first(&c).is_some());
assert!(t.key(&c, crate::ToolKey::Escape, Mods::default()).is_empty());
assert!(t.first(&c).is_none(), "Escape ends adding to the blend just made");
@@ -485,6 +504,51 @@ mod tests {
assert!(esc(&mut BlendTool::default()).is_empty(), "nothing to drop");
}
/// A dropped press (a long touch, a canceled touch) keeps the pick an earlier click made and
/// keeps adding to the blend just made: the next click blends with them. A dropped press
/// that picked an object keeps that pick, as the object stays selected.
#[test]
fn a_dropped_press_keeps_the_blend_tools_first_pick() {
let (mut d, a) = doc_with_rect();
let l = d.layers[0].id;
let (b, g) = (d.alloc_id(), d.alloc_id());
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap();
let (k1, k2) = (d.alloc_id(), d.alloc_id());
let key = |id: NodeId, x: f64| {
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0));
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
};
let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() });
d.insert(Some(l), 2, blend).unwrap();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y);
// The engine sends the release after the drop.
let dropped = |t: &mut BlendTool, c: &ToolContext, x: f64, y: f64| {
t.pointer(c, &ev(PointerKind::Down, x, y));
assert!(t.drop_press(c).is_empty());
assert!(t.pointer(c, &ev(PointerKind::Up, x, y)).is_empty());
};
let mut t = BlendTool::default();
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)), vec![Action::Exec("select.set".into(), json!({"ids": [a.0]}))]);
assert!(t.drop_press(&c).is_empty());
t.pointer(&c, &ev(PointerKind::Up, 150.0, 150.0));
assert_eq!(t.first(&c), Some((a, None)), "a dropped first press keeps the object it picked and selected");
// A second press on a, and a press on empty canvas, each dropped.
dropped(&mut t, &c, 151.0, 151.0);
dropped(&mut t, &c, 450.0, 450.0);
assert_eq!(t.first(&c), Some((a, None)), "the pick stays");
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 320.0, 120.0)), vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]);
// After a blend is made, a dropped press on empty canvas keeps adding to it.
let mut s = Selection::default();
s.set([g]);
let c = cx(&d, &s, &p);
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
dropped(&mut t, &c, 450.0, 450.0);
assert_eq!(t.first(&c), Some((g, None)), "the blend just made is still added to");
}
#[test]
fn mesh_tool_click_path_creates_mesh() {
let (d, a) = doc_with_rect();
+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;
+31
View File
@@ -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<Action> {
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<Action> {
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();
+35
View File
@@ -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();
+3
View File
@@ -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<Point>, u8),
/// Styled copy of the last copied range (pasting the same plain text keeps its styles).
+35
View File
@@ -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");
+17
View File
@@ -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<Action> {
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<Action> {
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![];
+33 -2
View File
@@ -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<Action> {
match g.grab {
Grab::Point(i) if g.out => vec![Action::Cancel, select(Some(i))],
Grab::Point(i) if g.left => vec![Action::Cancel, select(Some(i))],
_ => vec![Action::Cancel],
}
}
@@ -451,6 +452,31 @@ mod tests {
assert!(!t.claims_key(&cx, ToolKey::Escape));
}
/// A dropped click adds no point and leaves the line being drawn open: the next click still
/// joins it. A dropped drag of a point is canceled as by Escape.
#[test]
fn a_dropped_click_keeps_the_line_open() {
let (d, s) = doc(FreeformMode::Lines);
let p = paint();
let mut cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
let add = |x: f64, y: f64, line: bool| Action::Exec("paint.freeform.addPoint".into(), json!({"at": [x, y], "line": line, "stroke": false}));
assert_eq!(click(&mut t, &cx, 140.0, 150.0), vec![add(140.0, 150.0, false)]);
cx.freeform_point = Some(2);
assert!(t.claims_key(&cx, ToolKey::Escape));
// The engine sends the release after the drop.
t.pointer(&cx, &ev(PointerKind::Down, 170.0, 160.0));
assert!(t.drop_press(&cx).is_empty());
assert!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 160.0)).is_empty(), "the dropped click adds no point");
assert!(t.claims_key(&cx, ToolKey::Escape), "the line stays open");
assert_eq!(click(&mut t, &cx, 160.0, 140.0), vec![add(160.0, 140.0, true)]);
t.pointer(&cx, &ev(PointerKind::Down, 120.0, 121.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Drag, 130.0, 140.0))[0], Action::Begin("Gradient".into()));
assert_eq!(t.drop_press(&cx), vec![Action::Cancel]);
assert!(!t.busy());
assert!(t.pointer(&cx, &ev(PointerKind::Up, 130.0, 140.0)).is_empty());
}
#[test]
fn a_point_dragged_out_of_the_object_is_removed() {
let (d, s) = doc(FreeformMode::Points);
@@ -474,6 +500,11 @@ mod tests {
t.pointer(&cx, &ev(PointerKind::Down, 120.0, 120.0));
t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0));
assert_eq!(t.key(&cx, ToolKey::Escape, Default::default()), vec![Action::Cancel, select(Some(0))]);
// Also after the drag came back inside.
t.pointer(&cx, &ev(PointerKind::Down, 120.0, 120.0));
t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0));
t.pointer(&cx, &ev(PointerKind::Drag, 110.0, 110.0));
assert_eq!(t.key(&cx, ToolKey::Escape, Default::default()), vec![Action::Cancel, select(Some(0))]);
}
#[test]
+11
View File
@@ -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<Action> {
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<Action> {
if key == ToolKey::Escape && self.busy() {
self.gesture = None;
+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 drops the press, and a mouse-up returns it to rest with the options it had
// when the press began. A move waiting over a panel is still live and would otherwise outlive the drag that
// owns it (#2826).
let r = app.session.drop_tool_press(view);
apply_requests(app, r.map(|()| vec![]));
egui::DragAndDrop::clear_payload(ui.ctx());
}
Some(_) => {}
+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 canceled touch drops the tool's drag: the options the drag changed (the Polygon tool's
/// sides) are put back and not stored, and nothing is drawn.
#[test]
fn a_canceled_touch_drops_the_drag_without_keeping_its_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.prefs.touch_gestures = true;
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
app.select_tool("polygon");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let start = xf.to_screen(Point::new(200.0, 200.0));
frame(&mut app, &ctx, pen(egui::TouchPhase::Start, start, 0.5));
frame(&mut app, &ctx, pen(egui::TouchPhase::Move, start + vec2(60.0, 40.0), 0.5));
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Tool)));
assert!(app.session.active().unwrap().interaction.is_some(), "the drag previews the polygon");
let view = app.view_info();
assert_eq!(app.session.tool_options()["sides"], json!(6));
app.session.tool_key(vectorcraft_tools::ToolKey::Up, Mods::default(), view).unwrap();
assert_eq!(app.session.tool_options()["sides"], json!(7));
frame(&mut app, &ctx, pen(egui::TouchPhase::Cancel, start + vec2(60.0, 40.0), 0.5));
assert!(!app.session.tool_busy(), "the tool is at rest");
let st = app.session.active().unwrap();
assert!(st.interaction.is_none() && st.doc.art_bounds().is_none(), "nothing is drawn");
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.tool_options()["sides"], json!(6), "the tool has its sides from before the press");
let stored = app.session.prefs.tool_settings.get("polygon").and_then(|o| o.get("sides"));
assert_eq!(stored, None, "the dropped drag's sides are not stored");
}
/// #2826: a second finger drops a move waiting over a panel, so its interaction doesn't
/// outlive the drag that began it.
#[test]
+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 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.