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