mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 04:49:52 +00:00
1139 lines
53 KiB
Rust
1139 lines
53 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.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, backquote?, space?: "doc"|"screen"}], mods?, backquote?}`:
|
||
//! drive the active tool through the same path as the mouse, after the synthetic input sent
|
||
//! before it was applied
|
||
//! - `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`];
|
||
//! `action: confirm | cancel` then confirms or cancels that dialog in the same request and replies
|
||
//! `{dialog, result}`) / `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, AtomicU64, 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()
|
||
}
|
||
|
||
/// What `ui.dialog.set {action}` does with the dialog once its fields are set.
|
||
enum DialogAnswer {
|
||
Confirm,
|
||
Cancel,
|
||
}
|
||
|
||
/// The `action` of a `ui.dialog.set`: none, `confirm` or `cancel`. It is checked before any field is set.
|
||
fn dialog_action(p: &Value) -> Result<Option<DialogAnswer>, String> {
|
||
match p.get("action") {
|
||
None => Ok(None),
|
||
Some(v) if v == "confirm" => Ok(Some(DialogAnswer::Confirm)),
|
||
Some(v) if v == "cancel" => Ok(Some(DialogAnswer::Cancel)),
|
||
Some(_) => Err("`action` must be confirm or cancel".into()),
|
||
}
|
||
}
|
||
|
||
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(crate) struct PendingPointerReply {
|
||
pub from: u64,
|
||
pub to: u64,
|
||
pub document: Option<u64>,
|
||
pub reply: Sender<ControlResponse>,
|
||
}
|
||
|
||
pub struct ControlRequest {
|
||
pub method: String,
|
||
pub params: Value,
|
||
pub reply: Sender<ControlResponse>,
|
||
state: Arc<AtomicU8>,
|
||
/// Its place in the order requests were made ([`ControlRequest::next_seq`]).
|
||
seq: u64,
|
||
}
|
||
|
||
/// The sequence number of the next request made.
|
||
static NEXT_SEQ: AtomicU64 = AtomicU64::new(0);
|
||
|
||
/// 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();
|
||
let seq = NEXT_SEQ.fetch_add(1, Ordering::SeqCst);
|
||
(Self { method: method.into(), params, reply: tx, state: Arc::new(AtomicU8::new(QUEUED)), seq }, rx)
|
||
}
|
||
|
||
/// The sequence number the next request made gets: every request made so far is before it.
|
||
pub fn next_seq() -> u64 {
|
||
NEXT_SEQ.load(Ordering::SeqCst)
|
||
}
|
||
|
||
/// Whether this request was made before the one numbered `seq`.
|
||
pub fn made_before(&self, seq: u64) -> bool {
|
||
self.seq < seq
|
||
}
|
||
|
||
/// The caller's handle on this request, to give it up with.
|
||
pub fn ticket(&self) -> Ticket {
|
||
Ticket(self.state.clone())
|
||
}
|
||
|
||
/// Whether its caller gave it up (it never runs), without taking it.
|
||
pub fn given_up(&self) -> bool {
|
||
self.state.load(Ordering::SeqCst) == GIVEN_UP
|
||
}
|
||
|
||
/// 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,
|
||
},
|
||
/// A `ui.pointer` reply ([`pointer_reply`]), sent once the events numbered `from..to`
|
||
/// ([`vectorcraft_engine::Session::input_applied`]) have been made and the edit they started
|
||
/// on another thread is applied, so it shows the selection after the edit.
|
||
AfterToolEdit {
|
||
from: u64,
|
||
to: u64,
|
||
document: Option<u64>,
|
||
},
|
||
}
|
||
|
||
/// The reply to `ui.pointer`: the selection, the key object and the active tool.
|
||
pub(crate) fn pointer_reply(app: &mut VectorcraftApp) -> Value {
|
||
pointer_reply_for(app, app.session.active().map(|d| d.uid))
|
||
}
|
||
|
||
pub(crate) fn pointer_reply_for(app: &VectorcraftApp, document: Option<u64>) -> Value {
|
||
let state = document.and_then(|uid| app.session.documents().iter().find(|d| d.uid == uid));
|
||
match state {
|
||
Some(state) => json!({
|
||
"ok": true,
|
||
"result": {
|
||
"selection": state.selection.objects.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||
"key": state.selection.key.map(|id| id.0),
|
||
"tool": app.session.tool_id(),
|
||
}
|
||
}),
|
||
None => json!({"ok": false, "error": "the gesture's document is no longer open"}),
|
||
}
|
||
}
|
||
|
||
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");
|
||
leave_out_unreadable_paths(fields);
|
||
}
|
||
let mut inspected = json!({
|
||
"scriptRunning": app.session.script_running(),
|
||
"scriptReport": app.session.script_report(),
|
||
"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 › Arrange › Tile (not saved, so not in `ui`) and the panes the last frame drew:
|
||
// each one's document index, view number (Window › New Window) and rect. Canvas
|
||
// coordinates map to the current window's pane, inside its rulers and scroll bars
|
||
// (`canvasRect`).
|
||
"tiled": app.ui.tiled,
|
||
"panes": app.canvas.panes.last.iter().map(|(w, c)| json!({
|
||
"document": w.0,
|
||
"view": w.1,
|
||
"rect": [c.left(), c.top(), c.width(), c.height()],
|
||
"active": crate::doc_windows::current(app) == Some(*w),
|
||
})).collect::<Vec<_>>(),
|
||
// Window › Arrange › Float in Window: the floating windows' documents, by index. While the
|
||
// current one floats, `canvasRect` is in its window's coordinates.
|
||
"floating": crate::doc_windows::floating_windows(app).iter().map(|w| w.0).collect::<Vec<_>>(),
|
||
// Window › New Window: every window, as [document index, view number], in tab order, and
|
||
// the current one.
|
||
"windows": crate::doc_windows::windows(app),
|
||
"currentWindow": crate::doc_windows::current(app),
|
||
"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, without
|
||
// the ones outside the read root.
|
||
"opening": app.opening.pending.iter().map(|p| p.path.as_str()).filter(|p| readable(p)).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(),
|
||
});
|
||
let ids = unreadable_library_ids(app);
|
||
hide_library_ids(&mut inspected, &ids);
|
||
inspected
|
||
}
|
||
|
||
/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel
|
||
/// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels
|
||
/// shown as icons or collapsed, and the older floating panels' record.
|
||
const PANEL_FIELDS: [&str; 7] =
|
||
["open_panel", "docking", "docking_hidden", "docking_hidden_groups", "docking_icons", "docking_collapsed", "floating_panels"];
|
||
|
||
/// The ids of the loaded libraries (`loaded/<path>`) that the UI state refers to and whose file the
|
||
/// request may not read, each with the id a reply shows instead: `loaded/<file name>`, as a library
|
||
/// loaded without a path is named. The UI state refers to a library by its bare id in a library panel
|
||
/// and the Color Guide's limit, and by a panel id (`library:<kind>:loaded/<path>`) in the fields of
|
||
/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a
|
||
/// workspace's name) is not read. The ids are numbered with the session's loaded libraries as the
|
||
/// library commands number them ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so
|
||
/// that two libraries stay two and `ui.inspect`, `ui.docking` and the library commands show a library
|
||
/// under the same id.
|
||
fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
|
||
let mut ids = std::collections::BTreeSet::new();
|
||
let ui = serde_json::to_value(&app.ui).unwrap_or_default();
|
||
for field in PANEL_FIELDS {
|
||
collect_library_panel_ids(&ui[field], &mut ids);
|
||
}
|
||
for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() {
|
||
for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") {
|
||
collect_library_panel_ids(layout, &mut ids);
|
||
}
|
||
}
|
||
collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids);
|
||
for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) {
|
||
add_library_id(id, &mut ids);
|
||
}
|
||
let mut hidden = vectorcraft_engine::cmd::swatchlib::concealed_library_ids(&app.session, ids.iter().cloned());
|
||
// Only the ids the UI state refers to are replaced in the reply.
|
||
hidden.retain(|(id, _)| ids.contains(id));
|
||
// The longest first, so that an id that begins another one does not replace part of it.
|
||
hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len()));
|
||
hidden
|
||
}
|
||
|
||
/// Adds `id` to `ids` when it is a loaded library's id (`loaded/<path>`).
|
||
fn add_library_id(id: &str, ids: &mut std::collections::BTreeSet<String>) {
|
||
if id.len() > "loaded/".len() && id.starts_with("loaded/") {
|
||
ids.insert(id.to_string());
|
||
}
|
||
}
|
||
|
||
/// Adds to `ids` the loaded library id of every library panel id (`library:<kind>:loaded/<path>`)
|
||
/// that `v` holds as a string or a key.
|
||
fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<String>) {
|
||
let add = |s: &str, ids: &mut std::collections::BTreeSet<String>| {
|
||
if let Some((_, id)) = s.strip_prefix(crate::panels::library_panel::PREFIX).and_then(|rest| rest.split_once(':')) {
|
||
add_library_id(id, ids);
|
||
}
|
||
};
|
||
match v {
|
||
Value::String(s) => add(s, ids),
|
||
Value::Array(items) => items.iter().for_each(|item| collect_library_panel_ids(item, ids)),
|
||
Value::Object(fields) => {
|
||
for (key, item) in fields {
|
||
add(key, ids);
|
||
collect_library_panel_ids(item, ids);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's
|
||
/// id, in its panel's id (`library:swatches:loaded/<path>`) and in keys.
|
||
fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) {
|
||
if ids.is_empty() {
|
||
return;
|
||
}
|
||
let hide = |s: &mut String| {
|
||
for (id, shown) in ids {
|
||
if s.contains(id.as_str()) {
|
||
*s = s.replace(id.as_str(), shown);
|
||
}
|
||
}
|
||
};
|
||
match v {
|
||
Value::String(s) => hide(s),
|
||
Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)),
|
||
Value::Object(fields) => {
|
||
for (mut key, mut item) in std::mem::take(fields) {
|
||
hide(&mut key);
|
||
hide_library_ids(&mut item, ids);
|
||
fields.insert(key, item);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog.
|
||
const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||
|
||
/// The scratch disk choices that are not paths.
|
||
const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
|
||
|
||
/// May the request being served read `path`? Under an automation read root (a control client's
|
||
/// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent
|
||
/// file or a listed script outside it.
|
||
fn readable(path: &str) -> bool {
|
||
vectorcraft_engine::file_access::check_read(path).is_ok()
|
||
}
|
||
|
||
/// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files,
|
||
/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside
|
||
/// the read root. The UI state itself is not changed.
|
||
fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
|
||
if let Some(Value::Array(files)) = ui.get_mut("recent_files") {
|
||
files.retain(|f| f.as_str().is_some_and(readable));
|
||
}
|
||
if let Some(Value::Array(panels)) = ui.get_mut("library_panels") {
|
||
for panel in panels.iter_mut().filter_map(Value::as_object_mut) {
|
||
if panel.get("path").and_then(Value::as_str).is_some_and(|p| !readable(p)) {
|
||
panel.remove("path");
|
||
}
|
||
}
|
||
}
|
||
if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") {
|
||
leave_out_unreadable_folders(prefs);
|
||
}
|
||
if let Some(Value::Object(dialog)) = ui.get_mut("dialog")
|
||
&& dialog.get("kind").and_then(Value::as_str) == Some("preferences")
|
||
&& let Some(Value::Object(fields)) = dialog.get_mut("fields")
|
||
{
|
||
leave_out_unreadable_folders(fields);
|
||
}
|
||
}
|
||
|
||
/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read. An
|
||
/// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay.
|
||
fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) {
|
||
for key in PREF_FOLDERS {
|
||
let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue };
|
||
if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) {
|
||
continue;
|
||
}
|
||
prefs.remove(key);
|
||
}
|
||
}
|
||
|
||
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) -> Result<egui::Modifiers, String> {
|
||
let b = |n: &str| crate::menus::opt_bool(p, n).map(|on| on.unwrap_or(false));
|
||
let cmd = b("cmd")?;
|
||
Ok(egui::Modifiers { alt: b("alt")?, ctrl: b("ctrl")?, shift: b("shift")?, mac_cmd: cmd && cfg!(target_os = "macos"), command: cmd })
|
||
}
|
||
|
||
/// An optional number param of a control request as `f32`, `default` when it is omitted or null.
|
||
fn coord(p: &Value, key: &str, default: f32) -> Result<f32, String> {
|
||
crate::menus::opt_number(p, key).map(|n| n.map_or(default, |n| n as f32))
|
||
}
|
||
|
||
/// A pointer packet's `mods`: none when omitted or null, else an object of booleans.
|
||
fn packet_mods(value: Option<&Value>) -> Result<Option<Mods>, String> {
|
||
match value {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(m) if !m.is_object() => Err("`mods` must be an object of true or false values".to_string()),
|
||
Some(m) => serde_json::from_value(m.clone()).map(Some).map_err(|e| format!("`mods` must be an object of true or false values: {e}")),
|
||
}
|
||
}
|
||
|
||
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||
let p = &req.params;
|
||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||
match req.method.as_str() {
|
||
"engine.execute" | "ui.menu.invoke" | "command" => {
|
||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||
let params = p.get("params").cloned().unwrap_or(json!({}));
|
||
let params = if params.is_null() { json!({}) } else { params };
|
||
// A key the command's description doesn't name is an error, not a default (#1442).
|
||
if let Err(e) = crate::menus::check_params(&app.session, id, ¶ms) {
|
||
return err(e);
|
||
}
|
||
wrap(app.run(id, params))
|
||
}
|
||
"engine.commands" => ok(all_commands(app)),
|
||
"document.inspect" => wrap(app.run("document.inspect", p.clone())),
|
||
"ui.inspect" => ok(inspect(app, ctx)),
|
||
"ui.docking" => {
|
||
let mut docking = crate::panel_docking::inspect(app, ctx);
|
||
let ids = unreadable_library_ids(app);
|
||
hide_library_ids(&mut docking, &ids);
|
||
ok(docking)
|
||
}
|
||
"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" => {
|
||
let groups: Vec<_> = vectorcraft_tools::TOOL_GROUPS.iter().chain(vectorcraft_tools::addons::GROUPS).copied().collect();
|
||
ok(serde_json::to_value(groups).unwrap_or_default())
|
||
}
|
||
"ui.pointer" => pointer_events(app, ctx, req),
|
||
"ui.key" => {
|
||
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
|
||
let m = match modifiers(p) {
|
||
Ok(m) => m,
|
||
Err(e) => return err(e),
|
||
};
|
||
if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) {
|
||
return err(e);
|
||
}
|
||
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 = match coord(p, "x", 0.0).and_then(|x| Ok(egui::pos2(x, coord(p, "y", 0.0)?))) {
|
||
Ok(pos) => pos,
|
||
Err(e) => return err(e),
|
||
};
|
||
if let Err(e) = reserve_synthetic(app, 1) {
|
||
return err(e);
|
||
}
|
||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||
ctx.request_repaint();
|
||
ok(Value::Null)
|
||
}
|
||
"ui.click" | "ui.drag" => synthetic_click(app, ctx, req),
|
||
"ui.wheel" => {
|
||
// 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 given =
|
||
(|| Ok::<_, String>(([coord(p, "dx", 0.0)?, coord(p, "dy", 0.0)?, coord(p, "x", 0.0)?, coord(p, "y", 0.0)?], modifiers(p)?)))();
|
||
let ([dx, dy, x, y], modifiers) = match given {
|
||
Ok(given) => given,
|
||
Err(e) => return err(e),
|
||
};
|
||
if let Err(e) = reserve_synthetic(app, 2) {
|
||
return err(e);
|
||
}
|
||
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(dx, dy).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0));
|
||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y)));
|
||
app.synthetic.push(egui::Event::MouseWheel {
|
||
unit,
|
||
delta,
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers,
|
||
source: egui::MouseWheelSource::Unknown,
|
||
});
|
||
ctx.request_repaint();
|
||
ok(Value::Null)
|
||
}
|
||
"ui.text" => {
|
||
if let Err(e) = reserve_synthetic(app, 1) {
|
||
return err(e);
|
||
}
|
||
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
|
||
ctx.request_repaint();
|
||
ok(Value::Null)
|
||
}
|
||
"ui.set" => set_ui(app, req),
|
||
"ui.dialog.set" => match app.ui.dialog.as_mut() {
|
||
Some(d) => {
|
||
let fields = match dialog_fields(p) {
|
||
Ok(fields) => fields,
|
||
Err(e) => return err(e),
|
||
};
|
||
let action = match dialog_action(p) {
|
||
Ok(action) => action,
|
||
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));
|
||
}
|
||
// In order: a field that sets others (an export preset) goes before the fields after it.
|
||
// A field that fails leaves the dialog as it was before the request.
|
||
let before = app.ui.dialog.clone();
|
||
for (f, value) in fields {
|
||
let old = app.ui.dialog.as_ref().and_then(|d| d.fields.get(&f).cloned());
|
||
if let Some(d) = app.ui.dialog.as_mut() {
|
||
crate::dialogs::set_field(d, f.clone(), value);
|
||
}
|
||
if let Err(e) = crate::dialogs::field_set(app, &f, old.as_ref()) {
|
||
app.ui.dialog = before;
|
||
return err(e);
|
||
}
|
||
}
|
||
let dialog = app.ui.dialog.as_ref().and_then(|d| serde_json::to_value(d).ok()).unwrap_or_default();
|
||
// `action` answers the dialog the fields were just set in, within this request.
|
||
match action {
|
||
None => ok(dialog),
|
||
Some(DialogAnswer::Confirm) => match crate::dialogs::confirm(app) {
|
||
Ok(result) => ok(json!({"dialog": dialog, "result": result})),
|
||
Err(e) => err(e),
|
||
},
|
||
Some(DialogAnswer::Cancel) => {
|
||
crate::dialogs::cancel(app);
|
||
ok(json!({"dialog": dialog, "result": null}))
|
||
}
|
||
}
|
||
}
|
||
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, h) = match coord(p, "width", 1440.0).and_then(|w| Ok((w, coord(p, "height", 900.0)?))) {
|
||
Ok(size) => size,
|
||
Err(e) => return err(e),
|
||
};
|
||
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);
|
||
}
|
||
let flags = crate::menus::opt_bool(p, "focus").and_then(|focus| Ok((focus, crate::menus::opt_bool(p, "data")?)));
|
||
let (focus, data) = match flags {
|
||
Ok(flags) => flags,
|
||
Err(e) => return err(e),
|
||
};
|
||
if focus.unwrap_or(true) {
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||
}
|
||
ctx.request_repaint();
|
||
Outcome::Screenshot { path: s("path").map(str::to_string), data: data == Some(true) }
|
||
}
|
||
"ui.render" => {
|
||
let given = (|| {
|
||
Ok::<_, String>((
|
||
crate::menus::opt_index(p, "artboard")?,
|
||
crate::menus::opt_number(p, "scale")?,
|
||
crate::menus::opt_bool(p, "traceViews")?,
|
||
crate::menus::opt_bool(p, "data")?,
|
||
))
|
||
})();
|
||
let (index, scale, trace_views, data) = match given {
|
||
Ok(given) => given,
|
||
Err(e) => return err(e),
|
||
};
|
||
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 Some(r) = doc.artboards.get(index.unwrap_or(0)).map(|a| a.rect) else { return err("no such artboard") };
|
||
let scale = scale.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 = trace_views == 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 = data == 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" => match p.get("importer").filter(|v| !v.is_null()) {
|
||
// The importer's parameters, else their defaults: automation gets no dialog asking for them.
|
||
Some(importer) => {
|
||
let params = p.get("params").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!({}));
|
||
wrap(app.run("file.openWith", json!({"path": s("path"), "importer": importer, "params": params})))
|
||
}
|
||
// `params` go to the importer File › Open picks for the file, as in `document.open`.
|
||
None => {
|
||
let mut open = json!({"path": s("path")});
|
||
if let Some(params) = p.get("params").filter(|v| !v.is_null()) {
|
||
open["params"] = params.clone();
|
||
}
|
||
wrap(app.run("file.open", open))
|
||
}
|
||
},
|
||
"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();
|
||
// The key check of `document.export` or `document.save` runs before the file is written.
|
||
let checked = if method == "app.export" { "document.export" } else { "document.save" };
|
||
if let Err(e) = crate::menus::check_params(&app.session, checked, &p) {
|
||
return err(e);
|
||
}
|
||
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;
|
||
|
||
/// The window and coordinate mapping where a control-channel Rotate View drag began.
|
||
#[derive(Clone, Copy)]
|
||
pub(crate) struct RotateViewPress {
|
||
window: (u64, u32),
|
||
document_resets: u64,
|
||
xf: Xf,
|
||
start_angle: f64,
|
||
start_rotation: f64,
|
||
}
|
||
|
||
/// Rotate View belongs to the canvas, not the engine's placeholder tool. Keep its press mapping
|
||
/// across requests so document-coordinate drags do not move again as the view turns underneath.
|
||
fn rotate_view_pointer(
|
||
app: &mut VectorcraftApp,
|
||
kind: PointerKind,
|
||
position: vectorcraft_geom::Point,
|
||
screen: bool,
|
||
shift: bool,
|
||
) -> Result<(), String> {
|
||
let window = app.session.active().zip(crate::doc_windows::current(app)).map(|(d, w)| (d.uid, w.1)).ok_or("no document")?;
|
||
let document_resets = app.session.tool_resets();
|
||
let stored = if kind == PointerKind::Down { None } else { app.control_rotate_press };
|
||
if kind == PointerKind::Drag && stored.is_none() {
|
||
return Err("Rotate View needs a down event before a drag".into());
|
||
}
|
||
let mut press = match stored {
|
||
Some(press) => press,
|
||
None => {
|
||
let rect = app.canvas_rect.ok_or("no canvas yet")?;
|
||
let view = app.view().ok_or("no document")?;
|
||
RotateViewPress { window, document_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation }
|
||
}
|
||
};
|
||
// A gesture cannot turn a different document/view or resume after its document was reset.
|
||
if (press.window, press.document_resets) != (window, document_resets) {
|
||
return Err("the Rotate View gesture's window or document changed".into());
|
||
}
|
||
let point = if screen { egui::pos2(position.x as f32, position.y as f32) } else { press.xf.to_screen(position) };
|
||
let center = press.xf.rect.center();
|
||
if !point.is_finite() || !center.is_finite() {
|
||
return Err("Rotate View needs finite canvas coordinates".into());
|
||
}
|
||
match kind {
|
||
PointerKind::Down => {
|
||
press.start_angle = (point.y - center.y).atan2(point.x - center.x) as f64;
|
||
app.control_rotate_press = Some(press);
|
||
}
|
||
PointerKind::Drag => {
|
||
app.view_mut().ok_or("no document")?.rotation =
|
||
crate::canvas::drag_rotation(center, point, press.start_angle, press.start_rotation, shift);
|
||
}
|
||
PointerKind::Up => {
|
||
app.control_rotate_press = None;
|
||
}
|
||
PointerKind::Move | PointerKind::DoubleClick => {}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A Rotate View packet accepts only the documented boolean modifier fields.
|
||
fn validate_rotate_view_mods(value: Option<&Value>) -> Result<(), String> {
|
||
let Some(value) = value.filter(|value| !value.is_null()) else { return Ok(()) };
|
||
let Some(fields) = value.as_object() else { return Err("Rotate View `mods` must be an object of booleans".into()) };
|
||
if fields.iter().any(|(key, value)| !matches!(key.as_str(), "shift" | "alt" | "cmd" | "ctrl" | "space") || !value.is_boolean()) {
|
||
return Err("Rotate View `mods` accepts only boolean shift, alt, cmd, ctrl and space".into());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Reject malformed coordinates and modifiers before any event can change the angle or press.
|
||
fn validate_rotate_view_events(params: &Value, events: &[Value]) -> Result<(), String> {
|
||
validate_rotate_view_mods(params.get("mods"))?;
|
||
for event in events {
|
||
for axis in ["x", "y"] {
|
||
if event.get(axis).and_then(Value::as_f64).filter(|value| value.is_finite()).is_none() {
|
||
return Err(format!("Rotate View `{axis}` must be a finite number"));
|
||
}
|
||
}
|
||
validate_rotate_view_mods(event.get("mods"))?;
|
||
if event.get("space").is_some_and(|space| !matches!(space.as_str(), Some("doc" | "screen"))) {
|
||
return Err("Rotate View `space` must be doc or screen".into());
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `ui.pointer`: pointer events through the tools.
|
||
fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||
let rotating = app.session.tool_id() == "rotateView";
|
||
let before = if rotating { app.view().map(|view| view.rotation) } else { None };
|
||
let outcome = pointer_events_inner(app, ctx, req);
|
||
if rotating && matches!(&outcome, Outcome::Done(reply) if reply.get("ok").and_then(Value::as_bool) == Some(false)) {
|
||
// A failed packet must neither leave a partial turn nor allow a later drag to resume it.
|
||
app.control_rotate_press = None;
|
||
if let (Some(before), Some(view)) = (before, app.view_mut()) {
|
||
view.rotation = before;
|
||
}
|
||
ctx.request_repaint();
|
||
}
|
||
outcome
|
||
}
|
||
|
||
/// Apply a pointer packet after Rotate View validation; other tools retain their existing routing.
|
||
fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||
let p = &req.params;
|
||
let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") };
|
||
let rotating = app.session.tool_id() == "rotateView";
|
||
if rotating && let Err(error) = validate_rotate_view_events(p, events) {
|
||
return err(error);
|
||
}
|
||
// Every event is read before any is sent, so a malformed packet sends none of them.
|
||
let read = packet_mods(p.get("mods")).and_then(|base| {
|
||
let base = base.unwrap_or_default();
|
||
events
|
||
.iter()
|
||
.map(|e| {
|
||
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}`")),
|
||
};
|
||
PointerEvent::json_check(e)?;
|
||
let at = |k: &str| crate::menus::opt_number(e, k).map(|n| n.unwrap_or(0.0));
|
||
Ok((kind, at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base)))
|
||
})
|
||
.collect::<Result<Vec<_>, String>>()
|
||
});
|
||
let read = match read {
|
||
Ok(read) => read,
|
||
Err(e) => return err(e),
|
||
};
|
||
let view = app.view_info();
|
||
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
|
||
let screen = |e: &Value| e.get("space").and_then(Value::as_str) == Some("screen");
|
||
if !rotating && xf.is_none() && events.iter().any(screen) {
|
||
return err("no canvas yet");
|
||
}
|
||
let from = app.session.input_count();
|
||
let held =
|
||
PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::<Result<Vec<_>, _>>());
|
||
let held = match held {
|
||
Ok(held) => held,
|
||
Err(e) => return err(e),
|
||
};
|
||
for ((e, backquote), (kind, x, y, mods)) in events.iter().zip(held).zip(read) {
|
||
let screen = screen(e);
|
||
if rotating {
|
||
if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) {
|
||
return err(e);
|
||
}
|
||
continue;
|
||
}
|
||
let pos = if 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)
|
||
};
|
||
// A tilt given in screen axes leans the same way on the page as on the screen.
|
||
let pen = PointerEvent::json_pen(e).map(|q| match xf {
|
||
Some(xf) if screen => crate::canvas::pose_on_page(q, &xf),
|
||
_ => q,
|
||
});
|
||
crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, backquote, pressure: PointerEvent::json_pressure(e), pen }, 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();
|
||
// A Shape Builder merge of a large selection is computed on another thread, and the
|
||
// reply to the events that started it waits for it without holding the frames up. A
|
||
// gesture sent while it is computed is made after it, and its reply waits until then;
|
||
// a hover is answered at once.
|
||
let to = app.session.input_count();
|
||
if !app.session.input_applied(from, to) {
|
||
return Outcome::AfterToolEdit { from, to, document: app.session.active().map(|d| d.uid) };
|
||
}
|
||
Outcome::Done(pointer_reply(app))
|
||
}
|
||
|
||
/// `ui.click` and `ui.drag`: pointer input through egui itself.
|
||
fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||
let p = &req.params;
|
||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||
// Validate the entire expansion before appending even the initial move/press.
|
||
let steps = if req.method == "ui.drag" {
|
||
match input_count(p, "steps", 8, MAX_DRAG_STEPS) {
|
||
Ok(n) => n,
|
||
Err(e) => return err(e),
|
||
}
|
||
} else {
|
||
0
|
||
};
|
||
let count = match input_count(p, "count", 1, MAX_CLICK_COUNT) {
|
||
Ok(n) => n,
|
||
Err(e) => return err(e),
|
||
};
|
||
let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else {
|
||
return err("synthetic input event count is out of range");
|
||
};
|
||
// Screen-space (egui points) pointer input through egui itself: reaches every widget.
|
||
let drag = req.method == "ui.drag";
|
||
let given = (|| {
|
||
let a = egui::pos2(coord(p, "x", 0.0)?, coord(p, "y", 0.0)?);
|
||
let end = if drag { egui::pos2(coord(p, "toX", 0.0)?, coord(p, "toY", 0.0)?) } else { a };
|
||
Ok::<_, String>((a, end, modifiers(p)?))
|
||
})();
|
||
let (a, end, modifiers) = match given {
|
||
Ok(given) => given,
|
||
Err(e) => return err(e),
|
||
};
|
||
if let Err(e) = reserve_synthetic(app, events) {
|
||
return err(e);
|
||
}
|
||
let button = match s("button") {
|
||
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
|
||
_ => egui::PointerButton::Primary,
|
||
};
|
||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
|
||
if req.method == "ui.drag" {
|
||
for i in 1..=steps {
|
||
let t = i as f32 / steps as f32;
|
||
app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t));
|
||
}
|
||
}
|
||
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
|
||
for _ in 1..count {
|
||
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers });
|
||
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
|
||
}
|
||
ctx.request_repaint();
|
||
ok(Value::Null)
|
||
}
|
||
|
||
/// `ui.set`: view flags, panels and status.
|
||
fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome {
|
||
let p = &req.params;
|
||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||
// A flag of the wrong type is refused before anything is set.
|
||
let flags = ["rulers", "outline", "grid", "smartGuides", "boundingBox", "controlBar", "imageTracePreview"];
|
||
if let Some(e) = flags.iter().find_map(|k| crate::menus::opt_bool(p, k).err()) {
|
||
return err(e);
|
||
}
|
||
// Outline is a setting of the active document's view: without one, nothing is set.
|
||
if p.get("outline").is_some_and(Value::is_boolean) && app.session.active().is_none() {
|
||
return err("no document");
|
||
}
|
||
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.outline_view(),
|
||
"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)
|
||
}
|