From e9be911c9b8d1f2315126fc7c9fbf26ede529d43 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 22:41:09 -0800 Subject: [PATCH] MCP: pointer_gesture rejects a backquote that is not a boolean A `backquote` given as a string or a number, on the request or on an event, was read as absent, so `"true"` moved the object's geometry instead of its pattern. `pointer_gesture` and `ui.pointer`, headless and desktop, now answer with an error before any event runs. --- crates/mcp/src/headless.rs | 6 +++--- crates/mcp/src/tests.rs | 28 ++++++++++++++++++++++++++++ crates/mcp/src/tools/dispatch.rs | 6 ++++++ crates/tools/src/input.rs | 16 ++++++++++++++++ crates/ui-egui/src/control.rs | 9 +++++++-- crates/ui-egui/src/control/tests.rs | 23 +++++++++++++++++++++++ docs/mcp.md | 1 + 7 files changed, 84 insertions(+), 5 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 64a7f0ea1..0a81bb127 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -181,9 +181,11 @@ impl Headless { fn pointer(&mut self, p: &Value) -> Result { let events = p.get("events").and_then(Value::as_array).ok_or("missing `events`")?; let base: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); + let default = PointerEvent::json_backquote(p, false)?; + let held = events.iter().map(|e| PointerEvent::json_backquote(e, default)).collect::, _>>()?; self.ensure_document(); let mut requests = vec![]; - for e in events { + for (e, backquote) in events.iter().zip(held) { let kind = s(e, "kind").unwrap_or(""); let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; if s(e, "space") == Some("screen") { @@ -192,8 +194,6 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); - let backquote = - e.get("backquote").and_then(Value::as_bool).unwrap_or_else(|| p.get("backquote").and_then(Value::as_bool).unwrap_or(false)); let ev = PointerEvent { kind, pos: Point::new(x, y), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 2e153b62b..90df3349e 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1431,6 +1431,34 @@ fn pointer_gesture_forwards_backquote_on_each_event() { assert_eq!(rect["bounds"]["x"], 100.0, "{doc}"); } +/// A `backquote` that is not a boolean is an error, and none of the gesture's events runs. +#[test] +fn pointer_gesture_rejects_a_backquote_that_is_not_a_boolean() { + let mut s = server(); + let made = call(&mut s, 1, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 50, "height": 50, "fill": "#ff0000"})); + let id = serde_json::from_str::(&text_of(&made)).unwrap()["id"].as_u64().unwrap(); + let drag = |top: Option, first: Option| { + let mut down = json!({"kind": "down", "x": 300, "y": 300}); + if let Some(b) = first { + down["backquote"] = b; + } + let mut a = json!({"tool": "rectangle", "events": [down, {"kind": "drag", "x": 350, "y": 350}, {"kind": "up", "x": 350, "y": 350}]}); + if let Some(b) = top { + a["backquote"] = b; + } + a + }; + for (n, args) in [drag(Some(json!("true")), None), drag(None, Some(json!(1)))].into_iter().enumerate() { + let r = call(&mut s, 2 + n as u64, "pointer_gesture", args); + assert_eq!(r["isError"], true, "{r}"); + assert!(text_of(&r).contains("`backquote` must be a boolean"), "{r}"); + } + let inspected = call(&mut s, 4, "inspect_document", json!({})); + let doc = serde_json::from_str::(&text_of(&inspected)).unwrap(); + let children = doc["layers"][0]["children"].as_array().unwrap(); + assert_eq!(children.iter().map(|n| n["id"].as_u64()).collect::>(), vec![Some(id)], "{doc}"); +} + /// A Shift-drag marquee reaches agents (#483): `pointer_gesture` with `mods.shift` toggles the /// objects it reaches, so a selected one leaves the selection and an unselected one joins it. #[test] diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index 9e3af3dba..f9d768eaa 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -125,6 +125,12 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result f64 { e.get("holdMs").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(0.0, |ms| ms.clamp(0.0, 60_000.0) / 1000.0) } + /// Whether backquote is held, from the `backquote` of a JSON pointer event or request (control + /// channel, MCP): `default` when the key is absent, an error when it is not a boolean. + pub fn json_backquote(v: &Value, default: bool) -> Result { + v.get("backquote").map_or(Ok(default), |b| b.as_bool().ok_or_else(|| "`backquote` must be a boolean".to_owned())) + } } #[cfg(test)] @@ -119,6 +124,17 @@ mod tests { value["backquote"] = serde_json::json!(true); assert!(serde_json::from_value::(value).unwrap().backquote); } + + #[test] + fn json_backquote_takes_the_default_only_when_absent() { + use serde_json::json; + assert_eq!(PointerEvent::json_backquote(&json!({}), true), Ok(true)); + assert_eq!(PointerEvent::json_backquote(&json!({"backquote": false}), true), Ok(false)); + assert_eq!(PointerEvent::json_backquote(&json!({"backquote": true}), false), Ok(true)); + for bad in [json!("true"), json!(1), json!(null)] { + assert!(PointerEvent::json_backquote(&json!({"backquote": bad}), false).is_err(), "{bad}"); + } + } } /// Keys tools care about. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 045ad9732..3734be480 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -540,7 +540,13 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); - for e in events { + let held = + PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::, _>>()); + let held = match held { + Ok(held) => held, + Err(e) => return err(e), + }; + for (e, backquote) in events.iter().zip(held) { let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { "down" => PointerKind::Down, "drag" => PointerKind::Drag, @@ -566,7 +572,6 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe Some(xf) if screen => crate::canvas::pose_on_page(q, &xf), _ => q, }); - let backquote = e.get("backquote").and_then(Value::as_bool).unwrap_or_else(|| p.get("backquote").and_then(Value::as_bool).unwrap_or(false)); crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, backquote, pressure: PointerEvent::json_pressure(e), pen }, view); let hold = PointerEvent::json_hold(e); if hold > 0.0 { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index d084d7ee0..e587c0489 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -293,6 +293,29 @@ fn synthetic_pointer_backquote_keeps_solid_object_in_place() { assert_eq!(xf.as_coeffs()[4], 20.0, "synthetic backquote drag changes pattern paint"); } +/// `ui.pointer` rejects a `backquote` that is not a boolean, given for the request or for any +/// event, before any of the events runs. +#[test] +fn synthetic_pointer_rejects_a_backquote_that_is_not_a_boolean() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert_eq!(input_request(&mut app, "ui.tool.select", json!({"tool": "selection"}))["ok"], true); + let before = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let down = json!({"kind": "down", "x": 125, "y": 125}); + let drag = json!({"kind": "drag", "x": 145, "y": 125}); + for params in [ + json!({"events": [down, drag, {"kind": "up", "x": 145, "y": 125}], "backquote": "true"}), + json!({"events": [down, drag, {"kind": "up", "x": 145, "y": 125, "backquote": 1}]}), + ] { + let r = input_request(&mut app, "ui.pointer", params.clone()); + assert_eq!(r["ok"], false, "{params}: {r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("`backquote` must be a boolean")), "{r}"); + assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(), &before, "{params}"); + } +} + /// `ui.pointer` replies with the key object: a click on one of two selected objects makes it /// the key, and a click on the key again lets it go. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index fcbb492fd..da1c1658e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -426,6 +426,7 @@ opens the picker through `run_command` with the remote backend. | `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | +| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. | | `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. | | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | | `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. |