From 38bb9034959fb9889ab8bef0e6ff307f7829a0c1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 22:55:24 -0700 Subject: [PATCH] Distort tools: Escape cancels a Puppet Warp, perspective or Liquify drag Escape did not cancel a drag with the Puppet Warp, Perspective Grid, Perspective Selection and Liquify tools: the drag went on until the mouse was released. Escape cancels a drag with the Direct Selection, Gradient and Artboard tools. Escape now cancels a Puppet Warp pin drag or turn, a Perspective Grid handle or plane widget drag, a Perspective Selection move, resize or plane widget drag, and a Liquify stroke. The art and the grid go back to how they were when the drag began, the undo history does not change, and the rest of the drag and the release send nothing. Puppet Warp selects again the pins that were selected before the press. During a Liquify Alt-drag that sizes the brush, Escape puts the brush size back; the tool claims Escape only then. Escape during a Perspective Selection press that has not moved past the drag threshold ends the press, so moving on does not start a move. Otherwise, outside a drag, Escape does what it did before. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/tools/src/distort/liquify.rs | 63 ++++++++++++++++++- crates/tools/src/distort/perspective.rs | 58 +++++++++++++++++ crates/tools/src/distort/perspective/tools.rs | 18 ++++++ crates/tools/src/distort/puppet.rs | 47 +++++++++++++- crates/ui-egui/src/tests/distortkeys.rs | 41 ++++++++++++ docs/mcp.md | 13 ++-- docs/target-app-parity.md | 1 + 7 files changed, 234 insertions(+), 7 deletions(-) diff --git a/crates/tools/src/distort/liquify.rs b/crates/tools/src/distort/liquify.rs index 9f3583e2a..3e23b769d 100644 --- a/crates/tools/src/distort/liquify.rs +++ b/crates/tools/src/distort/liquify.rs @@ -5,7 +5,8 @@ //! pointer sample so far (and its pen pressure while Use Pressure Pen is on), so the result is a //! pure function of the parameters (replay reproduces it exactly). Holding the brush still with //! Twirl, Pucker or Bloat repeats the last sample every [`HOLD_EVERY`] seconds of [`Tool::tick`] -//! time: one more dab there each time. +//! time: one more dab there each time. Escape during a stroke cancels it, and Escape during an +//! Alt-drag that sizes the brush puts the brush size back. //! //! The kernel resamples the stroke into dabs spaced a fraction of the brush apart ([`Dabber`]: //! more samples only add dabs, so a growing stroke can be applied as it grows). For each dab it @@ -19,7 +20,7 @@ use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, Rect, SubPath, Vec2}; use super::ellipse_path; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LiquifyKind { @@ -813,6 +814,27 @@ impl Tool for LiquifyTool { } } } + /// The tool claims Escape during an Alt-drag that sizes the brush. + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + key == ToolKey::Escape && self.sizing.is_some() + } + /// Escape during a stroke cancels it: the art goes back to how the press found it. During an + /// Alt-drag sizing the brush, Escape puts the brush size back. + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key != ToolKey::Escape { + return vec![]; + } + if let Some(s) = self.sizing.take() { + (self.params.width, self.params.height) = (s.width, s.height); + return vec![]; + } + if !std::mem::take(&mut self.active) { + return vec![]; + } + self.points.clear(); + (self.held, self.previewed) = (0.0, 0); + vec![Action::Cancel] + } fn busy(&self) -> bool { self.active } @@ -960,4 +982,41 @@ mod tests { let r = LiquifyParams::from_json(&json!({"tool": "warp", "diameter": 40, "intensity": 80})).unwrap(); assert_eq!((r.width, r.height, r.intensity), (40.0, 40.0, 0.8)); } + + #[test] + fn escape_cancels_a_stroke_or_a_brush_size_drag() { + use crate::testutil::{cx, doc_with_rect, paint}; + let (d, id) = doc_with_rect(); + let s = vectorcraft_doc::Selection { objects: vec![id], ..Default::default() }; + let p = paint(); + let c = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let ev = |kind, x, y, mods| PointerEvent::new(kind, x, y).with_mods(mods); + for tool in ["warp", "twirl"] { + let mut t = LiquifyTool::new(tool); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "{tool}: no stroke, Escape is the shortcut's"); + assert!(!t.claims_key(&c, ToolKey::Escape), "{tool}"); + assert!(matches!(t.pointer(&c, &ev(PointerKind::Down, 190.0, 150.0, Mods::default()))[0], Action::Begin(_)), "{tool}"); + t.pointer(&c, &ev(PointerKind::Drag, 220.0, 150.0, Mods::default())); + assert!(t.busy(), "{tool}"); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{tool}"); + assert!(!t.busy() && !t.wants_ticks(), "{tool}"); + // The rest of the stroke does nothing. + assert!(t.tick(&c, 1.0).is_empty(), "{tool}"); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 240.0, 150.0, Mods::default())).is_empty(), "{tool}"); + assert!(t.pointer(&c, &ev(PointerKind::Up, 240.0, 150.0, Mods::default())).is_empty(), "{tool}"); + // Alt-drag sizes the brush; Escape puts the size back and the rest of that drag does nothing. + let size = (t.params.width, t.params.height); + t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0, alt)); + t.pointer(&c, &ev(PointerKind::Drag, 180.0, 170.0, alt)); + assert_ne!((t.params.width, t.params.height), size, "{tool}"); + assert!(t.claims_key(&c, ToolKey::Escape), "{tool}"); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "{tool}"); + assert_eq!((t.params.width, t.params.height), size, "{tool}"); + assert!(!t.claims_key(&c, ToolKey::Escape), "{tool}"); + t.pointer(&c, &ev(PointerKind::Drag, 200.0, 200.0, alt)); + assert!(t.pointer(&c, &ev(PointerKind::Up, 200.0, 200.0, alt)).is_empty(), "{tool}"); + assert_eq!((t.params.width, t.params.height), size, "{tool}"); + } + } } diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index aeaef8461..9a9863e55 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -1094,6 +1094,29 @@ mod tests { assert!(!grid_overlays(&d, 1.0).is_empty()); } + #[test] + fn escape_cancels_a_grid_tool_drag() { + let mut d = Document::new(800.0, 600.0); + grid(2).store(&mut d); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let g = PerspectiveGrid::effective(&d); + let mut t = PerspectiveGridTool::default(); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "no drag: Escape is the shortcut's"); + let widget = g.plane_widgets()[0].1; + for (what, at, begin) in [("handle", Point::new(g.vp_left, g.horizon), "Edit Perspective Grid"), ("plane", widget, "Move Plane")] { + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)), vec![Action::Begin(begin.into())], "{what}"); + assert!(!t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x - 40.0, at.y + 10.0)).is_empty(), "{what}"); + assert!(t.key(&c, ToolKey::Enter, Mods::default()).is_empty(), "{what}: Enter leaves the drag alone"); + assert!(t.busy(), "{what}"); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}"); + assert!(!t.busy(), "{what}"); + // The rest of the drag does nothing. + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x - 60.0, at.y)).is_empty(), "{what}"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, at.x - 60.0, at.y)).is_empty(), "{what}"); + } + } + #[test] fn depth_moves_along_the_plane_normal() { // A grid with a viewing angle foreshortens each axis by its own distance. @@ -1186,6 +1209,41 @@ mod tests { assert!(!t.busy()); } + #[test] + fn escape_cancels_a_perspective_selection_drag() { + let (mut d, id) = doc_with_rect(); + grid(2).store(&mut d); + set_attachment(d.node_mut(id).unwrap(), Plane::Right, 0.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let g = PerspectiveGrid::current(&d); + let (_, _, rect) = g.plane_bounds(&d, &[id]).unwrap(); + let h = g.homography(Plane::Right).unwrap(); + let (corner, mid) = (h.apply(Point::new(rect.x1, rect.y1)).unwrap(), h.apply(rect.center()).unwrap()); + let widget = g.plane_widgets()[0].1; + let mut t = PerspectiveSelectionTool::default(); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "no drag: Escape is the shortcut's"); + for (what, at) in [("resize", corner), ("move", mid), ("plane", widget)] { + t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 10.0)); + assert!(acts.iter().any(|a| matches!(a, Action::Preview(..))), "{what}: {acts:?}"); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}"); + assert!(!t.busy(), "{what}"); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { .. })), "{what}: the measurement goes"); + // The rest of the drag does nothing. + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 40.0, at.y)).is_empty(), "{what}"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, at.x + 40.0, at.y)).is_empty(), "{what}"); + } + // A press that has not moved past the drag threshold has nothing to cancel. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, mid.x, mid.y)); + assert!(t.busy()); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty()); + assert!(!t.busy()); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, mid.x + 20.0, mid.y)).is_empty(), "no move begins"); + } + #[test] fn resize_cursors_follow_the_handle_on_screen() { assert_eq!(resize_cursor(Vec2::new(1.0, 0.1)), Cursor::ResizeH); diff --git a/crates/tools/src/distort/perspective/tools.rs b/crates/tools/src/distort/perspective/tools.rs index 07df302ff..541e7a8f9 100644 --- a/crates/tools/src/distort/perspective/tools.rs +++ b/crates/tools/src/distort/perspective/tools.rs @@ -92,6 +92,15 @@ impl Tool for PerspectiveGridTool { } .turned(cx.view_angle()) } + /// Escape during a handle or plane widget drag cancels it: the grid and the plane go back to + /// the start of the drag. + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key != ToolKey::Escape || !self.busy() { + return vec![]; + } + (self.drag, self.plane) = (None, None); + vec![Action::Cancel] + } fn busy(&self) -> bool { self.drag.is_some() || self.plane.is_some() } @@ -270,7 +279,16 @@ impl Tool for PerspectiveSelectionTool { } } } + /// Escape during a move, a resize or a plane widget drag cancels it: the objects and the plane + /// go back to the start of the drag. fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + if key == ToolKey::Escape && self.busy() { + self.measure = None; + return match std::mem::take(&mut self.drag) { + SelDrag::Move { began: false, .. } => vec![], + _ => vec![Action::Cancel], + }; + } if let (ToolKey::Digit(5), SelDrag::Move { perp, began, .. }) = (key, &mut self.drag) { *perp = !*perp; return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] }; diff --git a/crates/tools/src/distort/puppet.rs b/crates/tools/src/distort/puppet.rs index 9c4b11f91..f14137838 100644 --- a/crates/tools/src/distort/puppet.rs +++ b/crates/tools/src/distort/puppet.rs @@ -5,7 +5,8 @@ //! adds a pin and selects it; Shift-click adds pins to the selection (or takes them out); dragging //! a pin moves every selected pin and warps the art; Alt-dragging near (not on) a selected pin //! turns the art around it, and that pin keeps the turn; Delete/Backspace remove the selected pins. -//! A click off the mesh places nothing and says so. +//! A click off the mesh places nothing and says so. Escape during a drag cancels it: the art, its +//! pins and the selected pins go back to how the press found them. //! //! The pins live in the document ([`PuppetPins`]) with the rest shape they started from, so every //! warp starts from that shape (dragging a pin back restores the original, warps don't stack) and @@ -224,6 +225,8 @@ pub struct PuppetWarpTool { /// Selected pins (indices into the pins). selected: Vec, drag: Option, + /// The selected pins before the press that began the drag (Escape puts them back). + before_drag: Vec, /// The pins as the drag began (each preview starts from them). start: Option>, /// The pointer and the modifiers held while hovering (for the turn ring). @@ -245,6 +248,7 @@ impl Default for PuppetWarpTool { ids: vec![], selected: vec![], drag: None, + before_drag: vec![], start: None, hover: None, off_mesh: None, @@ -368,14 +372,17 @@ impl Tool for PuppetWarpTool { && let Some(q) = set.pins.get(i) { self.drag = Some(Gesture::Turn { pin: i, base: set.turn_of(i), from: (p - q.at).atan2(), angle: 0.0 }); + self.before_drag = self.selected.clone(); self.start = Some(c.clone()); return vec![Action::Begin("Puppet Warp".into())]; } if let Some(i) = Self::pin_at(cx, set, p) { + let before = self.selected.clone(); if !self.pick(i, ev.mods.shift) { return vec![]; } self.drag = Some(Gesture::Move { from: p, to: p }); + self.before_drag = before; self.start = Some(c.clone()); return vec![Action::Begin("Puppet Warp".into())]; } @@ -426,7 +433,14 @@ impl Tool for PuppetWarpTool { && cx.selection.objects == self.ids && self.pins(cx).is_some_and(|c| self.selected.iter().any(|i| *i < c.set.pins.len())) } + /// Escape during a pin drag or a turn cancels it: the art and its pins go back to the start of + /// the drag, and the pins selected before the press are selected again. fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape && self.drag.take().is_some() { + self.start = None; + self.selected = std::mem::take(&mut self.before_drag); + return vec![Action::Cancel, Action::Notify("pins".into())]; + } if !self.claims_key(cx, key) { return vec![]; } @@ -572,6 +586,11 @@ mod tests { self.run(p); } Action::Commit => self.base = None, + Action::Cancel => { + if let Some(b) = self.base.take() { + self.doc = b; + } + } Action::Exec(c, p) if c == "object.puppetWarp" => self.run(p), Action::Notify(w) => { let p = paint(); @@ -678,6 +697,32 @@ mod tests { assert_eq!(r.bounds(), before); } + #[test] + fn escape_cancels_a_pin_drag_or_a_turn_and_selects_the_pins_selected_before_it() { + let mut r = Rig::new(); + r.click(110.0, 110.0, Mods::default()); + r.click(190.0, 110.0, Mods::default()); + let b = r.pins().len() - 1; + assert_eq!(r.tool.selected, vec![b]); + assert!(r.key(ToolKey::Escape).is_empty(), "no drag: Escape is the shortcut's"); + let (pins, art) = (r.pins(), r.doc.node(r.sel.objects[0]).cloned().unwrap()); + // A press on the unselected pin selects it and drags it; Escape puts everything back. + for (what, press, to, mods) in [("move", (110.0, 110.0), (130.0, 140.0), Mods::default()), ("turn", (205.0, 110.0), (190.0, 125.0), alt())] { + assert_eq!(r.ev(PointerKind::Down, press.0, press.1, mods), vec![Action::Begin("Puppet Warp".into())], "{what}"); + r.ev(PointerKind::Drag, to.0, to.1, mods); + assert_ne!(r.doc.node(r.sel.objects[0]), Some(&art), "{what}: the drag warps the art"); + assert_eq!(r.key(ToolKey::Escape), vec![Action::Cancel, Action::Notify("pins".into())], "{what}"); + assert_eq!(r.doc.node(r.sel.objects[0]), Some(&art), "{what}: Escape puts the art back"); + assert_eq!(r.pins(), pins, "{what}: and the pins"); + assert_eq!(r.tool.selected, vec![b], "{what}: and the pins selected before the press"); + assert!(!r.tool.busy(), "{what}"); + // The rest of the drag does nothing. + assert!(r.ev(PointerKind::Drag, to.0 + 10.0, to.1, mods).is_empty(), "{what}"); + assert!(r.ev(PointerKind::Up, to.0 + 10.0, to.1, mods).is_empty(), "{what}"); + assert_eq!(r.pins(), pins, "{what}"); + } + } + #[test] fn alt_drag_near_a_selected_pin_turns_the_art_around_it() { let mut r = Rig::new(); diff --git a/crates/ui-egui/src/tests/distortkeys.rs b/crates/ui-egui/src/tests/distortkeys.rs index 42e870b8b..63edd0466 100644 --- a/crates/ui-egui/src/tests/distortkeys.rs +++ b/crates/ui-egui/src/tests/distortkeys.rs @@ -133,6 +133,47 @@ fn delete_and_backspace_remove_the_selected_pin_then_clear() { } } +#[test] +fn escape_cancels_a_puppet_warp_perspective_or_liquify_drag() { + let grid = |app: &VectorcraftApp| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&app.session.doc().unwrap().doc); + for tool in ["puppetWarp", "perspectiveGrid", "perspectiveSelection", "warp"] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool(tool); + // Where the drag starts: a new pin, a vanishing point, a plane widget, the art's right edge. + let from = match tool { + "puppetWarp" => { + click(&mut app, 112.0, 188.0); + (112.0, 188.0) + } + "perspectiveGrid" => (grid(&app).vp_left, grid(&app).horizon), + "perspectiveSelection" => { + let q = grid(&app).plane_widgets()[0].1; + (q.x, q.y) + } + _ => (290.0, 150.0), + }; + let st = app.session.doc().unwrap(); + let (before, undo) = (st.doc.clone(), st.history.undo.len()); + let view = app.view_info(); + for (kind, x, y) in [(PointerKind::Down, from.0, from.1), (PointerKind::Drag, from.0 + 30.0, from.1 - 20.0)] { + app.session.pointer(&PointerEvent::new(kind, x, y), view).unwrap(); + } + assert!(app.session.tool_busy(), "{tool}"); + assert_ne!(*app.session.doc().unwrap().doc, *before, "{tool}: the drag changes the preview"); + press(&mut app, Key::Escape); + assert_eq!(*app.session.doc().unwrap().doc, *before, "{tool}: Escape cancels the drag"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "{tool}: and adds no undo step"); + assert!(!app.session.tool_busy(), "{tool}"); + assert_eq!(app.session.tool_id(), tool, "{tool}: Escape keeps the tool"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, from.0 + 30.0, from.1 - 20.0), view).unwrap(); + assert_eq!(*app.session.doc().unwrap().doc, *before, "{tool}: the release changes nothing"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "{tool}"); + } +} + fn artboard_keys_app(boards: usize) -> (VectorcraftApp, u64) { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 400, "height": 400, "artboards": boards})).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 749f56763..6bf025ee3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3018,7 +3018,9 @@ profile, which runs along every subpath (the Width tool shows each subpath's poi `object.puppetWarp {ids?, pins, moved, angles?, expand?}` warps art as rigidly as possible so that each pin (a point on the art) lands on its `moved` point. `angles` (degrees, or null for a free pin; as long as `pins`) holds the turn the art takes around a pin, as Alt-dragging near a selected pin with the Puppet Warp tool does: one pin with -`angles: [90]` turns the art a quarter turn around it. +`angles: [90]` turns the art a quarter turn around it. `press_key` Escape between two `pointer_gesture` calls of a +pin drag or a turn cancels it: the art, its pins and the selected pins go back to how the press found them, and no +undo step is added. The Puppet Warp tool's pins live in the document while the same art stays selected (never saved; Undo and Redo take them back with the art). `object.puppetWarp.pins {ids?}` reads them: `pins` (where each sits on the shape the pins @@ -5097,7 +5099,8 @@ horizon; with Lock Station Point the other one swings), the horizon, the third v and right ground-level points (24 px from the origin along each wall's ground line: they move the whole grid; Shift keeps one axis), the left and right extents (separate: `extent` and `extentRight`; Alt drags both, Shift goes by whole cells), the vertical extent and the cell size widget (on the line where the planes meet, a cell up, or more -cells while they're small) reshape the grid; Lock Grid stops them. The Plane Switching Widget stays put in a corner +cells while they're small) reshape the grid; Lock Grid stops them. `press_key` Escape during a drag of one of them, +or of a plane widget, cancels it and puts the grid back. The Plane Switching Widget stays put in a corner of the document window (headless: the first artboard's top-left corner); a press on it picks the plane with any tool while the grid shows (choosing a perspective tool shows it, and `perspective.grid.show` hides it again with the tool still chosen), and the keys 1–4 (`key` with `"1"`…`"4"`) pick the left, horizontal, right and no plane. @@ -5150,7 +5153,8 @@ Brush Dimensions shared by the seven tools (`width`, `height` in pt, `angle`, `i `showBrush`) and the tool's own: `detail` (1..10); `simplify` (0..100) with `simplifyOn` (Warp, Twirl, Pucker, Bloat); `rate` (Twirl, -180..180°); `complexity` (0..15) and `affectAnchors`, `affectIn`, `affectOut` (Scallop, Crystallize, Wrinkle: what the brush moves); `horizontal`, `vertical` (Wrinkle, 0..1). Alt-drag sizes the brush from -its current size (with Shift too it keeps its proportions). With Use Pressure Pen on, each `pointer_gesture` event's +its current size (with Shift too it keeps its proportions). `press_key` Escape during a stroke cancels it and adds no +undo step; during an Alt-drag it puts the brush size back. With Use Pressure Pen on, each `pointer_gesture` event's `pressure` (0..1) is the intensity there, and `object.liquify` takes points as `[x, y, pressure]`. ```json @@ -5190,7 +5194,8 @@ distance along the plane's normal), so copies, duplicates and pastes stay attach `perspectiveSelection` tool, a drag moves the selection within its plane (`perspective.move`; Alt copies), the bounding-box handles scale it in plane space (`perspective.transform`; Shift keeps proportions, Alt scales about the centre), and `press_key` `5` during a drag switches the move to perpendicular to the plane (press again to switch -back). The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt +back). `press_key` Escape during a move, a resize or a plane widget drag cancels it and puts the objects and the +grid back. The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt copies). Object › Transform › Transform Again repeats the last perspective move or scale in plane space. ```json diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5322a7794..aab69f52e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -516,6 +516,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Artboard background colors:** each artboard can have a background color (`artboard.setProps {index, color}`, `color: null` removes it). With the Artboard tool, Properties has a Fill row: its list sets None, White or Black, and its chip and its Color Picker… item open the Color Picker for the artboard. Artboard Options has a Background Color row with a list that sets None, White or Black, and a color button. The canvas draws the artboard's paper in the color, multiplied with the paper color while Simulate Colored Paper is on, and the transparency grid still covers it. The native format saves the color, and a document without colors saves as before. Exports and print do not draw the color. Engine, canvas, Properties, Color Picker and Artboard Options tests cover it. Public reference: [Apply colors to artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/add-background-color-to-artboards.html). - **Resizing several artboards:** with several artboards selected with the Artboard tool, a new W or H in Properties, the Transform panel or the Control bar, a preset or an orientation in Properties or the Control bar, and a new Width or Height in Artboard Options resize every selected artboard in one undo step (`artboard.setProps {artboards}`); with Scale Artwork with Artboard on, Properties, the Transform panel and the Control bar scale each object once, with the artboard it was inside before the change. The Transform panel, the Control bar and Artboard Options resize each one about its own reference point, a preset sizes each one in its own orientation, and X and Y still move the active artboard alone. Pointer and key tests in `panels/properties.rs`, dialog tests in `dialogs/tests.rs` and an engine test in `tests/xform.rs` cover them. Public references: [Artboard Enhancements In Illustrator CC](https://blog.adobe.com/en/publish/2017/11/10/artboard-enhancements-in-illustrator-cc) (a height and a width entered once resize every selected artboard) and [Using multiple artboards](https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (2019). - **Artboard Escape routing:** Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. +- **Escape in the distort tools:** Escape during a Puppet Warp pin drag or turn, a Perspective Grid tool handle or plane widget drag, a Perspective Selection move, resize or plane widget drag, or a Liquify stroke cancels it: the art goes back to how the press found it, no undo step is added, and the tool stays chosen. Puppet Warp also selects again the pins selected before the press, and Escape during a Liquify Alt-drag puts the brush size back. Regression tests: `escape_cancels_*` in `crates/tools/src/distort/puppet.rs`, `perspective.rs` and `liquify.rs`, and `escape_cancels_a_puppet_warp_perspective_or_liquify_drag` in `tests/distortkeys.rs`. - **Shift with the Artboard tool:** a Shift-click adds an artboard to the selection or takes it out when the button comes up. A Shift-drag inside an artboard draws a new artboard there, and a Shift-drag on the pasteboard is a marquee that toggles the artboards it touches, as the [default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (a Shift-drag draws an artboard within an existing one) and [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy) describe. Shift no longer keeps an artboard move at 45° steps or a drawn artboard square. Escape during a marquee or while a new artboard is being drawn cancels it and keeps the Artboard tool. Pointer and key tests in `crates/tools/src/xform/artboard.rs` cover the four gestures and Escape. - **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests/distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo. - **Locked artboards:** a click in the Artboards panel's lock column locks or unlocks an artboard in one undo step (`artboard.setProps {index, locked}`), and the native format saves the lock. The Artboard tool doesn't move, copy, resize or delete a locked artboard, Rearrange and Fit to Artwork Bounds leave it as it is, and its name isn't edited on the canvas. The art on it (each object whose bounds overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, `select.set`, `select.all` and the tools pass over it, and commands that act on the selection leave it; art drawn or pasted onto it stays selected and editable until the selection leaves it out. Tests in `crates/doc/src/lib.rs`, `tests/panelcmds.rs`, `xform/artboard.rs`, `panels/artboards.rs` and `canvas.rs` cover them. Public references: [Lock artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/lock-artboards.html) and [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/).