Files
vectorcraft/crates/ui-egui/src/control.rs
T

960 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<usize, String> {
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<Vec<(String, Value)>, 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<ControlResponse>,
state: Arc<AtomicU8>,
}
/// 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<AtomicU8>);
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<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
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<String>,
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<Value, String>) -> Outcome {
match r {
Ok(v) => ok(v),
Err(e) => err(e),
}
}
pub fn all_commands(app: &VectorcraftApp) -> Value {
let mut v: Vec<Value> = 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::<Vec<_>>(),
"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::<Vec<_>>(),
// 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::<Vec<_>>(),
// 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::<Vec<_>>(),
// 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> {
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, &params) {
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" => {
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.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" => {
// 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.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" => {
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)
}
"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<Value, String> {
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<usize> {
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<u8> = 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 {
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use super::{ControlRequest, MAX_DIALOG_FIELDS, Outcome, handle};
use crate::state::Dialog;
use crate::{Services, VectorcraftApp};
fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value {
let (req, _) = ControlRequest::new(method, params);
let Outcome::Done(r) = handle(app, &egui::Context::default(), &req) else { panic!("expected immediate reply") };
r
}
#[test]
fn ui_set_turns_the_image_trace_preview_off_and_on() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
assert!(app.ui.image_trace_preview);
for on in [false, true] {
let r = input_request(&mut app, "ui.set", json!({"imageTracePreview": on}));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(app.ui.image_trace_preview, on);
}
}
#[test]
fn the_image_trace_preview_is_a_command() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "imageTrace.preview"));
assert_eq!(app.run("imageTrace.preview", json!({})), Ok(json!(false)), "toggles");
assert!(!app.ui.image_trace_preview);
assert_eq!(app.run("imageTrace.preview", json!({"on": false})), Ok(json!(false)), "stays off");
assert_eq!(app.run("imageTrace.preview", json!({"on": true})), Ok(json!(true)));
assert!(app.ui.image_trace_preview);
assert!(app.run("imageTrace.preview", json!({"on": "yes"})).is_err());
assert!(app.ui.image_trace_preview, "a bad value leaves it");
let r = input_request(&mut app, "engine.execute", json!({"command": "imageTrace.preview", "params": {"on": false}}));
assert_eq!(r["ok"], true, "{r}");
assert!(!app.ui.image_trace_preview, "the control channel reaches it");
}
#[test]
fn control_input_rejects_excessive_counts_before_mutation() {
// Only modest over-limit values on the baseline: never exercise an unbounded loop.
for (method, params) in
[("ui.click", json!({"count": 17})), ("ui.drag", json!({"steps": 257})), ("ui.drag", json!({"steps": 2, "count": 17}))]
{
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.synthetic.push(egui::Event::Text("already queued".into()));
let before = format!("{:?}", app.synthetic);
let r = input_request(&mut app, method, params);
assert_eq!(r["ok"], false, "oversized input must be rejected: {r}");
assert_eq!(format!("{:?}", app.synthetic), before, "rejection must preserve the entire queue");
}
}
#[test]
fn control_input_respects_existing_queue_budget() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(1.0, 2.0)); 1023];
let before = format!("{:?}", app.synthetic);
let r = input_request(&mut app, "ui.click", json!({}));
assert_eq!(r["ok"], false, "a whole click must fit before any part is appended");
assert_eq!(format!("{:?}", app.synthetic), before);
}
#[test]
fn control_input_yields_control_channel_after_frame_budget() {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx);
let mut replies = vec![];
for _ in 0..65 {
let (req, reply) = ControlRequest::new("ui.move", json!({"x": 1, "y": 2}));
tx.send(req).unwrap();
replies.push(reply);
}
app.drain_control(&egui::Context::default());
assert_eq!(replies.iter().filter(|r| r.try_recv().is_ok()).count(), 64);
assert_eq!(app.synthetic.len(), 64);
app.drain_control(&egui::Context::default());
assert_eq!(replies.last().unwrap().try_recv().unwrap()["ok"], true);
assert_eq!(app.synthetic.len(), 65);
}
#[test]
fn control_input_rejects_invalid_and_extreme_counts() {
for value in [json!(u64::MAX), json!(-1), json!(1.5), json!("2"), Value::Null] {
for (method, key) in [("ui.click", "count"), ("ui.drag", "count"), ("ui.drag", "steps")] {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.synthetic.push(egui::Event::Text("pending".into()));
let before = format!("{:?}", app.synthetic);
let mut params = json!({});
params[key] = value.clone();
let r = input_request(&mut app, method, params);
assert_eq!(r["ok"], false, "{method}/{key}: {r}");
assert!(r["error"].as_str().unwrap().contains(key));
assert_eq!(format!("{:?}", app.synthetic), before);
}
}
}
#[test]
fn control_input_accepts_boundary_gestures_and_preserves_event_order() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
let r = input_request(
&mut app,
"ui.drag",
json!({"x": 2, "y": 3, "toX": 20, "toY": 30,
"steps": 256, "count": 16, "button": "right", "shift": true}),
);
assert_eq!(r["ok"], true);
assert_eq!(app.synthetic.len(), 289);
assert!(matches!(app.synthetic.first(), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(2.0, 3.0)));
assert!(
matches!(app.synthetic.get(1), Some(egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Secondary, modifiers, .. }) if modifiers.shift)
);
assert!(matches!(app.synthetic.get(257), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(20.0, 30.0)));
assert!(matches!(app.synthetic.get(258), Some(egui::Event::PointerButton { pressed: false, .. })));
assert!(matches!(app.synthetic.last(), Some(egui::Event::PointerButton { pressed: false, pos, .. }) if *pos == egui::pos2(20.0, 30.0)));
for (method, params, events) in [
("ui.click", json!({}), 3),
("ui.click", json!({"count": 2}), 5),
("ui.drag", json!({}), 11),
("ui.drag", json!({"steps": 0, "count": 0}), 4),
] {
app.synthetic.clear();
assert_eq!(input_request(&mut app, method, params)["ok"], true);
assert_eq!(app.synthetic.len(), events);
}
app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(0.0, 0.0)); 1021];
assert_eq!(input_request(&mut app, "ui.click", json!({}))["ok"], true);
assert_eq!(app.synthetic.len(), 1024, "the exact queue boundary is permitted");
}
#[test]
fn control_input_budget_covers_every_synthetic_method() {
for (method, params) in [
("ui.key", json!({"key": "Enter", "text": "x"})),
("ui.move", json!({})),
("ui.wheel", json!({"dy": 1})),
("ui.text", json!({"text": "hello"})),
("ui.click", json!({})),
("ui.drag", json!({"steps": 1})),
] {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(1.0, 2.0)); 1024];
let before = format!("{:?}", app.synthetic);
let r = input_request(&mut app, method, params);
assert_eq!(r["ok"], false, "{method}: {r}");
assert_eq!(format!("{:?}", app.synthetic), before);
app.synthetic.clear();
assert_eq!(input_request(&mut app, method, json!({"key": "Enter", "steps": 1}))["ok"], true, "queue space becomes usable after draining");
}
}
/// `ui.dialog.set {fields}` stores the whole list in order, or nothing when an item is refused.
#[test]
fn dialog_set_stores_a_field_list_whole_or_not_at_all() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.ui.dialog = Some(crate::state::Dialog::new("move", json!({"dx": "0 pt", "dy": "0 pt"})));
let fields = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| serde_json::to_value(&d.fields).unwrap());
let before = fields(&app);
for params in [
json!({"fields": [{"field": "copy", "value": true}, {"value": 1}]}),
json!({"fields": [{"field": "copy", "value": true}, 7]}),
json!({"fields": {"field": "copy"}}),
json!({"field": "dx", "fields": [{"field": "copy", "value": true}]}),
json!({"fields": [{"field": "copy", "value": true}, {"field": "note"}]}),
] {
let r = input_request(&mut app, "ui.dialog.set", params.clone());
assert_eq!(r["ok"], false, "{params}: {r}");
assert_eq!(fields(&app), before, "{params}: a refused list stores no field");
}
let list = json!([{"field": "copy", "value": true}, {"field": "dx", "value": "3 pt"}, {"field": "dx", "value": "5 pt"}, {"field": "dy"}]);
let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list}));
assert_eq!(r["ok"], true, "{r}");
let after = fields(&app).unwrap();
assert_eq!(after, json!({"copy": true, "dx": "5 pt", "dy": null}));
assert_eq!(r["result"]["fields"], after);
let r = input_request(&mut app, "ui.dialog.set", json!({"field": "dy", "value": "2 pt"}));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(fields(&app).unwrap()["dy"], "2 pt");
app.ui.dialog = None;
assert_eq!(input_request(&mut app, "ui.dialog.set", json!({"fields": []}))["error"], "no dialog open");
}
/// `ui.dialog.set {fields}` stores at most `MAX_DIALOG_FIELDS` items; a longer list stores none.
#[test]
fn dialog_set_refuses_a_field_list_over_the_limit() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.ui.dialog = Some(crate::state::Dialog::new("move", json!({"dx": "0 pt"})));
let list = |n: usize| Value::Array((0..n).map(|i| json!({"field": "dx", "value": format!("{i} pt")})).collect());
let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list(MAX_DIALOG_FIELDS + 1)}));
assert_eq!(r["ok"], false, "{r}");
assert_eq!(r["error"], format!("`fields` holds {} items; at most {MAX_DIALOG_FIELDS}", MAX_DIALOG_FIELDS + 1));
assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["dx"].clone()), Some(json!("0 pt")), "a refused list stores no field");
let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list(MAX_DIALOG_FIELDS)}));
assert_eq!(r["ok"], true, "{r}");
let last = format!("{} pt", MAX_DIALOG_FIELDS - 1);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["dx"].clone()), Some(json!(last)), "the whole list is stored, in order");
}
/// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked
/// for, at the scale asked for; the first without one.
#[test]
fn render_takes_the_artboard_asked_for() {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx);
let mut request = |method: &str, params: Value| {
let (req, reply) = ControlRequest::new(method, params);
tx.send(req).unwrap();
app.drain_control(&egui::Context::default());
reply.try_recv().unwrap()
};
let new = json!({"width": 64, "height": 64, "units": "Points", "artboards": 2});
request("engine.execute", json!({"command": "file.new", "params": new}));
let board = json!({"index": 1, "x": 100, "y": 0, "width": 96, "height": 48});
let r = request("engine.execute", json!({"command": "artboard.setProps", "params": board}));
assert_eq!(r["ok"], true, "{r}");
for (params, size) in [
(json!({}), [64, 64]),
(json!({"artboard": 0}), [64, 64]),
(json!({"artboard": 1}), [96, 48]),
(json!({"artboard": 1, "scale": 0.5}), [48, 24]),
] {
let r = request("ui.render", params.clone());
let got = [&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64);
assert_eq!(got, size.map(Some), "{params}: {r}");
}
assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string());
}
/// `ui.pointer` replies with the key object: a click on one of two selected objects makes it
/// the key, and a click on the key again lets it go.
#[test]
fn pointer_replies_name_the_key_object() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
let b = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
app.run("tool.select", json!({"tool": "selection"})).unwrap();
let ctx = egui::Context::default();
let click = |app: &mut VectorcraftApp| {
let events = json!([{"kind": "down", "x": 110, "y": 20}, {"kind": "up", "x": 110, "y": 20}]);
let (req, _) = ControlRequest::new("ui.pointer", json!({"events": events}));
let Outcome::Done(r) = handle(app, &ctx, &req) else { panic!("not done") };
assert_eq!(r["ok"], true, "{r}");
r["result"].get("key").cloned()
};
assert_eq!(click(&mut app), Some(b), "a click on a selected object makes it the key");
assert_eq!(click(&mut app), Some(Value::Null), "a click on the key again lets it go");
}
/// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields,
/// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard`
/// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`.
#[test]
fn dialog_set_refuses_a_field_the_dialog_does_not_have() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
let fields = json!({"index": 0, "uid": 1, "name": "Untitled-1", "then": "close"});
app.ui.dialog = Some(Dialog::new(crate::unsaved::KIND, fields.clone()));
let r = input_request(&mut app, "ui.dialog.set", json!({"field": "discrad", "value": true}));
assert_eq!(r["ok"], false, "{r}");
assert_eq!(r["error"], "the `saveChanges` dialog has no field `discrad`; its fields: discard, index, name, then, uid");
assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields));
for field in ["name", "discard", "__note"] {
let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true}));
assert_eq!(r["ok"], true, "{field}: {r}");
}
// Move opens without Copy, which its checkbox adds once clicked; Scale's options and the
// reference point belong to the other transform dialogs.
let set = |app: &mut VectorcraftApp, kind: &str, fields: Value, cases: &[(&str, bool)]| {
app.ui.dialog = Some(Dialog::new(kind, fields));
for (field, ok) in cases {
let r = input_request(app, "ui.dialog.set", json!({"field": field, "value": true}));
assert_eq!(r["ok"], *ok, "{kind} {field}: {r}");
}
};
let moved =
[("copy", true), ("preview", true), ("distance", true), ("discard", false), ("copies", false), ("corners", false), ("origin", false)];
set(&mut app, "move", json!({"dx": "0 pt", "dy": "0 pt"}), &moved);
set(
&mut app,
"scale",
json!({"sx": 100, "sy": 100, "uniform": true}),
&[("corners", true), ("strokes", true), ("origin", true), ("distance", false)],
);
set(&mut app, "rotate", json!({"angle": 0}), &[("origin", true), ("objects", true), ("corners", false)]);
set(&mut app, "rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}), &[("radius", true), ("sides", false)]);
set(&mut app, "ellipse", json!({"x": 0, "y": 0, "width": 10, "height": 10}), &[("radius", false)]);
// Corners that differ in kind and radius open without either.
let mixed = json!({"id": 1, "corners": [0, 1, 2, 3], "relativeRadii": false});
set(&mut app, vectorcraft_tools::corners::DIALOG, mixed, &[("kind", true), ("radius", true), ("radii", false)]);
set(&mut app, "saveForOffice", json!({"ppi": 150, "transparent": false, "artboard": 0}), &[("path", true)]);
set(&mut app, "brushOptions", json!({"name": "Dots"}), &[("tips", true), ("tip", false)]);
set(&mut app, "justification", json!({"wordDesired": 100}), &[("wordMin", true), ("singleWord", true), ("wordMid", false)]);
set(&mut app, "hyphenation", json!({"hyphenate": true}), &[("afterFirst", true), ("hyphenationZone", true), ("afterfirst", false)]);
set(&mut app, "effectGallery", json!({"stack": []}), &[("browseEffect", true), ("browseParams", true), ("browse", false)]);
// A new artboard's dialog opens without a background color or display marks.
let new_board = json!({"new": true, "name": "Artboard 2", "x": 0, "y": 0, "width": 10, "height": 10});
let marks = [
("color", true),
("showCenterMark", true),
("showCrossHairs", true),
("showVideoSafeAreas", true),
("videoRulerPixelAspectRatio", true),
("showCenterMarks", false),
];
set(&mut app, "artboardOptions", new_board, &marks);
// Glass and Texturizer take the texture Load Texture… reads; other effects don't.
set(&mut app, "effect", json!({"__effect": "distort.glass"}), &[("textureImage", true)]);
set(&mut app, "effect", json!({"__effect": "blur.gaussian"}), &[("textureImage", false)]);
// The generic dialog takes every parameter of its command.
set(&mut app, "command", json!({"__command": "shape.rectangle", "x": 0, "y": 0}), &[("width", true), ("widht", false)]);
// Transparency Flattener Presets holds the selected preset's options before its first frame.
crate::dialogs::flattener_presets::open(&mut app, None);
let r = input_request(&mut app, "ui.dialog.set", json!({"field": "textToOutlines", "value": true}));
assert_eq!(r["ok"], true, "{r}");
// Edit Selection takes `deleted<i>` for each of its rows only.
app.run("file.new", json!({})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
for name in ["A", "B"] {
app.run("select.save", json!({ "name": name })).unwrap();
}
crate::dialogs::edit_selection::open(&mut app).unwrap();
for (field, ok) in [("deleted1", true), ("deleted", false), ("deletedx", false), ("deleted01", false), ("deleted2", false)] {
let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true}));
assert_eq!(r["ok"], ok, "{field}: {r}");
}
let r = input_request(&mut app, "ui.dialog.set", json!({"field": "name", "value": "A"}));
assert_eq!(
r["error"],
"the `editSelection` dialog has no field `name`; its fields: count, deleted0, deleted1, name0, name1, orig0, orig1, selected"
);
app.ui.dialog = None;
assert_eq!(input_request(&mut app, "ui.dialog.set", json!({"field": "name", "value": "A"}))["error"], "no dialog open");
}
}