mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
After imageTrace.setView, screenshot and render_preview still drew the tracing result, because ui.render renders with the export options and only the canvas turns on trace views. Only the app window showed the view. ui.render now takes traceViews. With traceViews: true, Image Trace objects draw in the view set with imageTrace.setView, as on the canvas, both headless and in the running app. screenshot and render_preview take traceViews and pass it on; without it they draw the tracing result, as exports do. The trace-and-style prompt names the argument. A supersampled render drew Outline view's lines, type outlines and dotted guides as little as a quarter of an output pixel wide, so they came out pale gray. The renderer now scales those widths and the guides' dashes by the supersampling factor in the larger frame, so the lines stay one output pixel wide. Docs: docs/mcp.md (render_preview, screenshot), docs/control-protocol.md (ui.render).
361 lines
17 KiB
Rust
361 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.
|
|
// `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 = 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, ¶ms))
|
|
}
|
|
|
|
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, ¶ms)
|
|
}
|
|
"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}`")),
|
|
}
|
|
}
|
|
}
|