Holding the brush still keeps Twirl, Pucker and Bloat applying, scaled by the time held: a new Tool::tick (time supplied by the host: canvas frames, or `holdMs` on ui.pointer / pointer_gesture events) repeats the last point every 0.1 s, one more dab there. Liquify never touches graphs, meshes, type, symbols, images, or envelopes, repeats and blends with their contents, nor guides in selection mode; those under the brush come back in `skipped` with a `warning` that the status bar shows (UiRequest::Status). A drag applies only the dabs each new sample adds (the stroke's state is kept between previews), giving the same result as the whole stroke, so journal replay still reproduces it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
16 KiB
Rust
346 lines
16 KiB
Rust
//! 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.menu.list`: flattened menu tree
|
|
//! - `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}` / `ui.dialog.confirm` / `ui.dialog.cancel`
|
|
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}`
|
|
//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG)
|
|
//! - `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)
|
|
|
|
use std::sync::mpsc::Sender;
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
|
|
|
use crate::VectorcraftApp;
|
|
use crate::canvas::Xf;
|
|
|
|
pub type ControlResponse = Value;
|
|
|
|
pub struct ControlRequest {
|
|
pub method: String,
|
|
pub params: Value,
|
|
pub reply: Sender<ControlResponse>,
|
|
}
|
|
|
|
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 }, rx)
|
|
}
|
|
}
|
|
|
|
pub enum Outcome {
|
|
Done(Value),
|
|
Screenshot { path: Option<String> },
|
|
}
|
|
|
|
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();
|
|
json!({
|
|
"tool": app.session.tool_id(),
|
|
"toolOptions": app.session.tool_options(),
|
|
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
|
|
"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},
|
|
// Saves and exports still being written in the background (Background Save / Export).
|
|
"background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
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 };
|
|
wrap(app.run(id, params))
|
|
}
|
|
"engine.commands" => ok(all_commands(app)),
|
|
"document.inspect" => wrap(app.run("document.inspect", json!({}))),
|
|
"ui.inspect" => ok(inspect(app, ctx)),
|
|
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_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"], "tool": app.session.tool_id()})))
|
|
}
|
|
"ui.key" => {
|
|
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
|
|
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
|
|
let m = egui::Modifiers {
|
|
alt: b("alt"),
|
|
ctrl: b("ctrl"),
|
|
shift: b("shift"),
|
|
mac_cmd: b("cmd") && cfg!(target_os = "macos"),
|
|
command: b("cmd"),
|
|
};
|
|
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" => {
|
|
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" => {
|
|
// 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 b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
|
|
let modifiers = egui::Modifiers {
|
|
alt: b("alt"),
|
|
ctrl: b("ctrl"),
|
|
shift: b("shift"),
|
|
mac_cmd: b("cmd") && cfg!(target_os = "macos"),
|
|
command: b("cmd"),
|
|
};
|
|
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" {
|
|
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).max(1);
|
|
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 });
|
|
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
|
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.text" => {
|
|
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 p.get("closePanel").is_some() {
|
|
app.ui.open_panel = None;
|
|
}
|
|
wrap(r)
|
|
}
|
|
"ui.dialog.set" => match app.ui.dialog.as_mut() {
|
|
Some(d) => {
|
|
let Some(f) = s("field") else { return err("missing `field`") };
|
|
d.fields.insert(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
|
|
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" => {
|
|
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) }
|
|
}
|
|
"ui.render" => {
|
|
let Some(st) = app.session.active() else { return err("no document") };
|
|
let doc = st.doc.clone();
|
|
let Some(r) = doc.artboards.first().map(|a| a.rect) else { return err("no 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);
|
|
}
|
|
let img = app.canvas.renderer.render_region(&doc, r, scale, true);
|
|
let png = match img.to_png() {
|
|
Ok(png) => png,
|
|
Err(e) => return err(e),
|
|
};
|
|
match s("path") {
|
|
Some(path) => match app.services.write.as_mut() {
|
|
Some(w) => wrap(w(path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height}))),
|
|
None => err("no writer"),
|
|
},
|
|
None => ok(json!({"width": img.width, "height": img.height, "pngBase64": vectorcraft_format::base64_encode(&png)})),
|
|
}
|
|
}
|
|
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
|
|
"app.save" => wrap(app.run("file.save", if p.is_object() { p.clone() } else { json!({}) })),
|
|
"app.export" => match s("path") {
|
|
Some(path) => wrap(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 => wrap(app.run("document.export", p.clone())),
|
|
},
|
|
"app.quit" => {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
ok(Value::Null)
|
|
}
|
|
other => err(format!("unknown method `{other}`")),
|
|
}
|
|
}
|
|
|
|
pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
|
|
let [w, h] = image.size;
|
|
let Some(path) = path else {
|
|
return json!({"ok": true, "result": {"width": w, "height": h}});
|
|
};
|
|
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}),
|
|
};
|
|
match app.services.write.as_mut() {
|
|
Some(wr) => match wr(path, &png) {
|
|
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
|
|
Err(e) => json!({"ok": false, "error": e}),
|
|
},
|
|
None => json!({"ok": false, "error": "no writer configured"}),
|
|
}
|
|
}
|