mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 22:21:59 +00:00
Merge pull request #6755 from dotCooCoo/fix/ui-params-wrong-type
Commands: app and control channel params of the wrong type are refused
This commit is contained in:
+46
-24
@@ -56,6 +56,24 @@ fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
|
|||||||
p.get(k).and_then(Value::as_str)
|
p.get(k).and_then(Value::as_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An optional param read with `read`: none when it is omitted or null, else an error naming the
|
||||||
|
/// key when `read` gets nothing from its value.
|
||||||
|
fn opt<T>(p: &Value, k: &str, read: impl Fn(&Value) -> Option<T>, what: &str) -> Result<Option<T>, String> {
|
||||||
|
match p.get(k) {
|
||||||
|
None | Some(Value::Null) => Ok(None),
|
||||||
|
Some(v) => read(v).map(Some).ok_or_else(|| format!("`{k}` must be {what}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opt_bool(p: &Value, k: &str) -> Result<Option<bool>, String> {
|
||||||
|
opt(p, k, Value::as_bool, "true or false")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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| 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> {
|
pub(crate) fn pointer_kind(k: &str) -> Option<PointerKind> {
|
||||||
Some(match k {
|
Some(match k {
|
||||||
"down" => PointerKind::Down,
|
"down" => PointerKind::Down,
|
||||||
@@ -210,28 +228,30 @@ impl Headless {
|
|||||||
|
|
||||||
fn pointer(&mut self, p: &Value) -> Result<Value, String> {
|
fn pointer(&mut self, p: &Value) -> Result<Value, String> {
|
||||||
let events = p.get("events").and_then(Value::as_array).ok_or("missing `events`")?;
|
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 base = packet_mods(p)?.unwrap_or_default();
|
||||||
let default = PointerEvent::json_backquote(p, false)?;
|
let default = PointerEvent::json_backquote(p, false)?;
|
||||||
let held = events.iter().map(|e| PointerEvent::json_backquote(e, default)).collect::<Result<Vec<_>, _>>()?;
|
let held = events.iter().map(|e| PointerEvent::json_backquote(e, default)).collect::<Result<Vec<_>, _>>()?;
|
||||||
|
// 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)"))?;
|
||||||
|
PointerEvent::json_check(e)?;
|
||||||
|
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();
|
self.ensure_document();
|
||||||
let mut requests = vec![];
|
let mut requests = vec![];
|
||||||
for (e, backquote) in events.iter().zip(held) {
|
for (e, kind, pos, mods, backquote) in read {
|
||||||
let kind = s(e, "kind").unwrap_or("");
|
let ev = PointerEvent { kind, pos, mods, backquote, pressure: PointerEvent::json_pressure(e), pen: PointerEvent::json_pen(e) };
|
||||||
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 mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base);
|
|
||||||
let ev = PointerEvent {
|
|
||||||
kind,
|
|
||||||
pos: Point::new(x, y),
|
|
||||||
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())?;
|
let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?;
|
||||||
self.apply_ui_requests(reqs, &mut requests)?;
|
self.apply_ui_requests(reqs, &mut requests)?;
|
||||||
// With no frames to show the highlight or apply an edit in later, each event waits for
|
// With no frames to show the highlight or apply an edit in later, each event waits for
|
||||||
@@ -253,8 +273,8 @@ impl Headless {
|
|||||||
|
|
||||||
fn key(&mut self, p: &Value) -> Result<Value, String> {
|
fn key(&mut self, p: &Value) -> Result<Value, String> {
|
||||||
let key = s(p, "key").ok_or("missing `key`")?;
|
let key = s(p, "key").ok_or("missing `key`")?;
|
||||||
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
|
let b = |n: &str| opt_bool(p, n).map(|on| on.unwrap_or(false));
|
||||||
let mods = Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), ..Mods::default() };
|
let mods = Mods { shift: b("shift")?, alt: b("alt")?, cmd: b("cmd")?, ctrl: b("ctrl")?, ..Mods::default() };
|
||||||
// A letter reaches the tool only when the tool claims it without Cmd, Ctrl or Alt (E while
|
// A letter reaches the tool only when the tool claims it without Cmd, Ctrl or Alt (E while
|
||||||
// a selection marquee is dragged); otherwise it is a shortcut.
|
// a selection marquee is dragged); otherwise it is a shortcut.
|
||||||
let mut tk = tool_key(key);
|
let mut tk = tool_key(key);
|
||||||
@@ -296,16 +316,18 @@ impl Headless {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render(&mut self, p: &Value) -> Result<Value, String> {
|
fn render(&mut self, p: &Value) -> Result<Value, String> {
|
||||||
|
let idx = opt(p, "artboard", |v| v.as_u64().and_then(|i| usize::try_from(i).ok()), "a whole number of 0 or more")?.unwrap_or(0);
|
||||||
|
let scale = opt(p, "scale", Value::as_f64, "a number")?.unwrap_or(1.0).clamp(0.01, 16.0);
|
||||||
|
let trace_views = opt_bool(p, "traceViews")? == Some(true);
|
||||||
|
let data = opt_bool(p, "data")? == Some(true);
|
||||||
let st = self.session.active().ok_or("no document")?;
|
let st = self.session.active().ok_or("no document")?;
|
||||||
let doc = st.doc.clone();
|
let doc = st.doc.clone();
|
||||||
let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
|
|
||||||
let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?;
|
let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?;
|
||||||
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0);
|
|
||||||
vectorcraft_render::raster_size(r, scale)?;
|
vectorcraft_render::raster_size(r, scale)?;
|
||||||
// Rendered with the options of a PNG export on white, so its pixels are the exported file's.
|
// Rendered with the options of a PNG export on white, so its pixels are the exported file's.
|
||||||
// `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does.
|
// `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does.
|
||||||
let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png);
|
let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png);
|
||||||
opts.trace_views = p.get("traceViews").and_then(Value::as_bool) == Some(true);
|
opts.trace_views = trace_views;
|
||||||
let img = self.renderer.render_region_with(&doc, r, scale, &opts);
|
let img = self.renderer.render_region_with(&doc, r, scale, &opts);
|
||||||
// A cancelled render stops drawing: the picture is not whole and is not written.
|
// A cancelled render stops drawing: the picture is not whole and is not written.
|
||||||
vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?;
|
vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?;
|
||||||
@@ -317,7 +339,7 @@ impl Headless {
|
|||||||
vectorcraft_format::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
|
vectorcraft_format::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||||
out["path"] = json!(path);
|
out["path"] = json!(path);
|
||||||
// `data: true` asks for the image as well as the file.
|
// `data: true` asks for the image as well as the file.
|
||||||
if p.get("data").and_then(Value::as_bool) == Some(true) {
|
if data {
|
||||||
out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png));
|
out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1452,6 +1452,44 @@ fn headless_backend_methods() {
|
|||||||
assert!(h.call("ui.resize", json!({})).is_err());
|
assert!(h.call("ui.resize", json!({})).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The headless key, pointer and screenshot calls refuse a value of the wrong type and change
|
||||||
|
/// nothing; omitting it or giving null keeps its default.
|
||||||
|
#[test]
|
||||||
|
fn headless_methods_refuse_params_of_the_wrong_type() {
|
||||||
|
let mut h = Headless::new();
|
||||||
|
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 200}})).unwrap();
|
||||||
|
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"),
|
||||||
|
("ui.render", json!({"traceViews": 1}), "traceViews"),
|
||||||
|
("ui.screenshot", json!({"data": "yes", "path": "x.png"}), "data"),
|
||||||
|
("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"),
|
||||||
|
("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "pressure": "bad"})), "pressure"),
|
||||||
|
("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "holdMs": true})), "holdMs"),
|
||||||
|
("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "tiltX": "5"})), "tiltX"),
|
||||||
|
("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "rotation": [0]})), "rotation"),
|
||||||
|
("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "space": "screeen"})), "space"),
|
||||||
|
] {
|
||||||
|
let e = h.call(method, params.clone()).unwrap_err();
|
||||||
|
assert!(e.contains(&format!("`{key}`")), "{method} {params}: {e}");
|
||||||
|
}
|
||||||
|
assert_eq!(h.call("document.json", json!({})).unwrap(), before, "a refused call changes nothing");
|
||||||
|
let r = h.call("ui.render", json!({"artboard": null, "scale": null, "traceViews": null})).unwrap();
|
||||||
|
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(300), Some(200)));
|
||||||
|
h.call("ui.pointer", drag(Value::Null)).unwrap();
|
||||||
|
assert_ne!(h.call("document.json", json!({})).unwrap(), before, "null mods keep the packet's");
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- workflow tools ----------
|
// ---------- workflow tools ----------
|
||||||
|
|
||||||
fn id_of(result: &Value) -> u64 {
|
fn id_of(result: &Value) -> u64 {
|
||||||
|
|||||||
@@ -109,6 +109,19 @@ impl PointerEvent {
|
|||||||
pub fn json_backquote(v: &Value, default: bool) -> Result<bool, String> {
|
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()))
|
v.get("backquote").map_or(Ok(default), |b| b.as_bool().ok_or_else(|| "`backquote` must be a boolean".to_owned()))
|
||||||
}
|
}
|
||||||
|
/// Checks the optional keys of a JSON pointer event (control channel, MCP): `pressure`,
|
||||||
|
/// `holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers, and `space` must be `doc` or
|
||||||
|
/// `screen`. A key that is absent or null keeps its default.
|
||||||
|
pub fn json_check(e: &Value) -> Result<(), String> {
|
||||||
|
let given = |k: &str| e.get(k).filter(|v| !v.is_null());
|
||||||
|
if let Some(k) = ["pressure", "holdMs", "tiltX", "tiltY", "rotation"].into_iter().find(|k| given(k).is_some_and(|v| v.as_f64().is_none())) {
|
||||||
|
return Err(format!("`{k}` must be a number"));
|
||||||
|
}
|
||||||
|
if given("space").is_some_and(|v| !matches!(v.as_str(), Some("doc" | "screen"))) {
|
||||||
|
return Err("`space` must be doc or screen".to_owned());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -135,6 +148,27 @@ mod tests {
|
|||||||
assert!(PointerEvent::json_backquote(&json!({"backquote": bad}), false).is_err(), "{bad}");
|
assert!(PointerEvent::json_backquote(&json!({"backquote": bad}), false).is_err(), "{bad}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_check_refuses_pen_values_and_spaces_of_the_wrong_type() {
|
||||||
|
use serde_json::json;
|
||||||
|
for ok in [
|
||||||
|
json!({}),
|
||||||
|
json!({"pressure": 0.5, "holdMs": 10, "tiltX": -5, "tiltY": 5, "rotation": 400, "space": "screen"}),
|
||||||
|
json!({"pressure": null, "space": null}),
|
||||||
|
json!({"space": "doc"}),
|
||||||
|
] {
|
||||||
|
assert_eq!(PointerEvent::json_check(&ok), Ok(()), "{ok}");
|
||||||
|
}
|
||||||
|
for k in ["pressure", "holdMs", "tiltX", "tiltY", "rotation"] {
|
||||||
|
for bad in [json!("1"), json!(true), json!([1])] {
|
||||||
|
assert_eq!(PointerEvent::json_check(&json!({k: bad})), Err(format!("`{k}` must be a number")), "{k}: {bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for bad in [json!("screeen"), json!(true), json!(1)] {
|
||||||
|
assert_eq!(PointerEvent::json_check(&json!({"space": bad})), Err("`space` must be doc or screen".to_owned()), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keys tools care about.
|
/// Keys tools care about.
|
||||||
|
|||||||
+111
-40
@@ -474,9 +474,24 @@ fn key_from(name: &str) -> Option<egui::Key> {
|
|||||||
|
|
||||||
/// The modifier keys a request holds down (`cmd`, `ctrl`, `alt`, `shift`; `cmd` is Command on
|
/// The modifier keys a request holds down (`cmd`, `ctrl`, `alt`, `shift`; `cmd` is Command on
|
||||||
/// macOS, Ctrl elsewhere).
|
/// macOS, Ctrl elsewhere).
|
||||||
fn modifiers(p: &Value) -> egui::Modifiers {
|
fn modifiers(p: &Value) -> Result<egui::Modifiers, String> {
|
||||||
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
|
let b = |n: &str| crate::menus::opt_bool(p, n).map(|on| on.unwrap_or(false));
|
||||||
egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") }
|
let cmd = b("cmd")?;
|
||||||
|
Ok(egui::Modifiers { alt: b("alt")?, ctrl: b("ctrl")?, shift: b("shift")?, mac_cmd: cmd && cfg!(target_os = "macos"), command: cmd })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An optional number param of a control request as `f32`, `default` when it is omitted or null.
|
||||||
|
fn coord(p: &Value, key: &str, default: f32) -> Result<f32, String> {
|
||||||
|
crate::menus::opt_number(p, key).map(|n| n.map_or(default, |n| n as f32))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pointer packet's `mods`: none when omitted or null, else an object of booleans.
|
||||||
|
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}")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||||
@@ -512,10 +527,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||||||
"ui.pointer" => pointer_events(app, ctx, req),
|
"ui.pointer" => pointer_events(app, ctx, req),
|
||||||
"ui.key" => {
|
"ui.key" => {
|
||||||
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
|
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
|
||||||
|
let m = match modifiers(p) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => return err(e),
|
||||||
|
};
|
||||||
if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) {
|
if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) {
|
||||||
return err(e);
|
return err(e);
|
||||||
}
|
}
|
||||||
let m = modifiers(p);
|
|
||||||
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||||
// Typed text comes with the press, so it shares the key's frame and modifiers.
|
// Typed text comes with the press, so it shares the key's frame and modifiers.
|
||||||
if let Some(t) = s("text") {
|
if let Some(t) = s("text") {
|
||||||
@@ -526,31 +544,39 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||||||
ok(Value::Null)
|
ok(Value::Null)
|
||||||
}
|
}
|
||||||
"ui.move" => {
|
"ui.move" => {
|
||||||
|
let pos = match coord(p, "x", 0.0).and_then(|x| Ok(egui::pos2(x, coord(p, "y", 0.0)?))) {
|
||||||
|
Ok(pos) => pos,
|
||||||
|
Err(e) => return err(e),
|
||||||
|
};
|
||||||
if let Err(e) = reserve_synthetic(app, 1) {
|
if let Err(e) = reserve_synthetic(app, 1) {
|
||||||
return err(e);
|
return err(e);
|
||||||
}
|
}
|
||||||
let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32);
|
|
||||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
ok(Value::Null)
|
ok(Value::Null)
|
||||||
}
|
}
|
||||||
"ui.click" | "ui.drag" => synthetic_click(app, ctx, req),
|
"ui.click" | "ui.drag" => synthetic_click(app, ctx, req),
|
||||||
"ui.wheel" => {
|
"ui.wheel" => {
|
||||||
|
// Mouse wheel notches over screen point (x, y): `dy` up (+) or down, `dx` sideways
|
||||||
|
// (`unit: "point"` for trackpad-like points), with modifiers as `ui.click` takes them.
|
||||||
|
let given =
|
||||||
|
(|| Ok::<_, String>(([coord(p, "dx", 0.0)?, coord(p, "dy", 0.0)?, coord(p, "x", 0.0)?, coord(p, "y", 0.0)?], modifiers(p)?)))();
|
||||||
|
let ([dx, dy, x, y], modifiers) = match given {
|
||||||
|
Ok(given) => given,
|
||||||
|
Err(e) => return err(e),
|
||||||
|
};
|
||||||
if let Err(e) = reserve_synthetic(app, 2) {
|
if let Err(e) = reserve_synthetic(app, 2) {
|
||||||
return err(e);
|
return err(e);
|
||||||
}
|
}
|
||||||
// Mouse wheel notches over screen point (x, y): `dy` up (+) or down, `dx` sideways
|
|
||||||
// (`unit: "point"` for trackpad-like points), with modifiers as `ui.click` takes them.
|
|
||||||
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
|
||||||
let unit = if s("unit") == Some("point") { egui::MouseWheelUnit::Point } else { egui::MouseWheelUnit::Line };
|
let unit = if s("unit") == Some("point") { egui::MouseWheelUnit::Point } else { egui::MouseWheelUnit::Line };
|
||||||
// Notches, not a flood: a turn stays within what a real wheel sends.
|
// Notches, not a flood: a turn stays within what a real wheel sends.
|
||||||
let delta = egui::vec2(f("dx"), f("dy")).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0));
|
let delta = egui::vec2(dx, dy).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0));
|
||||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y"))));
|
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y)));
|
||||||
app.synthetic.push(egui::Event::MouseWheel {
|
app.synthetic.push(egui::Event::MouseWheel {
|
||||||
unit,
|
unit,
|
||||||
delta,
|
delta,
|
||||||
phase: egui::TouchPhase::Move,
|
phase: egui::TouchPhase::Move,
|
||||||
modifiers: modifiers(p),
|
modifiers,
|
||||||
source: egui::MouseWheelSource::Unknown,
|
source: egui::MouseWheelSource::Unknown,
|
||||||
});
|
});
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
@@ -615,8 +641,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||||||
ok(Value::Null)
|
ok(Value::Null)
|
||||||
}
|
}
|
||||||
"ui.resize" => {
|
"ui.resize" => {
|
||||||
let w = p.get("width").and_then(Value::as_f64).unwrap_or(1440.0) as f32;
|
let (w, h) = match coord(p, "width", 1440.0).and_then(|w| Ok((w, coord(p, "height", 900.0)?))) {
|
||||||
let h = p.get("height").and_then(Value::as_f64).unwrap_or(900.0) as f32;
|
Ok(size) => size,
|
||||||
|
Err(e) => return err(e),
|
||||||
|
};
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
|
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
|
||||||
ok(Value::Null)
|
ok(Value::Null)
|
||||||
}
|
}
|
||||||
@@ -629,32 +657,48 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||||||
if let Some(Err(e)) = s("path").map(vectorcraft_engine::file_access::check_write) {
|
if let Some(Err(e)) = s("path").map(vectorcraft_engine::file_access::check_write) {
|
||||||
return err(e);
|
return err(e);
|
||||||
}
|
}
|
||||||
if p.get("focus").and_then(Value::as_bool).unwrap_or(true) {
|
let flags = crate::menus::opt_bool(p, "focus").and_then(|focus| Ok((focus, crate::menus::opt_bool(p, "data")?)));
|
||||||
|
let (focus, data) = match flags {
|
||||||
|
Ok(flags) => flags,
|
||||||
|
Err(e) => return err(e),
|
||||||
|
};
|
||||||
|
if focus.unwrap_or(true) {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||||
}
|
}
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
Outcome::Screenshot { path: s("path").map(str::to_string), data: p.get("data").and_then(Value::as_bool) == Some(true) }
|
Outcome::Screenshot { path: s("path").map(str::to_string), data: data == Some(true) }
|
||||||
}
|
}
|
||||||
"ui.render" => {
|
"ui.render" => {
|
||||||
|
let given = (|| {
|
||||||
|
Ok::<_, String>((
|
||||||
|
crate::menus::opt_index(p, "artboard")?,
|
||||||
|
crate::menus::opt_number(p, "scale")?,
|
||||||
|
crate::menus::opt_bool(p, "traceViews")?,
|
||||||
|
crate::menus::opt_bool(p, "data")?,
|
||||||
|
))
|
||||||
|
})();
|
||||||
|
let (index, scale, trace_views, data) = match given {
|
||||||
|
Ok(given) => given,
|
||||||
|
Err(e) => return err(e),
|
||||||
|
};
|
||||||
let Some(st) = app.session.active() else { return err("no document") };
|
let Some(st) = app.session.active() else { return err("no document") };
|
||||||
let doc = st.doc.clone();
|
let doc = st.doc.clone();
|
||||||
// The artboard asked for (0-based, as headless), else the first.
|
// The artboard asked for (0-based, as headless), else the first.
|
||||||
let index = p.get("artboard").and_then(Value::as_u64).map_or(Some(0), |i| usize::try_from(i).ok());
|
let Some(r) = doc.artboards.get(index.unwrap_or(0)).map(|a| a.rect) else { return err("no such artboard") };
|
||||||
let Some(r) = index.and_then(|i| doc.artboards.get(i)).map(|a| a.rect) else { return err("no such artboard") };
|
let scale = scale.unwrap_or(1.0);
|
||||||
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
|
|
||||||
if let Err(e) = vectorcraft_render::raster_size(r, scale) {
|
if let Err(e) = vectorcraft_render::raster_size(r, scale) {
|
||||||
return err(e);
|
return err(e);
|
||||||
}
|
}
|
||||||
// Rendered with the options of a PNG export on white, so its pixels are the exported file's.
|
// Rendered with the options of a PNG export on white, so its pixels are the exported file's.
|
||||||
// `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does.
|
// `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does.
|
||||||
let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png);
|
let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png);
|
||||||
opts.trace_views = p.get("traceViews").and_then(Value::as_bool) == Some(true);
|
opts.trace_views = trace_views == Some(true);
|
||||||
let img = app.canvas.renderer.render_region_with(&doc, r, scale, &opts);
|
let img = app.canvas.renderer.render_region_with(&doc, r, scale, &opts);
|
||||||
let png = match img.to_png() {
|
let png = match img.to_png() {
|
||||||
Ok(png) => png,
|
Ok(png) => png,
|
||||||
Err(e) => return err(e),
|
Err(e) => return err(e),
|
||||||
};
|
};
|
||||||
let data = p.get("data").and_then(Value::as_bool) == Some(true);
|
let data = data == Some(true);
|
||||||
match s("path") {
|
match s("path") {
|
||||||
Some(path) => match app.services.write.as_mut() {
|
Some(path) => match app.services.write.as_mut() {
|
||||||
Some(w) => wrap(
|
Some(w) => wrap(
|
||||||
@@ -857,7 +901,7 @@ fn rotate_view_pointer(
|
|||||||
|
|
||||||
/// A Rotate View packet accepts only the documented boolean modifier fields.
|
/// A Rotate View packet accepts only the documented boolean modifier fields.
|
||||||
fn validate_rotate_view_mods(value: Option<&Value>) -> Result<(), String> {
|
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()) };
|
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()) {
|
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());
|
return Err("Rotate View `mods` accepts only boolean shift, alt, cmd, ctrl and space".into());
|
||||||
@@ -906,9 +950,36 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con
|
|||||||
if rotating && let Err(error) = validate_rotate_view_events(p, events) {
|
if rotating && let Err(error) = validate_rotate_view_events(p, events) {
|
||||||
return err(error);
|
return err(error);
|
||||||
}
|
}
|
||||||
let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
|
// Every event is read before any is sent, so a malformed packet sends none of them.
|
||||||
|
let read = packet_mods(p.get("mods")).and_then(|base| {
|
||||||
|
let base = base.unwrap_or_default();
|
||||||
|
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}`")),
|
||||||
|
};
|
||||||
|
PointerEvent::json_check(e)?;
|
||||||
|
let at = |k: &str| crate::menus::opt_number(e, k).map(|n| n.unwrap_or(0.0));
|
||||||
|
Ok((kind, at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base)))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, String>>()
|
||||||
|
});
|
||||||
|
let read = match read {
|
||||||
|
Ok(read) => read,
|
||||||
|
Err(e) => return err(e),
|
||||||
|
};
|
||||||
let view = app.view_info();
|
let view = app.view_info();
|
||||||
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
|
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 from = app.session.input_count();
|
||||||
let held =
|
let held =
|
||||||
PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::<Result<Vec<_>, _>>());
|
PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::<Result<Vec<_>, _>>());
|
||||||
@@ -916,19 +987,8 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con
|
|||||||
Ok(held) => held,
|
Ok(held) => held,
|
||||||
Err(e) => return err(e),
|
Err(e) => return err(e),
|
||||||
};
|
};
|
||||||
for (e, backquote) in events.iter().zip(held) {
|
for ((e, backquote), (kind, x, y, mods)) in events.iter().zip(held).zip(read) {
|
||||||
let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") {
|
let screen = screen(e);
|
||||||
"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 x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0);
|
|
||||||
let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0);
|
|
||||||
let screen = e.get("space").and_then(Value::as_str) == Some("screen");
|
|
||||||
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
|
|
||||||
if rotating {
|
if rotating {
|
||||||
if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) {
|
if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) {
|
||||||
return err(e);
|
return err(e);
|
||||||
@@ -987,18 +1047,24 @@ fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlR
|
|||||||
let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else {
|
let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else {
|
||||||
return err("synthetic input event count is out of range");
|
return err("synthetic input event count is out of range");
|
||||||
};
|
};
|
||||||
|
// Screen-space (egui points) pointer input through egui itself: reaches every widget.
|
||||||
|
let drag = req.method == "ui.drag";
|
||||||
|
let given = (|| {
|
||||||
|
let a = egui::pos2(coord(p, "x", 0.0)?, coord(p, "y", 0.0)?);
|
||||||
|
let end = if drag { egui::pos2(coord(p, "toX", 0.0)?, coord(p, "toY", 0.0)?) } else { a };
|
||||||
|
Ok::<_, String>((a, end, modifiers(p)?))
|
||||||
|
})();
|
||||||
|
let (a, end, modifiers) = match given {
|
||||||
|
Ok(given) => given,
|
||||||
|
Err(e) => return err(e),
|
||||||
|
};
|
||||||
if let Err(e) = reserve_synthetic(app, events) {
|
if let Err(e) = reserve_synthetic(app, events) {
|
||||||
return err(e);
|
return err(e);
|
||||||
}
|
}
|
||||||
// Screen-space (egui points) pointer input through egui itself: reaches every widget.
|
|
||||||
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
|
||||||
let button = match s("button") {
|
let button = match s("button") {
|
||||||
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
|
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
|
||||||
_ => egui::PointerButton::Primary,
|
_ => egui::PointerButton::Primary,
|
||||||
};
|
};
|
||||||
let modifiers = modifiers(p);
|
|
||||||
let a = egui::pos2(f("x"), f("y"));
|
|
||||||
let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a };
|
|
||||||
app.synthetic.push(egui::Event::PointerMoved(a));
|
app.synthetic.push(egui::Event::PointerMoved(a));
|
||||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
|
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
|
||||||
if req.method == "ui.drag" {
|
if req.method == "ui.drag" {
|
||||||
@@ -1020,6 +1086,11 @@ fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlR
|
|||||||
fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome {
|
fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome {
|
||||||
let p = &req.params;
|
let p = &req.params;
|
||||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||||
|
// A flag of the wrong type is refused before anything is set.
|
||||||
|
let flags = ["rulers", "outline", "grid", "smartGuides", "boundingBox", "controlBar", "imageTracePreview"];
|
||||||
|
if let Some(e) = flags.iter().find_map(|k| crate::menus::opt_bool(p, k).err()) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
// Outline is a setting of the active document's view: without one, nothing is set.
|
// Outline is a setting of the active document's view: without one, nothing is set.
|
||||||
if p.get("outline").is_some_and(Value::is_boolean) && app.session.active().is_none() {
|
if p.get("outline").is_some_and(Value::is_boolean) && app.session.active().is_none() {
|
||||||
return err("no document");
|
return err("no document");
|
||||||
|
|||||||
@@ -314,6 +314,93 @@ fn render_takes_the_artboard_asked_for() {
|
|||||||
assert_eq!(got, size.map(Some), "{params}: {r}");
|
assert_eq!(got, size.map(Some), "{params}: {r}");
|
||||||
}
|
}
|
||||||
assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string());
|
assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string());
|
||||||
|
for (params, key) in [
|
||||||
|
(json!({"artboard": "1"}), "artboard"),
|
||||||
|
(json!({"artboard": -1}), "artboard"),
|
||||||
|
(json!({"scale": "0.5"}), "scale"),
|
||||||
|
(json!({"traceViews": 1}), "traceViews"),
|
||||||
|
(json!({"data": "yes"}), "data"),
|
||||||
|
] {
|
||||||
|
let r = request("ui.render", params.clone());
|
||||||
|
assert!(r["error"].as_str().is_some_and(|e| e.contains(&format!("`{key}`"))), "{params}: {r}");
|
||||||
|
}
|
||||||
|
let r = request("ui.render", json!({"artboard": null, "scale": null}));
|
||||||
|
assert_eq!([&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64), [Some(64), Some(64)], "null keeps the default: {r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The control channel's input methods refuse a number or switch of the wrong type before they
|
||||||
|
/// queue any event; omitting it keeps its default.
|
||||||
|
#[test]
|
||||||
|
fn control_input_refuses_params_of_the_wrong_type() {
|
||||||
|
let move_to = |mods: Value| json!({"events": [{"kind": "move", "x": 5, "y": 5}, {"kind": "move", "x": 6, "y": 6, "mods": mods}]});
|
||||||
|
for (method, params, key) in [
|
||||||
|
("ui.move", json!({"x": "10"}), "x"),
|
||||||
|
("ui.move", json!({"y": true}), "y"),
|
||||||
|
("ui.wheel", json!({"dy": "1"}), "dy"),
|
||||||
|
("ui.wheel", json!({"dy": 1, "shift": "yes"}), "shift"),
|
||||||
|
("ui.click", json!({"x": [1]}), "x"),
|
||||||
|
("ui.click", json!({"cmd": 1}), "cmd"),
|
||||||
|
("ui.drag", json!({"toX": "5"}), "toX"),
|
||||||
|
("ui.key", json!({"key": "Enter", "alt": "true"}), "alt"),
|
||||||
|
("ui.resize", json!({"width": "800"}), "width"),
|
||||||
|
("ui.screenshot", json!({"focus": "no"}), "focus"),
|
||||||
|
("ui.screenshot", json!({"data": 1}), "data"),
|
||||||
|
("ui.set", json!({"rulers": "true"}), "rulers"),
|
||||||
|
("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());
|
||||||
|
app.synthetic.push(egui::Event::Text("pending".into()));
|
||||||
|
let before = (format!("{:?}", app.synthetic), app.ui.view.rulers, app.ui.view.grid, app.ui.control_bar);
|
||||||
|
let r = input_request(&mut app, method, params.clone());
|
||||||
|
assert_eq!(r["ok"], false, "{method} {params}: {r}");
|
||||||
|
assert!(r["error"].as_str().is_some_and(|e| e.contains(&format!("`{key}`"))), "{method} {params}: {r}");
|
||||||
|
let after = (format!("{:?}", app.synthetic), app.ui.view.rulers, app.ui.view.grid, app.ui.control_bar);
|
||||||
|
assert_eq!(after, before, "{method} {params}: nothing is queued or set");
|
||||||
|
}
|
||||||
|
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||||
|
assert_eq!(input_request(&mut app, "ui.move", json!({"x": null, "y": 4}))["ok"], true);
|
||||||
|
assert!(matches!(app.synthetic.first(), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(0.0, 4.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's own commands refuse a param of the wrong type and change nothing; omitting it or
|
||||||
|
/// giving null keeps its default.
|
||||||
|
#[test]
|
||||||
|
fn ui_commands_refuse_params_of_the_wrong_type() {
|
||||||
|
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||||
|
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||||
|
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||||
|
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||||
|
app.run("view.setZoom", json!({"zoom": 300, "center": [5, 5]})).unwrap();
|
||||||
|
let view = |app: &VectorcraftApp| app.view().map(|v| (v.zoom, v.center.x, v.center.y));
|
||||||
|
let before = (view(&app), app.ui.screen_mode, app.session.documents().len(), app.ui.dialog.is_some(), app.ui.open_panel.clone());
|
||||||
|
for (id, params, key) in [
|
||||||
|
("view.setZoom", json!({"zoom": "200"}), "zoom"),
|
||||||
|
("view.setZoom", json!({"zoom": 200, "center": [1]}), "center"),
|
||||||
|
("view.setZoom", json!({"zoom": 200, "center": "0,0"}), "center"),
|
||||||
|
("view.screenMode", json!({"mode": "2"}), "mode"),
|
||||||
|
("view.screenMode", json!({"mode": 1.5}), "mode"),
|
||||||
|
("window.panel", json!({"panel": "layers", "toggle": "true"}), "toggle"),
|
||||||
|
("window.panel", json!({"panel": "layers", "toggle": true, "toggleGroup": 1}), "toggleGroup"),
|
||||||
|
("file.revert", json!({"confirmed": "yes"}), "confirmed"),
|
||||||
|
("file.close", json!({"index": "0"}), "index"),
|
||||||
|
("ui.newSwatch", json!({"spot": "true"}), "spot"),
|
||||||
|
("ui.newSwatch", json!({"global": 1}), "global"),
|
||||||
|
("ui.symbolOptions", json!({"edit": 1}), "edit"),
|
||||||
|
("ui.batchDialog", json!({"set": "1"}), "set"),
|
||||||
|
("ui.batchDialog", json!({"action": -1}), "action"),
|
||||||
|
("file.exportSelection", json!({"multiple": "true"}), "multiple"),
|
||||||
|
] {
|
||||||
|
let e = app.run(id, params.clone()).unwrap_err();
|
||||||
|
assert!(e.contains(&format!("`{key}`")), "{id} {params}: {e}");
|
||||||
|
let after = (view(&app), app.ui.screen_mode, app.session.documents().len(), app.ui.dialog.is_some(), app.ui.open_panel.clone());
|
||||||
|
assert_eq!(after, before, "{id} {params}: nothing changes");
|
||||||
|
}
|
||||||
|
app.run("view.setZoom", json!({"zoom": null, "center": null})).unwrap();
|
||||||
|
assert_eq!(app.view().map(|v| v.zoom), Some(crate::state::zoom_from_display_percent(100.0, app.session.prefs.display_print_size)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -370,6 +457,35 @@ fn synthetic_pointer_rejects_a_backquote_that_is_not_a_boolean() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ui.pointer` refuses an unknown event kind, a pen value or `space` of the wrong type, 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"),
|
||||||
|
(json!({"kind": "move", "x": 145, "y": 125, "pressure": "bad"}), "`pressure` must be a number"),
|
||||||
|
(json!({"kind": "move", "x": 145, "y": 125, "holdMs": "800"}), "`holdMs` must be a number"),
|
||||||
|
(json!({"kind": "move", "x": 145, "y": 125, "tiltY": true}), "`tiltY` must be a number"),
|
||||||
|
(json!({"kind": "move", "x": 145, "y": 125, "rotation": "90"}), "`rotation` must be a number"),
|
||||||
|
(json!({"kind": "move", "x": 145, "y": 125, "space": "screeen"}), "`space` must be doc or screen"),
|
||||||
|
(json!({"kind": "move", "x": 145, "y": 125, "space": true}), "`space` must be doc or screen"),
|
||||||
|
] {
|
||||||
|
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
|
/// `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.
|
/// the key, and a click on the key again lets it go.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ pub(crate) use labels::lists_plugin;
|
|||||||
use labels::shows_a_name;
|
use labels::shows_a_name;
|
||||||
pub use labels::{CONTEXT_LABELS, DYNAMIC_LABELS, display_label, dynamic_label, item_shortcut, menu_strings};
|
pub use labels::{CONTEXT_LABELS, DYNAMIC_LABELS, display_label, dynamic_label, item_shortcut, menu_strings};
|
||||||
pub use run::{check_params, run_ui_command};
|
pub use run::{check_params, run_ui_command};
|
||||||
pub(crate) use run::{normalize_panel, opt_bool};
|
pub(crate) use run::{normalize_panel, opt_bool, opt_index, opt_number};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use slots::RECENT_FONT_IDS;
|
use slots::RECENT_FONT_IDS;
|
||||||
pub use state::{checked, enabled, plugin_revision, shortcut_of, shown_state};
|
pub use state::{checked, enabled, plugin_revision, shortcut_of, shown_state};
|
||||||
|
|||||||
@@ -25,6 +25,34 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An optional `{key?: number}` param: none when it is omitted or null.
|
||||||
|
pub(crate) fn opt_number(p: &Value, key: &str) -> Result<Option<f64>, String> {
|
||||||
|
match p.get(key) {
|
||||||
|
None | Some(Value::Null) => Ok(None),
|
||||||
|
Some(v) => v.as_f64().map(Some).ok_or_else(|| format!("`{key}` must be a number")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An optional `{key?: index}` param, a whole number of 0 or more: none when it is omitted or null.
|
||||||
|
pub(crate) fn opt_index(p: &Value, key: &str) -> Result<Option<usize>, String> {
|
||||||
|
match p.get(key) {
|
||||||
|
None | Some(Value::Null) => Ok(None),
|
||||||
|
Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).map(Some).ok_or_else(|| format!("`{key}` must be a whole number of 0 or more")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An optional `{key?: [x, y]}` param: none when it is omitted or null.
|
||||||
|
fn opt_point(p: &Value, key: &str) -> Result<Option<vectorcraft_geom::Point>, String> {
|
||||||
|
match p.get(key) {
|
||||||
|
None | Some(Value::Null) => Ok(None),
|
||||||
|
Some(v) => match v.as_array().map(Vec::as_slice) {
|
||||||
|
Some([x, y]) => x.as_f64().zip(y.as_f64()).map(|(x, y)| Some(vectorcraft_geom::Point::new(x, y))),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
.ok_or_else(|| format!("`{key}` must be [x, y] numbers")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||||
/// `params` has only keys the params description of engine command `id` names, or the parameter
|
/// `params` has only keys the params description of engine command `id` names, or the parameter
|
||||||
/// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their
|
/// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their
|
||||||
@@ -48,13 +76,18 @@ pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Va
|
|||||||
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||||
// Window menu items and the named panel function keys toggle against the checked state;
|
// Window menu items and the named panel function keys toggle against the checked state;
|
||||||
// other commands that open panels show them.
|
// other commands that open panels show them.
|
||||||
if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) {
|
let toggles = if id == "window.panel" { opt_bool(p, "toggle").and_then(|t| Ok((t, opt_bool(p, "toggleGroup")?))) } else { Ok((None, None)) };
|
||||||
|
let (toggle, toggle_group) = match toggles {
|
||||||
|
Ok(flags) => flags,
|
||||||
|
Err(e) => return Some(Err(e)),
|
||||||
|
};
|
||||||
|
if toggle == Some(true) && checked(app, id, p) == Some(true) {
|
||||||
let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default();
|
let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default();
|
||||||
if app.ui.open_panel.as_deref() == Some(panel) {
|
if app.ui.open_panel.as_deref() == Some(panel) {
|
||||||
app.ui.open_panel = None;
|
app.ui.open_panel = None;
|
||||||
return Some(Ok(json!({"open": null})));
|
return Some(Ok(json!({"open": null})));
|
||||||
}
|
}
|
||||||
if p.get("toggleGroup").and_then(Value::as_bool) == Some(true) {
|
if toggle_group == Some(true) {
|
||||||
return Some(crate::panel_docking::hide_group(app, panel));
|
return Some(crate::panel_docking::hide_group(app, panel));
|
||||||
}
|
}
|
||||||
return Some(app.run("window.panel.close", json!({"panel": panel})));
|
return Some(app.run("window.panel.close", json!({"panel": panel})));
|
||||||
@@ -131,7 +164,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
|||||||
}
|
}
|
||||||
// Bytes sent by an agent go straight to the engine; a path or nothing opens it here.
|
// Bytes sent by an agent go straight to the engine; a path or nothing opens it here.
|
||||||
"file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")),
|
"file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")),
|
||||||
"file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app),
|
"file.revert" if opt_bool(p, "confirmed") != Ok(Some(true)) => opt_bool(p, "confirmed").and_then(|_| io::ask_revert(app)),
|
||||||
"file.reveal" => io::reveal(app),
|
"file.reveal" => io::reveal(app),
|
||||||
"file.cancelExport" => Ok(io::cancel_exports(app)),
|
"file.cancelExport" => Ok(io::cancel_exports(app)),
|
||||||
"file.exportInBackground" => Ok(io::hide_exports(app)),
|
"file.exportInBackground" => Ok(io::hide_exports(app)),
|
||||||
@@ -386,9 +419,11 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
|||||||
}
|
}
|
||||||
"view.setZoom" => {
|
"view.setZoom" => {
|
||||||
let print = app.session.prefs.display_print_size;
|
let print = app.session.prefs.display_print_size;
|
||||||
let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print);
|
let (zoom, center) = match opt_number(p, "zoom").and_then(|z| Ok((z, opt_point(p, "center")?))) {
|
||||||
let center =
|
Ok(given) => given,
|
||||||
p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
Err(e) => return Some(Err(e)),
|
||||||
|
};
|
||||||
|
let z = crate::state::zoom_from_display_percent(zoom.unwrap_or(100.0), print);
|
||||||
match app.view_mut() {
|
match app.view_mut() {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
v.zoom = z.clamp(0.0313, 640.0);
|
v.zoom = z.clamp(0.0313, 640.0);
|
||||||
@@ -417,7 +452,11 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
|||||||
Ok(json!(app.ui.screen_mode))
|
Ok(json!(app.ui.screen_mode))
|
||||||
}
|
}
|
||||||
"view.screenMode" => {
|
"view.screenMode" => {
|
||||||
app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) {
|
let mode = match opt_index(p, "mode") {
|
||||||
|
Ok(mode) => mode,
|
||||||
|
Err(e) => return Some(Err(e)),
|
||||||
|
};
|
||||||
|
app.ui.screen_mode = match mode {
|
||||||
Some(m) => m.min(3) as u8,
|
Some(m) => m.min(3) as u8,
|
||||||
// F cycles the three screen modes. Without a mode, the command also goes from
|
// F cycles the three screen modes. Without a mode, the command also goes from
|
||||||
// Presentation Mode (not in the cycle) back to Normal.
|
// Presentation Mode (not in the cycle) back to Normal.
|
||||||
@@ -683,9 +722,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
|||||||
// Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`).
|
// Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`).
|
||||||
// Without an `index`, the current window closes: one of several views of a document closes
|
// Without an `index`, the current window closes: one of several views of a document closes
|
||||||
// without closing the document (Window › New Window).
|
// without closing the document (Window › New Window).
|
||||||
"file.close" => match p.get("index").and_then(Value::as_u64).map(|i| i as usize) {
|
"file.close" => match opt_index(p, "index") {
|
||||||
Some(i) => crate::unsaved::close(app, i),
|
Err(e) => Err(e),
|
||||||
None => {
|
Ok(Some(i)) => crate::unsaved::close(app, i),
|
||||||
|
Ok(None) => {
|
||||||
app.session.active_index()?;
|
app.session.active_index()?;
|
||||||
crate::doc_windows::close_current(app)
|
crate::doc_windows::close_current(app)
|
||||||
}
|
}
|
||||||
@@ -697,8 +737,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
|||||||
None => Err("missing `name`".into()),
|
None => Err("missing `name`".into()),
|
||||||
},
|
},
|
||||||
"ui.newSwatch" => {
|
"ui.newSwatch" => {
|
||||||
let on = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
|
let on = |k: &str| opt_bool(p, k).map(|on| on.unwrap_or(false));
|
||||||
crate::dialogs::new_swatch::open(app, on("spot"), on("global"), s("group").as_deref())
|
on("spot")
|
||||||
|
.and_then(|spot| Ok((spot, on("global")?)))
|
||||||
|
.and_then(|(spot, global)| crate::dialogs::new_swatch::open(app, spot, global, s("group").as_deref()))
|
||||||
}
|
}
|
||||||
"ui.newColorGroup" => {
|
"ui.newColorGroup" => {
|
||||||
let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||||
@@ -721,7 +763,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
|||||||
"ui.newParaStyle" => crate::panels::text_styles::open_new(app, crate::panels::text_styles::Kind::Para),
|
"ui.newParaStyle" => crate::panels::text_styles::open_new(app, crate::panels::text_styles::Kind::Para),
|
||||||
"ui.symbolOptions" => {
|
"ui.symbolOptions" => {
|
||||||
let name = s("name");
|
let name = s("name");
|
||||||
let edit = p.get("edit").and_then(Value::as_bool).unwrap_or(false);
|
let edit = match opt_bool(p, "edit") {
|
||||||
|
Ok(edit) => edit.unwrap_or(false),
|
||||||
|
Err(e) => return Some(Err(e)),
|
||||||
|
};
|
||||||
if edit || name.is_some() {
|
if edit || name.is_some() {
|
||||||
crate::dialogs::symbol_options::open_edit(app, name.as_deref())
|
crate::dialogs::symbol_options::open_edit(app, name.as_deref())
|
||||||
} else {
|
} else {
|
||||||
@@ -924,9 +969,8 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result
|
|||||||
crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p),
|
crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p),
|
||||||
crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p),
|
crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p),
|
||||||
"ui.batchDialog" => {
|
"ui.batchDialog" => {
|
||||||
let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize;
|
let index = |k: &str| opt_index(p, k).map(|i| i.unwrap_or(0));
|
||||||
let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize;
|
index("set").and_then(|set| Ok((set, index("action")?))).and_then(|(set, action)| crate::dialogs::action_batch::open(app, set, action))
|
||||||
crate::dialogs::action_batch::open(app, set, action)
|
|
||||||
}
|
}
|
||||||
crate::panels::actions::SAVE_SET => crate::panels::actions::save_set(app, p),
|
crate::panels::actions::SAVE_SET => crate::panels::actions::save_set(app, p),
|
||||||
crate::panels::actions::LOAD_SET => crate::panels::actions::load_set(app, p),
|
crate::panels::actions::LOAD_SET => crate::panels::actions::load_set(app, p),
|
||||||
@@ -959,7 +1003,11 @@ fn run_ui_part12(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result
|
|||||||
// File → Export Selection…: the selection becomes one asset (`multiple: true`: one per
|
// File → Export Selection…: the selection becomes one asset (`multiple: true`: one per
|
||||||
// object), shown checked on Export for Screens' Assets tab.
|
// object), shown checked on Export for Screens' Assets tab.
|
||||||
"file.exportSelection" => {
|
"file.exportSelection" => {
|
||||||
app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(false) })).map(|r| {
|
let multiple = match opt_bool(p, "multiple") {
|
||||||
|
Ok(multiple) => multiple.unwrap_or(false),
|
||||||
|
Err(e) => return Some(Err(e)),
|
||||||
|
};
|
||||||
|
app.run("assets.add", json!({ "multiple": multiple })).map(|r| {
|
||||||
let ids: Vec<u64> = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect();
|
let ids: Vec<u64> = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect();
|
||||||
crate::dialogs::open_export_for_screens_assets(app, Some(&ids));
|
crate::dialogs::open_export_for_screens_assets(app, Some(&ids));
|
||||||
json!({ "assets": ids })
|
json!({ "assets": ids })
|
||||||
|
|||||||
@@ -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}]}));
|
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");
|
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);
|
assert_eq!(app.view().unwrap().rotation, 45.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
@@ -1341,6 +1341,22 @@ fails with no change. A description with a `…` entry takes more keys than it l
|
|||||||
object can carry several formats' options). A command that takes its own reply back
|
object can carry several formats' options). A command that takes its own reply back
|
||||||
(`document.setup`, `text.areaOptions`) says so the same way.
|
(`document.setup`, `text.areaOptions`) says so the same way.
|
||||||
|
|
||||||
|
The app's own commands are not checked against their descriptions, but those listed here refuse a value of the wrong
|
||||||
|
type and change nothing: `view.setZoom` (`zoom`, `center`), `view.screenMode` (`mode`), `window.panel` (`toggle`,
|
||||||
|
`toggleGroup`), `file.revert` (`confirmed`), `file.close` (`index`), `ui.newSwatch` (`spot`, `global`),
|
||||||
|
`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, 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 event's `pressure`,
|
||||||
|
`holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers and its `space` must be `doc` or `screen`. A pointer packet
|
||||||
|
with one malformed event sends none of its events:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": "view.setZoom", "params": {"zoom": "200"}}
|
||||||
|
→ `zoom` must be a number
|
||||||
|
```
|
||||||
|
|
||||||
Saved actions aren't checked this way. When the Actions panel plays an action (Play at any speed, an action's function
|
Saved actions aren't checked this way. When the Actions panel plays an action (Play at any speed, an action's function
|
||||||
key, the Batch dialog or `ui.action.play`), a key a command no longer reads, recorded by an older version, is ignored
|
key, the Batch dialog or `ui.action.play`), a key a command no longer reads, recorded by an older version, is ignored
|
||||||
and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect
|
and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect
|
||||||
|
|||||||
Reference in New Issue
Block a user