mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 14:46:03 +00:00
Commands: pointer packets are read whole before any event runs
A headless or control channel pointer packet now checks every event's kind, coordinates and mods before it sends any of them, so a malformed later event no longer leaves the earlier ones applied. A mods value that is not an object, such as an array, is refused. Rotate View takes null mods as omitted. The MCP docs name the params that refuse null.
This commit is contained in:
+19
-18
@@ -71,7 +71,7 @@ fn opt_bool(p: &Value, k: &str) -> Result<Option<bool>, String> {
|
||||
|
||||
/// A pointer packet's `mods`: none when omitted or null, else an object of booleans.
|
||||
fn packet_mods(p: &Value) -> Result<Option<Mods>, String> {
|
||||
opt(p, "mods", |m| serde_json::from_value(m.clone()).ok(), "an object of true or false values")
|
||||
opt(p, "mods", |m| m.as_object().and_then(|_| serde_json::from_value(m.clone()).ok()), "an object of true or false values")
|
||||
}
|
||||
|
||||
pub(crate) fn pointer_kind(k: &str) -> Option<PointerKind> {
|
||||
@@ -231,25 +231,26 @@ impl Headless {
|
||||
let base = packet_mods(p)?.unwrap_or_default();
|
||||
let default = PointerEvent::json_backquote(p, false)?;
|
||||
let held = events.iter().map(|e| PointerEvent::json_backquote(e, default)).collect::<Result<Vec<_>, _>>()?;
|
||||
let mods = events.iter().map(|e| Ok(packet_mods(e)?.unwrap_or(base))).collect::<Result<Vec<_>, String>>()?;
|
||||
// Every event is read before any is sent, so a malformed packet sends none of them.
|
||||
let read = events
|
||||
.iter()
|
||||
.zip(held)
|
||||
.map(|(e, backquote)| {
|
||||
let mods = packet_mods(e)?.unwrap_or(base);
|
||||
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") {
|
||||
return Err("screen-space pointer events need the desktop app; use document coordinates".to_string());
|
||||
}
|
||||
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`")?;
|
||||
Ok((e, kind, Point::new(x, y), mods, backquote))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
self.ensure_document();
|
||||
let mut requests = vec![];
|
||||
for ((e, backquote), mods) in events.iter().zip(held).zip(mods) {
|
||||
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") {
|
||||
return Err("screen-space pointer events need the desktop app; use document coordinates".into());
|
||||
}
|
||||
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 ev = PointerEvent {
|
||||
kind,
|
||||
pos: Point::new(x, y),
|
||||
mods,
|
||||
backquote,
|
||||
pressure: PointerEvent::json_pressure(e),
|
||||
pen: PointerEvent::json_pen(e),
|
||||
};
|
||||
for (e, kind, pos, mods, backquote) in read {
|
||||
let ev = PointerEvent { kind, pos, mods, backquote, pressure: PointerEvent::json_pressure(e), pen: PointerEvent::json_pen(e) };
|
||||
let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?;
|
||||
self.apply_ui_requests(reqs, &mut requests)?;
|
||||
// With no frames to show the highlight or apply an edit in later, each event waits for
|
||||
|
||||
@@ -1461,6 +1461,7 @@ fn headless_methods_refuse_params_of_the_wrong_type() {
|
||||
h.call("ui.tool.select", json!({"tool": "rectangle"})).unwrap();
|
||||
let before = h.call("document.json", json!({})).unwrap();
|
||||
let drag = |mods: Value| json!({"events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": 60, "y": 60, "mods": mods}, {"kind": "up", "x": 60, "y": 60}]});
|
||||
let after_rect = |last: Value| json!({"events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": 60, "y": 60}, {"kind": "up", "x": 60, "y": 60}, last]});
|
||||
for (method, params, key) in [
|
||||
("ui.render", json!({"artboard": "0"}), "artboard"),
|
||||
("ui.render", json!({"scale": "2"}), "scale"),
|
||||
@@ -1469,6 +1470,10 @@ fn headless_methods_refuse_params_of_the_wrong_type() {
|
||||
("ui.key", json!({"key": "Delete", "shift": "true"}), "shift"),
|
||||
("ui.pointer", drag(json!({"shift": "yes"})), "mods"),
|
||||
("ui.pointer", json!({"mods": 1, "events": []}), "mods"),
|
||||
("ui.pointer", drag(json!([true])), "mods"),
|
||||
// A whole rectangle comes before the malformed event, and none of it is drawn.
|
||||
("ui.pointer", after_rect(json!({"kind": "move", "x": "bad", "y": 0})), "x"),
|
||||
("ui.pointer", after_rect(json!({"kind": "wiggle", "x": 0, "y": 0})), "wiggle"),
|
||||
] {
|
||||
let e = h.call(method, params.clone()).unwrap_err();
|
||||
assert!(e.contains(&format!("`{key}`")), "{method} {params}: {e}");
|
||||
|
||||
@@ -509,6 +509,7 @@ fn coord(p: &Value, key: &str, default: f32) -> Result<f32, String> {
|
||||
fn packet_mods(value: Option<&Value>) -> Result<Option<Mods>, String> {
|
||||
match value {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(m) if !m.is_object() => Err("`mods` must be an object of true or false values".to_string()),
|
||||
Some(m) => serde_json::from_value(m.clone()).map(Some).map_err(|e| format!("`mods` must be an object of true or false values: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -920,7 +921,7 @@ fn rotate_view_pointer(
|
||||
|
||||
/// A Rotate View packet accepts only the documented boolean modifier fields.
|
||||
fn validate_rotate_view_mods(value: Option<&Value>) -> Result<(), String> {
|
||||
let Some(value) = value else { return Ok(()) };
|
||||
let Some(value) = value.filter(|value| !value.is_null()) else { return Ok(()) };
|
||||
let Some(fields) = value.as_object() else { return Err("Rotate View `mods` must be an object of booleans".into()) };
|
||||
if fields.iter().any(|(key, value)| !matches!(key.as_str(), "shift" | "alt" | "cmd" | "ctrl" | "space") || !value.is_boolean()) {
|
||||
return Err("Rotate View `mods` accepts only boolean shift, alt, cmd, ctrl and space".into());
|
||||
@@ -975,8 +976,16 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con
|
||||
events
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") {
|
||||
"down" => PointerKind::Down,
|
||||
"drag" => PointerKind::Drag,
|
||||
"up" => PointerKind::Up,
|
||||
"move" => PointerKind::Move,
|
||||
"doubleclick" | "dblclick" => PointerKind::DoubleClick,
|
||||
other => return Err(format!("unknown pointer kind `{other}`")),
|
||||
};
|
||||
let at = |k: &str| crate::menus::opt_number(e, k).map(|n| n.unwrap_or(0.0));
|
||||
Ok((at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base)))
|
||||
Ok((kind, at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base)))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
});
|
||||
@@ -986,6 +995,10 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con
|
||||
};
|
||||
let view = app.view_info();
|
||||
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
|
||||
let screen = |e: &Value| e.get("space").and_then(Value::as_str) == Some("screen");
|
||||
if !rotating && xf.is_none() && events.iter().any(screen) {
|
||||
return err("no canvas yet");
|
||||
}
|
||||
let from = app.session.input_count();
|
||||
let held =
|
||||
PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::<Result<Vec<_>, _>>());
|
||||
@@ -993,16 +1006,8 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con
|
||||
Ok(held) => held,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
for ((e, backquote), (x, y, mods)) in events.iter().zip(held).zip(read) {
|
||||
let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") {
|
||||
"down" => PointerKind::Down,
|
||||
"drag" => PointerKind::Drag,
|
||||
"up" => PointerKind::Up,
|
||||
"move" => PointerKind::Move,
|
||||
"doubleclick" | "dblclick" => PointerKind::DoubleClick,
|
||||
other => return err(format!("unknown pointer kind `{other}`")),
|
||||
};
|
||||
let screen = e.get("space").and_then(Value::as_str) == Some("screen");
|
||||
for ((e, backquote), (kind, x, y, mods)) in events.iter().zip(held).zip(read) {
|
||||
let screen = screen(e);
|
||||
if rotating {
|
||||
if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) {
|
||||
return err(e);
|
||||
|
||||
@@ -349,6 +349,7 @@ fn control_input_refuses_params_of_the_wrong_type() {
|
||||
("ui.set", json!({"grid": true, "controlBar": 0}), "controlBar"),
|
||||
("ui.pointer", json!({"events": [{"kind": "move", "x": "5", "y": 0}]}), "x"),
|
||||
("ui.pointer", move_to(json!({"shift": "yes"})), "mods"),
|
||||
("ui.pointer", move_to(json!([true])), "mods"),
|
||||
("ui.pointer", json!({"mods": "shift", "events": [{"kind": "move", "x": 1, "y": 1}]}), "mods"),
|
||||
] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
@@ -456,6 +457,29 @@ fn synthetic_pointer_rejects_a_backquote_that_is_not_a_boolean() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ui.pointer` refuses an unknown event kind, or a screen-space event with no canvas, before any
|
||||
/// of the packet's events runs.
|
||||
#[test]
|
||||
fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() {
|
||||
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 moves = [json!({"kind": "down", "x": 125, "y": 125}), json!({"kind": "drag", "x": 145, "y": 125}), json!({"kind": "up", "x": 145, "y": 125})];
|
||||
for (last, error) in [
|
||||
(json!({"kind": "wiggle", "x": 145, "y": 125}), "unknown pointer kind `wiggle`"),
|
||||
(json!({"kind": "move", "x": 145, "y": 125, "space": "screen"}), "no canvas yet"),
|
||||
] {
|
||||
let events = moves.iter().cloned().chain([last]).collect::<Vec<_>>();
|
||||
let r = input_request(&mut app, "ui.pointer", json!({"events": events}));
|
||||
assert_eq!(r["ok"], false, "{error}: {r}");
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains(error)), "{r}");
|
||||
assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(), &before, "{error}: the drag does not run");
|
||||
}
|
||||
}
|
||||
|
||||
/// `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]
|
||||
|
||||
@@ -117,7 +117,8 @@ fn control_rotate_view_keeps_press_coordinates_and_shift_snapping_across_request
|
||||
send(&mut app, &ctx, "ui.pointer", json!({"mods": {"shift": true}, "events": [{"kind": "drag", "x": x, "y": y}]}));
|
||||
assert_eq!(app.view().unwrap().rotation, 45.0, "the same document point does not turn again as the view changes");
|
||||
}
|
||||
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": x, "y": y}]}));
|
||||
// Null `mods` keep the default, as an omitted value does.
|
||||
send(&mut app, &ctx, "ui.pointer", json!({"mods": null, "events": [{"kind": "up", "x": x, "y": y, "mods": null}]}));
|
||||
assert_eq!(app.view().unwrap().rotation, 45.0);
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -1271,7 +1271,9 @@ type and change nothing: `view.setZoom` (`zoom`, `center`), `view.screenMode` (`
|
||||
`ui.symbolOptions` (`edit`), `ui.batchDialog` (`set`, `action`) and `file.exportSelection` (`multiple`). So do the
|
||||
control channel's `ui.key`, `ui.move`, `ui.wheel`, `ui.click`, `ui.drag`, `ui.pointer`, `ui.resize`, `ui.screenshot`,
|
||||
`ui.render` and `ui.set` for their numbers, switches and `mods`, and the headless session's key, pointer and screenshot
|
||||
calls. Omitting a param or giving null keeps its default:
|
||||
calls. Omitting a param or giving null keeps its default, with two exceptions: `ui.click`'s `count` and `ui.drag`'s
|
||||
`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer packet with one
|
||||
malformed event sends none of its events:
|
||||
|
||||
```json
|
||||
{"id": "view.setZoom", "params": {"zoom": "200"}}
|
||||
|
||||
Reference in New Issue
Block a user