M8.1: Delete/Backspace remove the selected width points and Puppet Warp pins

The Width and Puppet Warp tools now claim Delete and Backspace while they
have width points or pins selected, so the shortcut routing (desktop and MCP
press_key) removes those instead of clearing the artwork. With nothing of the
tool's selected the keys still clear the selected objects. Headless Backspace
clears the selection like the desktop app does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-06 09:23:48 +07:00
parent a05e251622
commit 5d21826fa8
8 changed files with 176 additions and 0 deletions

View File

@ -197,6 +197,11 @@ impl Headless {
self.session.select_tool(t.id, self.view).map_err(|e| e.to_string())?;
return Ok(json!({"handledBy": "tool.select", "tool": t.id}));
}
// Backspace clears the selection as Delete does (as in the desktop app).
if tk == Some(ToolKey::Backspace) && mods == Mods::default() && self.session.active().is_some_and(|d| !d.selection.is_empty()) {
let r = self.exec("edit.clear", &json!({}))?;
return Ok(json!({"handledBy": "command", "command": "edit.clear", "result": r}));
}
if let Some(k) = tk {
let mut out = vec![];
let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?;

View File

@ -30,6 +30,8 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979";
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_distortkeys;
#[cfg(test)]
mod tests_exportas;
#[cfg(test)]
mod tests_fileio;

View File

@ -0,0 +1,66 @@
//! `press_key` with the Width and Puppet Warp tools: Delete and Backspace reach the selected width
//! point or pin, and clear the selected art when the tool has nothing selected.
use serde_json::{Value, json};
use crate::{Backend, Headless, call_tool};
fn ok(h: &mut Headless, name: &str, args: Value) -> Value {
let r = call_tool(h, name, &args);
assert!(!r.is_error, "{name} {args}: {r:?}");
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null)
}
fn run(h: &mut Headless, command: &str, params: Value) -> Value {
ok(h, "run_command", json!({"command": command, "params": params}))
}
fn exists(h: &Headless, id: u64) -> bool {
h.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).is_some()
}
fn click(h: &mut Headless, tool: &str, x: f64, y: f64) {
ok(h, "pointer_gesture", json!({"tool": tool, "events": [{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]}));
}
fn session() -> Headless {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 400, "height": 400}})).unwrap();
h
}
#[test]
fn delete_and_backspace_reach_the_width_tool() {
for key in ["Delete", "Backspace"] {
let mut h = session();
let id = run(&mut h, "shape.line", json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200}))["id"].as_u64().unwrap();
run(&mut h, "stroke.set", json!({"weight": 10}));
run(&mut h, "stroke.widthPoint.set", json!({"id": id, "t": 0.5, "left": 12, "right": 12}));
click(&mut h, "width", 200.0, 200.0);
let r = ok(&mut h, "press_key", json!({"key": key}));
assert_eq!(r["handledBy"], "tool", "{key}: {r}");
let n = h.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.stroke().unwrap().profile.clone().unwrap();
assert_eq!(n.points.len(), 2, "{key} removed the width point");
// With no width point selected the key clears the selected line.
ok(&mut h, "press_key", json!({"key": key}));
assert!(!exists(&h, id), "{key} cleared the line");
}
}
#[test]
fn delete_and_backspace_reach_the_puppet_warp_tool() {
for key in ["Delete", "Backspace"] {
let mut h = session();
let id = run(&mut h, "shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100}))["id"].as_u64().unwrap();
ok(&mut h, "select_tool", json!({"tool": "puppetWarp"}));
let pins = |h: &Headless| h.session.tool_options()["pins"].as_array().map_or(0, Vec::len);
click(&mut h, "puppetWarp", 112.0, 188.0);
let n = pins(&h);
let r = ok(&mut h, "press_key", json!({"key": key}));
assert_eq!(r["handledBy"], "tool", "{key}: {r}");
assert!(exists(&h, id), "{key} kept the art");
assert_eq!(pins(&h), n - 1, "{key} removed the pin");
ok(&mut h, "press_key", json!({"key": key}));
assert!(!exists(&h, id), "{key} cleared the art");
}
}

View File

@ -131,6 +131,14 @@ impl Tool for PuppetWarpTool {
_ => vec![],
}
}
/// Delete/Backspace remove the selected pin; with none selected they are the shortcut's
/// (Clear deletes the selected objects).
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Delete | ToolKey::Backspace)
&& self.drag.is_none()
&& cx.selection.objects == self.ids
&& self.selected.is_some_and(|i| i < self.pins.len())
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if matches!(key, ToolKey::Delete | ToolKey::Backspace)
&& let Some(i) = self.selected.take()

View File

@ -349,6 +349,13 @@ impl Tool for WidthTool {
},
}
}
/// Delete/Backspace remove the selected width points; with none selected they are the
/// shortcut's (Clear deletes the selected objects).
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Delete | ToolKey::Backspace)
&& self.drag.is_none()
&& self.selected.as_ref().is_some_and(|(id, _)| !self.selected_indices(cx, *id).is_empty())
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if !matches!(key, ToolKey::Delete | ToolKey::Backspace) {
return vec![];

View File

@ -49,6 +49,8 @@ mod tests_clipboard;
#[cfg(test)]
mod tests_cut;
#[cfg(test)]
mod tests_distortkeys;
#[cfg(test)]
mod tests_docsetup;
#[cfg(test)]
mod tests_fonts;

View File

@ -0,0 +1,83 @@
//! Delete and Backspace with the Width and Puppet Warp tools, through the shortcut routing: the
//! selected width point or pin goes, and with nothing of the tool's selected the keys clear the
//! selected art as usual.
use egui::{Key, Modifiers};
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::VectorcraftApp;
/// A 300×300 document with a selected 10 pt line from (100, 200) to (300, 200) → its id.
fn app_with_line() -> (VectorcraftApp, u64) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
let id = app.run("shape.line", json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200})).unwrap()["id"].as_u64().unwrap();
app.run("stroke.set", json!({"weight": 10})).unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
(app, id)
}
/// 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();
}
fn click(app: &mut VectorcraftApp, x: f64, y: f64) {
let v = app.view_info();
for kind in [PointerKind::Down, PointerKind::Up] {
app.session.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
}
}
fn exists(app: &VectorcraftApp, id: u64) -> bool {
app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).is_some()
}
fn width_points(app: &VectorcraftApp, id: u64) -> Value {
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).cloned().unwrap();
json!(n.appearance.stroke().unwrap().profile.as_ref().map(|p| p.points.len()))
}
#[test]
fn delete_and_backspace_remove_the_selected_width_point_then_clear() {
for key in [Key::Delete, Key::Backspace] {
let (mut app, id) = app_with_line();
app.run("stroke.widthPoint.set", json!({"id": id, "t": 0.5, "left": 12, "right": 12})).unwrap();
assert_eq!(width_points(&app, id), json!(3));
app.select_tool("width");
// A click on the width point's centre selects it; the key removes it, not the line.
click(&mut app, 200.0, 200.0);
press(&mut app, key);
assert!(exists(&app, id), "{key:?} kept the line");
assert_eq!(width_points(&app, id), json!(2), "{key:?} removed the width point");
// Nothing of the tool's is selected now: the key clears the selected line.
press(&mut app, key);
assert!(!exists(&app, id), "{key:?} with no width point selected clears the selection");
}
}
#[test]
fn delete_and_backspace_remove_the_selected_pin_then_clear() {
for key in [Key::Delete, Key::Backspace] {
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("puppetWarp");
let pins = |app: &VectorcraftApp| app.session.tool_options()["pins"].as_array().map_or(0, Vec::len);
// A click on the art away from the automatic pins adds a pin and selects it.
click(&mut app, 112.0, 188.0);
let n = pins(&app);
assert!(n > 1, "{n} pins");
press(&mut app, key);
assert!(exists(&app, id), "{key:?} kept the art");
assert_eq!(pins(&app), n - 1, "{key:?} removed the pin");
press(&mut app, key);
assert!(!exists(&app, id), "{key:?} with no pin selected clears the selection");
}
}

View File

@ -870,6 +870,9 @@ two points at the same `t` (the width before it, then after it), and the stroke'
`document.inspect` reports a path's `strokeOptions.widthPoints` (`[t, left, right]`, factors of half the weight; null
for a uniform stroke). `stroke.widthPoint.remove {id, index | indices}` deletes points; `stroke.widthProfile.set {ids,
points}` replaces them all, as dragging several Shift-selected points with the Width tool does in one undo step.
With the Width tool, `press_key` Delete or Backspace removes the selected width points (`handledBy: "tool"`); with none
selected the key clears the selected objects as usual (`edit.clear`). The Puppet Warp tool's selected pins go the
same way.
```json
{"name":"run_command","arguments":{"command":"stroke.widthPoint.set","params":{"id":9,"t":0.5,"left":4,"right":4}}}