From 5d21826fa8c1e18ebaf8e3cebceb5ae89c1355e5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 09:23:48 +0700 Subject: [PATCH 1/2] 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 --- crates/mcp/src/headless.rs | 5 ++ crates/mcp/src/lib.rs | 2 + crates/mcp/src/tests_distortkeys.rs | 66 ++++++++++++++++++++ crates/tools/src/distort/puppet.rs | 8 +++ crates/tools/src/distort/width.rs | 7 +++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_distortkeys.rs | 83 +++++++++++++++++++++++++ docs/mcp.md | 3 + 8 files changed, 176 insertions(+) create mode 100644 crates/mcp/src/tests_distortkeys.rs create mode 100644 crates/ui-egui/src/tests_distortkeys.rs diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 69ac0e41..eb906d43 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -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())?; diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 39807d6c..db68aaf2 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -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; diff --git a/crates/mcp/src/tests_distortkeys.rs b/crates/mcp/src/tests_distortkeys.rs new file mode 100644 index 00000000..275831b9 --- /dev/null +++ b/crates/mcp/src/tests_distortkeys.rs @@ -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"); + } +} diff --git a/crates/tools/src/distort/puppet.rs b/crates/tools/src/distort/puppet.rs index 575e2969..c10d9da9 100644 --- a/crates/tools/src/distort/puppet.rs +++ b/crates/tools/src/distort/puppet.rs @@ -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 { if matches!(key, ToolKey::Delete | ToolKey::Backspace) && let Some(i) = self.selected.take() diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs index 4fa59e2e..7a5207e0 100644 --- a/crates/tools/src/distort/width.rs +++ b/crates/tools/src/distort/width.rs @@ -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 { if !matches!(key, ToolKey::Delete | ToolKey::Backspace) { return vec![]; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 104d0d6c..8d36c58b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/tests_distortkeys.rs b/crates/ui-egui/src/tests_distortkeys.rs new file mode 100644 index 00000000..5513d81c --- /dev/null +++ b/crates/ui-egui/src/tests_distortkeys.rs @@ -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"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 1a7486f2..d8b56e43 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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}}} From 37c82d082e85ed9a5397928823343f2fe3d1b0ec Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 09:39:58 +0700 Subject: [PATCH 2/2] ROADMAP: M8.1 landed Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a52f8c8e..7305a8e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 ``/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 |