Merge pull request #4421 from dotCooCoo/fix/escape-cancels-distort-tool-drags

Distort tools: Escape cancels a Puppet Warp, perspective or Liquify drag
This commit is contained in:
BFlat
2026-10-29 11:01:42 +07:00
committed by GitHub
7 changed files with 234 additions and 7 deletions
+61 -2
View File
@@ -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<Action> {
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}");
}
}
}
+58
View File
@@ -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);
@@ -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<Action> {
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<Action> {
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![] };
+46 -1
View File
@@ -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<usize>,
drag: Option<Gesture>,
/// The selected pins before the press that began the drag (Escape puts them back).
before_drag: Vec<usize>,
/// The pins as the drag began (each preview starts from them).
start: Option<Arc<Cached>>,
/// 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<Action> {
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();
+41
View File
@@ -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();
+9 -4
View File
@@ -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
+1
View File
@@ -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/).