Merge pull request #180 from storytold/m8-delete-in-tools

M8.1: Delete/Backspace remove the selected width points and Puppet Warp pins
This commit is contained in:
BFlat 2026-10-06 09:40:10 +07:00 committed by GitHub
commit fe7da442ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 177 additions and 1 deletions

View File

@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush) | 8–12 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art) | 8–12 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |

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}}}