//! Programmatic control of the running app (agents, tests, MCP). //! //! Methods (JSON-lines over the host's transport): //! - `engine.execute {command, params}` / `ui.menu.invoke {command|id, params}`: run any command //! - `engine.commands`: engine + UI commands with enablement //! - `document.inspect`: document summary; `ui.inspect`: UI state //! - `ui.docking`: the panel layout in use, the open flyout, the focused panel and the docking //! controls' rects //! - `ui.menu.list`: flattened menu tree //! - `ui.contextMenu.list`: the canvas context menu for the current selection, flattened //! - `ui.tool.select {tool}`, `ui.tool.list` //! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`: //! drive the active tool through the same path as the mouse //! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input //! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`: //! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs) //! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` //! - `ui.dialog.set {field, value}` or `{fields: [{field, value}…]}` (all or none; setting a field //! the dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`]) / //! `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG //! comes back as `pngBase64` as well) //! - `ui.render {path?, scale?, artboard?, data?, traceViews?}`: render an artboard headlessly //! (`artboard`: its 0-based index, default the first; PNG with the pixels of a PNG export on //! white; `traceViews: true` draws Image Trace objects in their View, as the canvas does; without //! a path, or with `data: true`, as `pngBase64`) //! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit` //! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each //! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard) //! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through //! the host; the document keeps its path. No path → `{dataBase64, format, bytes}` (as headless) //! - `wait: true` on `app.save` and `app.export`: the file is written before the reply, even with //! Background Save or Background Export on. A `wait` other than true, false or null is refused use std::sync::Arc; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::mpsc::Sender; use serde_json::{Value, json}; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; use crate::VectorcraftApp; use crate::canvas::Xf; /// Synthetic pointer input advances one event per frame: keep gestures and backlog modest. pub(crate) const MAX_DRAG_STEPS: u64 = 256; pub(crate) const MAX_CLICK_COUNT: u64 = 16; pub(crate) const MAX_SYNTHETIC_EVENTS: usize = 1024; pub(crate) const MAX_CONTROL_REQUESTS_PER_FRAME: usize = 64; /// Most items one `ui.dialog.set {fields}` may store, as MCP `answer_dialog` allows. pub(crate) const MAX_DIALOG_FIELDS: usize = 256; fn input_count(p: &Value, key: &str, default: u64, max: u64) -> Result { let n = match p.get(key) { None => default, Some(v) => v.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))?, }; if n > max { return Err(format!("`{key}` must be at most {max}")); } // Preserve legacy zero behaviour: both a zero count and zero steps still perform once. usize::try_from(n.max(1)).map_err(|_| format!("`{key}` is out of range")) } /// The fields one `ui.dialog.set` stores, in order: `{field, value?}`, or `{fields: [{field, value?}…]}`. /// Every item is checked before any is stored, so a refused item leaves the dialog as it was. fn dialog_fields(p: &Value) -> Result, String> { let Some(list) = p.get("fields") else { let f = p.get("field").and_then(Value::as_str).ok_or("missing `field`")?; return Ok(vec![(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null))]); }; if p.get("field").is_some() { return Err("give `field` or `fields`, not both".into()); } let list = list.as_array().ok_or("`fields` must be a list of {field, value?}")?; if list.len() > MAX_DIALOG_FIELDS { return Err(format!("`fields` holds {} items; at most {MAX_DIALOG_FIELDS}", list.len())); } list.iter() .enumerate() .map(|(i, item)| { let f = item.get("field").and_then(Value::as_str).ok_or_else(|| format!("fields {i}: missing string `field`"))?; Ok((f.to_string(), item.get("value").cloned().unwrap_or(Value::Null))) }) .collect() } fn reserve_synthetic(app: &mut VectorcraftApp, additional: usize) -> Result<(), String> { if additional > MAX_SYNTHETIC_EVENTS.saturating_sub(app.synthetic.len()) { return Err(format!("synthetic input queue limit ({MAX_SYNTHETIC_EVENTS} events); retry after pending input drains")); } app.synthetic.try_reserve(additional).map_err(|e| format!("cannot reserve synthetic input: {e}")) } pub type ControlResponse = Value; pub struct ControlRequest { pub method: String, pub params: Value, pub reply: Sender, state: Arc, } /// A request's state: waiting to run, taken by the app ([`ControlRequest::claim`]), or given up /// by its caller before that ([`Ticket::give_up`]). It leaves `QUEUED` once, one way or the other. const QUEUED: u8 = 0; const STARTED: u8 = 1; const GIVEN_UP: u8 = 2; /// The caller's side of a queued [`ControlRequest`]. pub struct Ticket(Arc); impl Ticket { /// Stop waiting for the request → `true` when it had not started, and so never runs: a caller /// told "timeout" can then retry without the edit being applied twice, or applied later when /// nobody expects it (#1725). `false`: the app is running it (or has); its outcome is unknown. pub fn give_up(&self) -> bool { self.0.compare_exchange(QUEUED, GIVEN_UP, Ordering::SeqCst, Ordering::SeqCst).is_ok() } } impl ControlRequest { pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { let (tx, rx) = std::sync::mpsc::channel(); (Self { method: method.into(), params, reply: tx, state: Arc::new(AtomicU8::new(QUEUED)) }, rx) } /// The caller's handle on this request, to give it up with. pub fn ticket(&self) -> Ticket { Ticket(self.state.clone()) } /// Take the request to run it → `false` when its caller gave it up meanwhile (don't run it). pub fn claim(&self) -> bool { self.state.compare_exchange(QUEUED, STARTED, Ordering::SeqCst, Ordering::SeqCst).is_ok() } } pub enum Outcome { Done(Value), /// A window capture, answered once a frame was presented: written to `path`, the PNG sent /// back too with `data`. Screenshot { path: Option, data: bool, }, } fn ok(v: Value) -> Outcome { Outcome::Done(json!({"ok": true, "result": v})) } pub(crate) fn err(e: impl std::fmt::Display) -> Outcome { Outcome::Done(json!({"ok": false, "error": e.to_string()})) } fn wrap(r: Result) -> Outcome { match r { Ok(v) => ok(v), Err(e) => err(e), } } pub fn all_commands(app: &VectorcraftApp) -> Value { let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); for (id, label, sc, params) in crate::menus::UI_COMMANDS { v.push(json!({"id": id, "label": label, "shortcut": sc, "params": params, "enabled": crate::menus::enabled(app, id), "ui": true})); } Value::Array(v) } pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { let r = ctx.content_rect(); let mut ui = serde_json::to_value(&app.ui).unwrap_or_default(); // The old floating panels' record, kept only to read older preferences: `ui.docking` has // the layout. if let Some(fields) = ui.as_object_mut() { fields.remove("floating_panels"); } json!({ "tool": app.session.tool_id(), "toolOptions": app.session.tool_options(), "ui": ui, // Not saved with the UI state, so not in `ui`: 0 normal … 3 Presentation Mode. "screenMode": app.ui.screen_mode, // The Contextual Task Bar: pinned (not saved either) and where it shows. "taskBar": { "pinned": app.ui.task_bar_place.pinned, "rect": crate::canvas::task_bar_rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])), }, // The Free Transform tool's widget, where it shows. "freeTransformWidget": crate::free_transform::rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])), "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), "window": [r.width(), r.height()], "documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::>(), "activeDocument": app.session.active_index(), "perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps}, "graphicsAdapter": app.graphics_adapter, // The menus are in the macOS menu bar rather than the window. "nativeMenuBar": app.services.native_menu.is_some(), // Saves and exports still being written in the background (Background Save / Export). "background": app.background.jobs.iter().filter(|j| !j.label.is_empty()).map(|j| j.label.as_str()).collect::>(), // Documents still being read off the UI thread (File › Open, Open Recent): their paths. "opening": app.opening.pending.iter().map(|p| p.path.as_str()).collect::>(), // Files still being read off the UI thread for Place (the Place dialog's OK, drops): their // names, in the order they are placed. "placing": app.placing.pending.iter().map(|p| p.name.as_str()).collect::>(), // The screen color sampler is shown, waiting for the person at the computer // (`ui.sampleScreenColor`). "screenSampler": app.screen_sampler.is_some(), // A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), }) } fn key_from(name: &str) -> Option { egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { "enter" | "return" => Some(egui::Key::Enter), "esc" | "escape" => Some(egui::Key::Escape), "delete" => Some(egui::Key::Delete), "backspace" => Some(egui::Key::Backspace), "left" => Some(egui::Key::ArrowLeft), "right" => Some(egui::Key::ArrowRight), "up" => Some(egui::Key::ArrowUp), "down" => Some(egui::Key::ArrowDown), "space" => Some(egui::Key::Space), "tab" => Some(egui::Key::Tab), _ => None, }) } /// The modifier keys a request holds down (`cmd`, `ctrl`, `alt`, `shift`; `cmd` is Command on /// macOS, Ctrl elsewhere). fn modifiers(p: &Value) -> egui::Modifiers { let b = |n: &str| p.get(n).and_then(Value::as_bool).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") } } pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { "engine.execute" | "ui.menu.invoke" | "command" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().unwrap_or(json!({})); let params = if params.is_null() { json!({}) } else { params }; // A key the command's description doesn't name is an error, not a default (#1442). if let Err(e) = crate::menus::check_params(&app.session, id, ¶ms) { return err(e); } wrap(app.run(id, params)) } "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", p.clone())), "ui.inspect" => ok(inspect(app, ctx)), "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), "ui.tool.list" => ok(serde_json::to_value(vectorcraft_tools::TOOL_GROUPS).unwrap_or_default()), "ui.pointer" => pointer_events(app, ctx, req), "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) { return err(e); } let m = modifiers(p); 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. if let Some(t) = s("text") { app.synthetic.push(egui::Event::Text(t.to_string())); } app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); ctx.request_repaint(); ok(Value::Null) } "ui.move" => { if let Err(e) = reserve_synthetic(app, 1) { 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)); ctx.request_repaint(); ok(Value::Null) } "ui.click" | "ui.drag" => synthetic_click(app, ctx, req), "ui.wheel" => { if let Err(e) = reserve_synthetic(app, 2) { 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 }; // 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)); app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))); app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers: modifiers(p), source: egui::MouseWheelSource::Unknown, }); ctx.request_repaint(); ok(Value::Null) } "ui.text" => { if let Err(e) = reserve_synthetic(app, 1) { return err(e); } app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); ctx.request_repaint(); ok(Value::Null) } "ui.set" => set_ui(app, req), "ui.dialog.set" => match app.ui.dialog.as_mut() { Some(d) => { let fields = match dialog_fields(p) { Ok(fields) => fields, Err(e) => return err(e), }; // Every field is checked before any is set, so a request sets all of them or none. if let Some((f, _)) = fields.iter().find(|(f, _)| !crate::dialogs::accepts_field(d, f)) { let names = crate::dialogs::field_names(d).join(", "); return err(format!("the `{}` dialog has no field `{f}`; its fields: {names}", d.kind)); } for (f, value) in fields { d.fields.insert(f, value); } ok(serde_json::to_value(&*d).unwrap_or_default()) } None => err("no dialog open"), }, "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { crate::dialogs::cancel(app); ok(Value::Null) } "ui.resize" => { let w = p.get("width").and_then(Value::as_f64).unwrap_or(1440.0) as f32; let h = p.get("height").and_then(Value::as_f64).unwrap_or(900.0) as f32; ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); ok(Value::Null) } "ui.focus" => { ctx.send_viewport_cmd(egui::ViewportCommand::Focus); ok(Value::Null) } "ui.screenshot" => { // Refused now rather than once the frame comes (it is checked again then). if let Some(Err(e)) = s("path").map(vectorcraft_engine::file_access::check_write) { return err(e); } if p.get("focus").and_then(Value::as_bool).unwrap_or(true) { ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } ctx.request_repaint(); Outcome::Screenshot { path: s("path").map(str::to_string), data: p.get("data").and_then(Value::as_bool) == Some(true) } } "ui.render" => { let Some(st) = app.session.active() else { return err("no document") }; let doc = st.doc.clone(); // 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) = index.and_then(|i| doc.artboards.get(i)).map(|a| a.rect) else { return err("no such artboard") }; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0); if let Err(e) = vectorcraft_render::raster_size(r, scale) { return err(e); } // 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. 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); let img = app.canvas.renderer.render_region_with(&doc, r, scale, &opts); let png = match img.to_png() { Ok(png) => png, Err(e) => return err(e), }; let data = p.get("data").and_then(Value::as_bool) == Some(true); match s("path") { Some(path) => match app.services.write.as_mut() { Some(w) => wrap( w(path, &png) .map(|_| with_data(json!({"path": path, "width": img.width, "height": img.height}), data.then_some(png.as_slice()))), ), None => err("no writer"), }, None => ok(with_data(json!({"width": img.width, "height": img.height}), Some(&png))), } } "app.open" => wrap(app.run("file.open", json!({"path": s("path")}))), "app.save" | "app.export" => { let mut p = if p.is_object() { p.clone() } else { json!({}) }; // `wait: true`: the file is written before the reply, whatever Background Save and // Background Export are set to, so the reply is the written file or the error. let wait = match crate::menus::opt_bool(&p, "wait") { Ok(w) => w == Some(true), Err(e) => return err(e), }; if let Some(o) = p.as_object_mut() { o.remove("wait"); } let method = req.method.as_str(); wrap(if wait { crate::background::in_foreground(app, |app| save_or_export(app, method, &p)) } else { save_or_export(app, method, &p) }) } // As the menu's Quit: `{"pending": "saveChanges"}` while a modified document is asked // about; otherwise null, and the host closes the window (#830). "app.quit" => wrap(app.run("app.quit", json!({}))), other => err(format!("unknown method `{other}`")), } } /// `app.save` (`method`) or `app.export` with params `p`. fn save_or_export(app: &mut VectorcraftApp, method: &str, p: &Value) -> Result { if method == "app.save" { return app.run("file.save", p.clone()); } let s = |k: &str| p.get(k).and_then(Value::as_str); match s("path") { Some(path) => crate::io::export(app, s("format"), Some(path.to_string()), p), // No save dialog for an agent: the bytes come back, as in headless mode. None => app.run("document.export", p.clone()), } } /// `result` with the PNG as `pngBase64` when there is one. fn with_data(mut result: Value, png: Option<&[u8]>) -> Value { if let Some(png) = png { result["pngBase64"] = json!(vectorcraft_format::base64_encode(png)); } result } /// The largest image whose PNG goes back in a `ui.screenshot` reply (`data: true`): the reply /// makes a straight-alpha copy, the encoded PNG and, for `data`, a base64 string (~1.33× the /// PNG), so this bounds that working set before any of it is allocated. A path-only capture keeps /// the shared 128 MiB image budget. pub(crate) const MAX_SCREENSHOT_PNG_REPLY_BYTES: usize = 64 * 1024 * 1024; /// The checked RGBA size of `w × h`, when it fits. fn screenshot_rgba_bytes(w: usize, h: usize) -> Option { w.checked_mul(h).and_then(|n| n.checked_mul(4)) } /// The reply to `ui.screenshot`: the window capture `image` written to `path`, and sent back as /// `pngBase64` with `data`. pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: Option<&str>, data: bool) -> Value { save_screenshot_with_limit(app, image, path, data, MAX_SCREENSHOT_PNG_REPLY_BYTES) } /// [`save_screenshot`] with the inline-PNG encoding limit supplied, so a small image can exercise /// the over-limit path without allocating a multi-megapixel frame. The public function always uses /// [`MAX_SCREENSHOT_PNG_REPLY_BYTES`]. pub(crate) fn save_screenshot_with_limit( app: &mut VectorcraftApp, image: &egui::ColorImage, path: Option<&str>, data: bool, max_reply_bytes: usize, ) -> Value { let [w, h] = image.size; if path.is_none() && !data { return json!({"ok": true, "result": {"width": w, "height": h}}); } // A data-only reply has no second way to deliver the image, so refuse an oversized one before // allocating its working set. A request that named a path still writes that file: only the // inline copy is downgraded. if data && path.is_none() && screenshot_rgba_bytes(w, h).is_none_or(|bytes| bytes > max_reply_bytes) { return json!({"ok": false, "error": format!( "a PNG reply of this {w}×{h} screenshot would exceed the {max_reply_bytes}-byte reply-encoding limit; write it to a path instead" )}); } let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); let img = vectorcraft_render::Rendered { width: w as u32, height: h as u32, pixels: rgba }; let png = match img.to_png() { Ok(png) => png, Err(e) => return json!({"ok": false, "error": e}), }; let reply_png = data && png.len() <= max_reply_bytes; if data && !reply_png && path.is_none() { return json!({"ok": false, "error": format!( "this screenshot's PNG is {} bytes, over the {max_reply_bytes}-byte reply-encoding limit; write it to a path instead", png.len() )}); } let data = reply_png.then_some(png.as_slice()); let Some(path) = path else { return json!({"ok": true, "result": with_data(json!({"width": w, "height": h}), data)}); }; match app.services.write.as_mut() { Some(wr) => match wr(path, &png) { Ok(()) => json!({"ok": true, "result": with_data(json!({"path": path, "width": w, "height": h}), data)}), Err(e) => json!({"ok": false, "error": e}), }, None => json!({"ok": false, "error": "no writer configured"}), } } #[cfg(test)] mod tests; /// `ui.pointer`: pointer events through the tools. fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); for e in events { let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { "down" => PointerKind::Down, "drag" => PointerKind::Drag, "up" => PointerKind::Up, "move" => PointerKind::Move, "doubleclick" | "dblclick" => PointerKind::DoubleClick, other => return err(format!("unknown pointer kind `{other}`")), }; let 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 pos = if e.get("space").and_then(Value::as_str) == Some("screen") { match xf { Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)), None => return err("no canvas yet"), } } else { vectorcraft_geom::Point::new(x, y) }; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view); let hold = PointerEvent::json_hold(e); if hold > 0.0 { let r = app.session.tool_tick(hold, view); crate::canvas::apply_requests(app, r); } } ctx.request_repaint(); wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()}))) } /// `ui.click` and `ui.drag`: pointer input through egui itself. fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); // Validate the entire expansion before appending even the initial move/press. let steps = if req.method == "ui.drag" { match input_count(p, "steps", 8, MAX_DRAG_STEPS) { Ok(n) => n, Err(e) => return err(e), } } else { 0 }; let count = match input_count(p, "count", 1, MAX_CLICK_COUNT) { Ok(n) => n, Err(e) => return err(e), }; 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"); }; if let Err(e) = reserve_synthetic(app, events) { 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") { Some("right") | Some("secondary") => egui::PointerButton::Secondary, _ => 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::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { for i in 1..=steps { let t = i as f32 / steps as f32; app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); } } app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); for _ in 1..count { app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); } ctx.request_repaint(); ok(Value::Null) } /// `ui.set`: view flags, panels and status. fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); let mut r = Ok(Value::Null); if let Some(b) = s("brightness") { r = app.run("window.brightness", json!({"brightness": b})); } if let Some(pn) = s("panel") { r = app.run("window.panel", json!({"panel": pn})); } if let Some(st) = s("status") { app.ui.status = st.to_string(); } for (k, flag) in [ ("rulers", "view.rulers"), ("outline", "view.outline"), ("grid", "view.grid"), ("smartGuides", "view.smartGuides"), ("boundingBox", "view.boundingBox"), ] { if let Some(want) = p.get(k).and_then(Value::as_bool) { let cur = match k { "rulers" => app.ui.view.rulers, "outline" => app.ui.view.outline, "grid" => app.ui.view.grid, "smartGuides" => app.ui.view.smart_guides, _ => app.ui.view.bounding_box, }; if cur != want { r = app.run(flag, json!({})); } } } if let Some(v) = p.get("controlBar").and_then(Value::as_bool) { app.ui.control_bar = v; } if let Some(v) = p.get("imageTracePreview").and_then(Value::as_bool) { r = app.run("imageTrace.preview", json!({"on": v})); } if p.get("closePanel").is_some() { app.ui.open_panel = None; } wrap(r) }