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:
BFlat
2026-11-12 14:17:29 +07:00
committed by GitHub
9 changed files with 429 additions and 83 deletions
+46 -24
View File
@@ -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));
} }
} }
+38
View File
@@ -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 {
+34
View File
@@ -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
View File
@@ -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");
+116
View File
@@ -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]
+1 -1
View File
@@ -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};
+65 -17
View File
@@ -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 })
+2 -1
View File
@@ -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
View File
@@ -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