Merge pull request #5762 from dotCooCoo/fix/pointer-backquote-boolean

MCP: pointer_gesture rejects a backquote that is not a boolean
This commit is contained in:
BFlat
2026-11-05 14:25:47 +07:00
committed by GitHub
7 changed files with 84 additions and 5 deletions
+3 -3
View File
@@ -181,9 +181,11 @@ impl Headless {
fn pointer(&mut self, p: &Value) -> Result<Value, String> {
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::<Result<Vec<_>, _>>()?;
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),
+28
View File
@@ -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::<Value>(&text_of(&made)).unwrap()["id"].as_u64().unwrap();
let drag = |top: Option<Value>, first: Option<Value>| {
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::<Value>(&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<_>>(), 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]
+6
View File
@@ -125,6 +125,12 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
if !e.get("x").is_some_and(Value::is_number) || !e.get("y").is_some_and(Value::is_number) {
return Err(format!("event {i}: numeric `x` and `y` required"));
}
if e.get("backquote").is_some_and(|v| !v.is_boolean()) {
return Err(format!("event {i}: `backquote` must be a boolean"));
}
}
if a.get("backquote").is_some_and(|v| !v.is_boolean()) {
return Err("`backquote` must be a boolean".into());
}
if let Some(t) = a.get("tool").and_then(Value::as_str) {
b.call("ui.tool.select", json!({"tool": t}))?;
+16
View File
@@ -104,6 +104,11 @@ impl PointerEvent {
pub fn json_hold(e: &Value) -> 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<bool, String> {
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::<PointerEvent>(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.
+7 -2
View File
@@ -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::<Result<Vec<_>, _>>());
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 {
+23
View File
@@ -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]
+1
View File
@@ -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`. |