Files
vectorcraft/crates/mcp/src/headless.rs
T
dotCooCoo 0921b8629e Shape Builder: hover and Alt hover share one region map
Holding Alt over a selection the Shape Builder had already mapped built
the planar map a second time, to add the outline edges that erasing
hits. A merge, and a Live Paint Bucket fill of a plain selection, also
dropped the map, so a gesture that left the document unchanged was
followed by another full build.

The Shape Builder now builds its map with the outline edges the first
time, and both tools keep the map until the document, its revision or
the selection changes.

When the inputs have more than 128 segments, a hover with the Shape
Builder or the Live Paint Bucket now builds the map on another thread
and highlights no region until it arrives, instead of holding up the
frame; the canvas checks for it each frame. A press waits for the map,
and the headless MCP host waits for it after a move. The web build has
no threads and builds the map at once. The Live Paint Selection tool's
hover no longer builds a map of the selection, which it did not use.
2026-10-24 23:15:51 +07:00

359 lines
17 KiB
Rust

//! An in-process engine session that answers the control-channel methods itself.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_engine::file_access::{self, AutomationRoots};
use vectorcraft_engine::{Session, UiRequest, ViewInfo};
use vectorcraft_geom::Point;
use vectorcraft_render::Renderer;
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, TOOL_GROUPS, ToolKey};
use crate::backend::Backend;
/// Headless backend: a [`Session`] plus a CPU [`Renderer`] for screenshots and PNG export.
pub struct Headless {
pub session: Session,
pub renderer: Renderer,
/// View state handed to tools (zoom 1, smart guides on).
pub view: ViewInfo,
/// The folders its methods may read and write (`--automation-read-root`,
/// `--automation-write-root`); `None`: anywhere.
roots: Option<Arc<AutomationRoots>>,
}
impl Default for Headless {
fn default() -> Self {
Self::new()
}
}
/// Commands the desktop app adds on top of the engine; the headless backend implements them too so
/// `run_command` behaves the same in both modes.
const HOST_COMMANDS: &[(&str, &str, &str)] = &[
("file.open", "Open…", "{path} open any readable file (see document.formats) as a new document; templates open untitled"),
(
"file.save",
"Save",
"{…as document.save} = document.save {path?, format?, options?, svg?: {…SVG options}}: .vectorcraft, .ai (a PDF that reopens editable), .pdf, .svg or .svgz (default: the document's own path and format)",
),
("file.export", "Export…", "{…as document.export} = document.export {path?, format?, artboard?, range?, scale?, …} (no path → dataBase64)"),
(
"file.exportForScreens",
"Export for Screens…",
"{folder?, zip?, artboards? | range? | fullDocument? | assets?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)",
),
("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"),
(
"tool.setOption",
"Tool Option",
"{key?, value? | values?: {key: value…}, tool?: id (default: the active tool)} → the tool's options (`{}` reads them); kept options last across tool switches",
),
];
fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
p.get(k).and_then(Value::as_str)
}
pub(crate) fn pointer_kind(k: &str) -> Option<PointerKind> {
Some(match k {
"down" => PointerKind::Down,
"drag" => PointerKind::Drag,
"up" => PointerKind::Up,
"move" => PointerKind::Move,
"doubleclick" | "dblclick" => PointerKind::DoubleClick,
_ => return None,
})
}
fn tool_key(name: &str) -> Option<ToolKey> {
Some(match name.to_ascii_lowercase().as_str() {
"enter" | "return" => ToolKey::Enter,
"esc" | "escape" => ToolKey::Escape,
"backspace" => ToolKey::Backspace,
"delete" => ToolKey::Delete,
"up" | "arrowup" => ToolKey::Up,
"down" | "arrowdown" => ToolKey::Down,
"left" | "arrowleft" => ToolKey::Left,
"right" | "arrowright" => ToolKey::Right,
"[" | "bracketleft" | "openbracket" => ToolKey::BracketLeft,
"]" | "bracketright" | "closebracket" => ToolKey::BracketRight,
"tab" => ToolKey::Tab,
k if k.len() == 1 && k.bytes().all(|b| b.is_ascii_lowercase()) => ToolKey::Letter(k.chars().next()?),
k if k.len() == 1 => ToolKey::Digit(k.parse().ok()?),
_ => return None,
})
}
/// Does a shortcut like `Cmd+Shift+]` match `key` + `mods`?
fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool {
let parts: Vec<&str> = shortcut.split('+').collect();
let Some((k, ms)) = parts.split_last() else { return false };
let has = |m: &str| ms.iter().any(|x| x.eq_ignore_ascii_case(m));
k.eq_ignore_ascii_case(key)
&& has("cmd") == mods.cmd
&& has("shift") == mods.shift
&& (has("alt") || has("option")) == mods.alt
&& has("ctrl") == mods.ctrl
}
impl Headless {
/// A session with no document (the first `file.new` / `app.open` creates one).
pub fn new() -> Self {
Self { session: Session::new(), renderer: Renderer::new(), view: ViewInfo::default(), roots: None }
}
/// Confine the files every method reads and writes to `roots` ([`file_access`]).
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
self.roots = roots;
self
}
/// A session with a fresh default (Letter) document, ready to draw into.
pub fn with_document() -> Self {
let mut h = Self::new();
h.ensure_document();
h
}
/// Create a default document when none is open.
pub fn ensure_document(&mut self) {
if self.session.active().is_none()
&& let Err(e) = self.session.execute("file.new", &json!({}))
{
log::error!("file.new failed: {e}");
}
}
fn exec(&mut self, id: &str, params: &Value) -> Result<Value, String> {
// A key the command's description doesn't name is an error, not a default (#1442).
match HOST_COMMANDS.iter().find(|c| c.0 == id) {
Some((_, _, doc)) => vectorcraft_engine::check_keys(id, doc, params),
None => self.session.check_params(id, params),
}
.map_err(|e| e.to_string())?;
match id {
"file.open" => self.open(params),
"file.save" => self.save(params),
"file.export" => self.export(params),
"file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()),
"tool.select" => self.select_tool(params),
"tool.setOption" => self.session.set_tool_option_cmd(params),
_ => self.session.execute(id, params).map_err(|e| e.to_string()),
}
}
fn commands(&self) -> Value {
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
for (id, label, params) in HOST_COMMANDS {
v.push(json!({"id": id, "label": label, "menu": [], "shortcut": null, "params": params, "enabled": true, "host": true}));
}
Value::Array(v)
}
fn select_tool(&mut self, p: &Value) -> Result<Value, String> {
let t = s(p, "tool").ok_or("missing `tool`")?;
if vectorcraft_tools::tool_info(t).is_none() {
return Err(format!("unknown tool `{t}` (see ui.tool.list)"));
}
self.session.select_tool(t, self.view).map_err(|e| e.to_string())?;
Ok(json!({"tool": self.session.tool_id()}))
}
fn apply_ui_requests(&mut self, reqs: Vec<UiRequest>, out: &mut Vec<Value>) -> Result<(), String> {
for r in reqs {
match r {
UiRequest::SwitchTool(t) => {
self.session.select_tool(&t, self.view).map_err(|e| e.to_string())?;
}
UiRequest::Dialog(k, p) => out.push(json!({"dialog": k, "params": p})),
UiRequest::Status(msg) => out.push(json!({"status": msg})),
// The place cursor's file, placed at once.
UiRequest::Place(p) => {
self.session.execute("file.place", &p).map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
fn pointer(&mut self, p: &Value) -> Result<Value, String> {
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();
self.ensure_document();
let mut requests = vec![];
for e in events {
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)"))?;
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, pressure: PointerEvent::json_pressure(e) };
let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut requests)?;
// With no frames to show the highlight in later, a hover waits for the work the tool
// runs on another thread.
if kind == PointerKind::Move && self.session.tool_waiting() {
self.session.tool_poll(self.view, true).map_err(|e| e.to_string())?;
}
let hold = PointerEvent::json_hold(e);
if hold > 0.0 {
let reqs = self.session.tool_tick(hold, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut requests)?;
}
}
let sel = self.session.active().map(|d| d.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>()).unwrap_or_default();
let key = self.session.active().and_then(|d| d.selection.key).map(|k| k.0);
Ok(json!({"selection": sel, "key": key, "tool": self.session.tool_id(), "requests": requests}))
}
fn key(&mut self, p: &Value) -> Result<Value, String> {
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 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 selection marquee is dragged); otherwise it is a shortcut.
let mut tk = tool_key(key);
if let Some(k @ ToolKey::Letter(_)) = tk
&& (mods.cmd || mods.ctrl || mods.alt || !self.session.tool_claims_key(k, self.view))
{
tk = None;
}
// A busy tool (pen path in progress, shape being dragged) gets its keys first, as do keys
// the tool claims (the Gradient tool's selected stop takes Delete and the arrows).
if let Some(k) = tk
&& (self.session.tool_busy() || self.session.tool_claims_key(k, self.view))
{
let mut out = vec![];
let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut out)?;
return Ok(json!({"handledBy": "tool", "requests": out}));
}
if let Some(c) = vectorcraft_engine::command_specs().iter().find(|c| c.shortcut.is_some_and(|sc| shortcut_matches(sc, key, mods))) {
let r = self.exec(c.id, &json!({}))?;
return Ok(json!({"handledBy": "command", "command": c.id, "result": r}));
}
if let Some(t) = TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.shortcut.is_some_and(|sc| shortcut_matches(sc, key, mods))) {
self.session.select_tool(t.id, self.view).map_err(|e| e.to_string())?;
return Ok(json!({"handledBy": "tool.select", "tool": t.id}));
}
// Backspace clears the selection as Delete does (as in the desktop app).
if tk == Some(ToolKey::Backspace) && mods == Mods::default() && self.session.active().is_some_and(|d| !d.selection.is_empty()) {
let r = self.exec("edit.clear", &json!({}))?;
return Ok(json!({"handledBy": "command", "command": "edit.clear", "result": r}));
}
if let Some(k) = tk {
let mut out = vec![];
let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut out)?;
return Ok(json!({"handledBy": "tool", "requests": out}));
}
Err(format!("no headless binding for key `{key}`"))
}
fn render(&mut self, p: &Value) -> Result<Value, String> {
let st = self.session.active().ok_or("no document")?;
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 scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0);
vectorcraft_render::raster_size(r, scale)?;
// Rendered with the options of a PNG export on white, so its pixels are the exported file's.
let opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png);
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.
vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?;
let png = img.to_png()?;
let mut out = json!({"width": img.width, "height": img.height});
match s(p, "path") {
Some(path) => {
file_access::check_write(path)?;
vectorcraft_format::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
out["path"] = json!(path);
// `data: true` asks for the image as well as the file.
if p.get("data").and_then(Value::as_bool) == Some(true) {
out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png));
}
}
None => out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png)),
}
Ok(out)
}
/// `app.open {path}`: any readable file as a new active document (`document.open`).
pub fn open(&mut self, p: &Value) -> Result<Value, String> {
let path = s(p, "path").ok_or("missing `path`")?;
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.session.execute("document.open", &json!({"path": path}))).map_err(|e| e.to_string())
}
/// `app.save {path?, format?, options?}`: `document.save` (by default the document's own path and
/// format); remembers the path and clears the dirty flag.
pub fn save(&mut self, p: &Value) -> Result<Value, String> {
let st = self.session.active().ok_or("no document")?;
if s(p, "path").is_none() && st.path.is_none() {
return Err("missing `path` (document was never saved)".into());
}
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.session.execute("document.save", p)).map_err(|e| e.to_string())
}
/// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension).
pub fn export(&mut self, p: &Value) -> Result<Value, String> {
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.session.execute("document.export", p)).map_err(|e| e.to_string())
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.answer(method, &params))
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"headless".into()
}
}
impl Headless {
/// One control-channel method ([`Backend::call`]).
fn answer(&mut self, method: &str, p: &Value) -> Result<Value, String> {
match method {
"engine.execute" | "ui.menu.invoke" | "command" => {
let id = s(p, "command").or(s(p, "id")).ok_or("missing `command`")?.to_string();
let params = match p.get("params") {
None | Some(Value::Null) => json!({}),
Some(v) => v.clone(),
};
self.exec(&id, &params)
}
"engine.commands" => Ok(self.commands()),
"document.inspect" => self.exec("document.inspect", p),
"document.node" => self.exec("document.node", p),
"document.json" => self.exec("document.json", &json!({})),
"ui.tool.select" => self.select_tool(p),
"ui.tool.list" => Ok(serde_json::to_value(TOOL_GROUPS).unwrap_or_default()),
"ui.pointer" => self.pointer(p),
"ui.key" => self.key(p),
"ui.render" | "ui.screenshot" => self.render(p),
"app.open" => self.open(p),
"app.save" => self.save(p),
"app.export" => self.export(p),
"app.quit" => Ok(Value::Null),
m if m.starts_with("ui.") => {
Err(format!("`{m}` needs the desktop app (start `vectorcraft --control 7979` and use `vectorcraft-cli mcp --connect`)"))
}
other => Err(format!("unknown method `{other}`")),
}
}
}