From a8c166a957d3518592aafd68b78054465f0d7233 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 02:21:21 -0700 Subject: [PATCH] Selection tool: Escape cancels a move, scale, rotation, marquee or widget drag Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a drag of a corner widget, a pie widget or the brackets of type on a path did nothing, and the release then applied the drag. Only a ruler guide, mirror axis or grid repeat drag and the loaded text took Escape. Escape in any of these drags now cancels it, the way Direct Selection cancels its drags: the art returns to its state at the press, an Alt-drag leaves no copy, no undo step is added, and the release does nothing. Outside a drag Escape works as before. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/tools/src/pie.rs | 18 ++++ crates/tools/src/select.rs | 15 ++- crates/tools/src/select/tests.rs | 119 +++++++++++++++++++++++ crates/ui-egui/src/tests.rs | 1 + crates/ui-egui/src/tests/selectescape.rs | 51 ++++++++++ docs/mcp.md | 4 + docs/target-app-parity.md | 1 + 7 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/tests/selectescape.rs diff --git a/crates/tools/src/pie.rs b/crates/tools/src/pie.rs index 57ef47734..af79c1ce4 100644 --- a/crates/tools/src/pie.rs +++ b/crates/tools/src/pie.rs @@ -287,6 +287,24 @@ mod tests { assert!(t.pointer(&c, &ev(PointerKind::Up, Point::new(312.0, 150.0))).is_empty()); } + /// Escape during a pie widget drag with the Selection tool cancels it: the angle readout is removed, + /// and the release after it commits nothing. + #[test] + fn escape_cancels_a_pie_widget_drag() { + let (d, _, s) = doc_with_ellipse((0.0, 360.0)); + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, Point::new(312.0, 150.0))).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, Point::new(200.0, 20.0))); + assert_eq!(a[0], Action::Begin("Live Shape".into())); + assert_eq!(t.key(&c, crate::ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the drag"); + assert!(!t.busy() && !t.transforming()); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { .. })), "the readout is removed"); + assert!(t.pointer(&c, &ev(PointerKind::Drag, Point::new(100.0, 200.0))).is_empty(), "the drag is over"); + assert!(t.pointer(&c, &ev(PointerKind::Up, Point::new(100.0, 200.0))).is_empty(), "the release commits nothing"); + } + /// On a whole ellipse the two widgets sit at the very same point, whatever its size, place or /// angle, so a press there always takes the End widget. #[test] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 3dab80fa3..c957066c7 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -17,6 +17,8 @@ //! scaling its type: the text reflows at its size. //! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and //! E pressed again makes it take the objects it touches. +//! Escape during a drag (a move or an Alt-drag copy, a scale, a rotation, a marquee, a widget or +//! bracket drag) cancels it: the art returns to its state at the press. //! Selected area type and type on a path show an in port and an out port. A click on the out port //! loads the pointer with the text ([`Cursor::LoadedText`]): the next click threads it into the //! closed path, area type or type on a path under the pointer, or into a new frame of the same @@ -559,7 +561,11 @@ impl Tool for SelectionTool { } /// E during a marquee drag switches the marquee between the objects it touches and only the - /// objects inside it. + /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or + /// rotation, a marquee, a corner widget, a pie widget, the brackets of type on a path, a mirror + /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy + /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape + /// drops the text. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Letter('e') && let State::Marquee { enclosed, .. } = &mut self.state @@ -583,6 +589,13 @@ impl Tool for SelectionTool { self.state = State::Idle; return vec![]; } + if key == ToolKey::Escape && !matches!(self.state, State::Idle) { + self.state = State::Idle; + self.measure = None; + self.guides.clear(); + (self.targets, self.moving, self.snapped) = (None, None, false); + return vec![Action::Cancel]; + } if key == ToolKey::Escape { self.guide.cancel() } else { vec![] } } diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index f777d59cc..0819d56ad 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -102,6 +102,44 @@ fn a_corner_drag_counts_as_transforming_once_it_moves() { assert!(t.transforming()); } +/// Escape during a corner widget drag cancels it, and the release after it commits nothing. +/// After an Alt-press on a widget, Escape drops the press, so its release cycles no corner kind. +#[test] +fn escape_cancels_a_corner_widget_drag() { + use vectorcraft_doc::{Appearance, LiveShape, Node, NodeKind}; + let live = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), + }; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let id = d.alloc_id(); + let mut n = Node::path(id, live.to_path(), Appearance::default_art()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(live)); + } + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 190.0, 191.0)).is_empty()); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 175.0, 176.0)); + assert_eq!(a.first(), Some(&Action::Begin("Corner Radius".into())), "{a:?}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the drag"); + assert!(!t.busy() && !t.transforming()); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 171.0)).is_empty(), "the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 171.0)).is_empty(), "the release commits nothing"); + let alt = Mods { alt: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 190.0, 191.0).with_mods(alt)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 190.0, 191.0).with_mods(alt)).is_empty(), "the Alt-click cycles no corner kind"); +} + /// A type-on-path bracket drag is a transform, so the sharp selection box hides while the span moves. #[test] fn a_bracket_drag_counts_as_transforming_once_it_moves() { @@ -118,6 +156,25 @@ fn a_bracket_drag_counts_as_transforming_once_it_moves() { assert!(t.transforming()); } +/// Escape during a drag of the brackets of type on a path cancels it, and the release after it +/// commits nothing. +#[test] +fn escape_cancels_a_bracket_drag() { + let (d, id) = crate::testutil::doc_with_path_type(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 280.0, 290.0)).is_empty()); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 320.0, 290.0)); + assert!(matches!(a.first(), Some(Action::Begin(_))), "{a:?}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the drag"); + assert!(!t.busy() && !t.transforming()); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 330.0, 290.0)).is_empty(), "the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 330.0, 290.0)).is_empty(), "the release commits nothing"); +} + /// #812: with Smart Guides on, hovering near an object's centre or an anchor labels it, and a /// press there grabs the object by that point. #[test] @@ -460,6 +517,30 @@ fn drag_moves_with_single_undo() { assert_eq!(a, vec![Action::Commit]); } +/// Escape during a move, or an Alt-drag copy, cancels it: the release after it commits nothing, +/// and the next drag is a new one. Escape with nothing to cancel sends nothing. +#[test] +fn escape_cancels_a_move_or_an_alt_drag_copy() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + for (what, m) in [("move", Mods::default()), ("copy", Mods { alt: true, ..Default::default() })] { + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0).with_mods(m)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0).with_mods(m)); + assert!(matches!(&a[1], Action::Preview(_, v) if v["copy"] == m.alt), "{what}: {a:?}"); + assert!(t.transforming(), "{what}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy() && !t.transforming(), "{what}"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Measure { .. })), "{what}: no readout stays"); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 150.0).with_mods(m)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 150.0).with_mods(m)).is_empty(), "{what}: the release commits nothing"); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty(), "{what}: nothing to cancel"); + } +} + /// #1534: with Smart Guides on (the default), the point a selection is dragged by still snaps /// to a ruler guide within the Snap to Point distance; not with Snap to Point off. #[test] @@ -907,6 +988,22 @@ fn marquee_selects() { assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); } +/// Escape during a marquee drag ends it: the marquee is removed, and its release selects nothing. +#[test] +fn escape_cancels_a_marquee() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the marquee"); + assert!(!t.busy()); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee is removed"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)).is_empty(), "the release selects nothing"); +} + /// Shift-drag a marquee: the objects it reaches toggle (#483), so selected ones can be /// taken out of the selection; a Shift-click on empty canvas leaves the selection alone. #[test] @@ -1099,6 +1196,28 @@ fn handle_drag_scales() { assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); } +/// Escape during a bounding-box scale or rotation cancels it, and the release after it commits +/// nothing. +#[test] +fn escape_cancels_a_scale_or_a_rotation() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + // (what, the press, its Begin, the drag) + for (what, from, begin, to) in [("scale", (200.0, 200.0), "Scale", (300.0, 300.0)), ("rotate", (210.0, 210.0), "Rotate", (150.0, 230.0))] { + let mut t = SelectionTool::default(); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, from.0, from.1)), vec![Action::Begin(begin.into())], "{what}"); + assert!(matches!(t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1)).as_slice(), [Action::Preview(..)]), "{what}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy() && !t.transforming(), "{what}"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Measure { .. })), "{what}: no readout stays"); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, to.0 + 5.0, to.1)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, to.0 + 5.0, to.1)).is_empty(), "{what}: the release commits nothing"); + } +} + #[test] fn handle_drag_snaps_to_the_other_objects_with_smart_guides() { use vectorcraft_doc::{Appearance, Node}; diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index ef79cde6a..e38f66872 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -41,6 +41,7 @@ mod screenshots; mod script_check; mod scrub; mod selectall; +mod selectescape; mod slices; pub(super) mod svg; mod svgsave; diff --git a/crates/ui-egui/src/tests/selectescape.rs b/crates/ui-egui/src/tests/selectescape.rs new file mode 100644 index 000000000..1acf599f7 --- /dev/null +++ b/crates/ui-egui/src/tests/selectescape.rs @@ -0,0 +1,51 @@ +//! Escape through shortcut routing during a Selection tool move, Alt-drag copy, scale or rotation. + +use egui::{Key, Modifiers}; +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use crate::VectorcraftApp; + +/// One frame pressing `key` through the shortcut handler. +fn press(app: &mut VectorcraftApp, key: Key) { + let ctx = egui::Context::default(); + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); +} + +#[test] +fn escape_cancels_a_selection_tool_move_copy_scale_or_rotation() { + // (what, Alt held, where the drag starts, where it goes) + for (what, alt, from, to) in [ + ("move", false, (150.0, 150.0), (190.0, 170.0)), + ("copy", true, (150.0, 150.0), (190.0, 170.0)), + ("scale", false, (200.0, 200.0), (260.0, 240.0)), + ("rotate", false, (210.0, 210.0), (150.0, 240.0)), + ] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.select_tool("selection"); + let st = app.session.doc().unwrap(); + let (before, undo) = (st.doc.clone(), st.history.undo.len()); + let view = app.view_info(); + let mods = Mods { alt, ..Default::default() }; + let mid = ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0); + for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, mid), (PointerKind::Drag, to)] { + app.session.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), view).unwrap(); + } + assert!(app.session.tool_busy(), "{what}"); + assert_ne!(*app.session.doc().unwrap().doc, *before, "{what}: the drag changes the preview"); + press(&mut app, Key::Escape); + assert_eq!(*app.session.doc().unwrap().doc, *before, "{what}: Escape cancels the drag, and no copy is left"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "{what}: and adds no undo step"); + assert!(!app.session.tool_busy(), "{what}"); + assert_eq!(app.session.tool_id(), "selection", "{what}: Escape keeps the tool"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(mods), view).unwrap(); + assert_eq!(*app.session.doc().unwrap().doc, *before, "{what}: the release changes nothing"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "{what}"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 2976dfb18..27faa692e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -470,6 +470,10 @@ mode. To press the key during a drag, end one {"name":"pointer_gesture","arguments":{"tool":"selection","events":[{"kind":"drag","x":230,"y":170},{"kind":"up","x":230,"y":170}]}} ``` +`press_key {key: "Escape"}` between two `pointer_gesture` calls of a Selection tool drag (a move or an Alt-drag copy, a +bounding-box scale or rotation, a marquee, a corner widget, pie widget or bracket drag) cancels it: the art goes back, +no copy is left, no undo step is added, and the `up` event that follows does nothing. + With nothing being dragged, `e` chooses the tool bound to E. While an arc is being dragged out with the Arc tool, `press_key {key: "c"}` switches it between an open and a closed diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 207e200c7..462e227bb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -472,6 +472,7 @@ since 2026-10-01. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. +- **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. - **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`. - **TIFF in screen and asset export:** both format menus expose the existing TIFF encoder and Format Settings. Headless UI regressions choose TIFF and check scaled dimensions, artboard selection, filenames, encoding options, saved settings and collected-asset output. Public reference: [Export for Screens](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/export-for-screens.html), updated 2026-09-28, lists TIFF for both artboards and assets. Adobe staff [confirmed support in 30.2.1](https://illustrator.uservoice.com/forums/333657-illustrator-desktop-feature-requests/suggestions/49081034-add-tiff-format-into-export-for-screens-assets-d) on 2026-02-19; the older 2025 Collect assets guide omits TIFF.