Plug-ins: heavy filters, allowed at install and run in the background (G17)

A filter plug-in gets 50 M instructions and a minute for each run.
Filters that order a plotter's paths, pack shapes or run a simulation
need minutes, and the app shouldn't freeze while they compute.

A filter's manifest may now ask for "budget": "heavy" (Budget): each run
gets HEAVY_FUEL (1 G instructions) and HEAVY_WALL_TIME_MS (300 s). Only a
filter may ask. plugin.install refuses such a filter without allowHeavy:
true, and plugin.inspect returns the entry a module would have without
installing it. Install Plug-in… and File › Open ask the user and install
on OK; the plug-in store keeps the answer, and folder loads skip heavy
filters with a note, since they ask nobody.

The app runs a heavy filter on a thread of its own (plugin_runs), not in
Background Save's queue, with the status bar and a progress window
(Cancel, Continue in Background) that it shares with Background Export
(export_progress::window). FilterRun splits plugin.run: prepared on the
session, run on any thread, its output handed in, and the next
plugin.run with the same params applies that output instead of running
the plug-in again, so the edit stays one guarded, journaled command. A
result whose document or plug-in changed meanwhile is dropped, and the
status bar says to run it again. A cancelled plug-in run is now
EngineError::Cancelled.

What a filter keeps with the document (#3720) goes with the run: it is
given _context.documentData when the run is prepared, and the
documentData of its output is applied with its objects, in the same undo
step, when the run is handed in (FilterRun::finish).

Tests: plugins/tests/abi.rs (100 M loop steps out of a light budget,
within a heavy one; folders skip heavy filters), manifest.rs, engine
tests_plugins.rs (consent, inspect, the store, hand-in, a handed-in
run's document data, cancel) and
ui-egui tests_plugin_runs.rs (installing asks, no Preview, a background
run as one undo step, a stale result kept out, the progress window).
CRAFT_UI_HEAVY_FILTER_WASM=<folder> writes a test filter to try in the
app. Its strings and messages have rows in each complete catalog
(COMPLETE_MESSAGES).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
This commit is contained in:
Louise Fox
2026-10-23 08:18:47 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 3e0c1d6870
commit 79cb38f678
46 changed files with 1372 additions and 112 deletions
+197 -53
View File
@@ -8,18 +8,26 @@
//! the document ([`vectorcraft_doc::plugin_data`], theirs alone); live-effect plug-ins are applied
//! like any effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`;
//! exporters add a file format that `document.export` writes (`fileio::plugin_formats`).
//!
//! A filter may ask for a heavy budget (`Budget::Heavy`: minutes rather than a minute, for routing,
//! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`),
//! which loading a folder doesn't ask, so folders skip it; the app runs it in the background with
//! Cancel ([`FilterRun`]).
use std::sync::{Arc, Mutex};
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Effect, Node};
use vectorcraft_plugins::{Kind, Plugin, effect, export, objects, registry};
use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry};
use super::*;
/// What a plug-in error looks like to the user.
/// What a plug-in error looks like to the user. A run the caller cancelled changed nothing.
fn err(e: vectorcraft_plugins::Error) -> EngineError {
EngineError::Other(e.to_string())
match e {
vectorcraft_plugins::Error::Cancelled => EngineError::Cancelled,
e => EngineError::Other(e.to_string()),
}
}
/// JSON description of an installed plug-in.
@@ -38,6 +46,9 @@ pub fn describe(p: &Plugin) -> Value {
}
Kind::Filter => {}
}
if m.budget == Budget::Heavy {
v["budget"] = json!("heavy");
}
v
}
@@ -60,48 +71,145 @@ fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into())))
}
/// One run of an object filter, split so the plug-in can compute away from the session: made from
/// the active document ([`FilterRun::prepare`]), run on any thread ([`FilterRun::run`] touches no
/// document), then handed in ([`FilterRun::hand_in`]) for `plugin.run` to apply instead of running
/// the plug-in again. The app runs a heavy filter ([`Budget::Heavy`]) this way, in the background
/// with Cancel, and the edit itself stays a `plugin.run`: journaled, guarded and undone as one step
/// like any other command.
pub struct FilterRun {
plugin: Arc<Plugin>,
input: Vec<u8>,
params: Vec<u8>,
/// The objects the plug-in is given, and the container new objects go in.
inputs: Vec<NodeId>,
parent: Option<NodeId>,
/// The document it was made from, and that document's revision then.
document: (u64, u64),
}
/// A filter's output handed in by a [`FilterRun`] made away from `plugin.run`, with what the run
/// was made of: only a run of the same installation of the plug-in on the same input with the same
/// parameters takes it (a plug-in's output depends on nothing else).
pub(crate) struct HandedIn {
serial: u64,
input: Vec<u8>,
params: Vec<u8>,
output: Vec<u8>,
}
impl FilterRun {
/// The run `plugin.run`'s params `p` ask for, on the active document: the plug-in (a filter),
/// its checked parameters and `_context`, and the selection's objects.
pub fn prepare(s: &Session, p: &Value) -> Result<FilterRun> {
const C: &str = "plugin.run";
let plugin = plugin_by_id(C, p)?;
let m = plugin.manifest();
match m.kind {
Kind::Filter => {}
Kind::Effect => {
return Err(bad(
C,
format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id)),
));
}
Kind::Exporter => {
return Err(bad(
C,
format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_id(&m.id)),
));
}
}
// Bad parameters fail before anything runs.
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
let st = s.doc()?;
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
let inputs = objects::filter_targets(&st.doc, &selected);
let parent = st.insertion_parent();
let bounds = st.doc.bounds_of(&inputs, false);
let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first());
let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
// What the plug-in keeps with the document, as it last wrote it.
let data = st.doc.plugin_data(&m.id).cloned().unwrap_or(Value::Null);
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data});
let input = objects::encode_input(&st.doc, &inputs);
let params = serde_json::to_vec(&params).map_err(|e| bad(C, e.to_string()))?;
let document = (st.uid, st.revision);
Ok(FilterRun { plugin, input, params, inputs, parent, document })
}
/// The plug-in, for its name and budget.
pub fn plugin(&self) -> &Arc<Plugin> {
&self.plugin
}
/// The objects it gives the plug-in: `plugin.run`'s `ids` that make this same run again.
pub fn inputs(&self) -> &[NodeId] {
&self.inputs
}
/// The document it was made from (`DocState::uid`).
pub fn document(&self) -> u64 {
self.document.0
}
/// Does it still apply: is the active document the one it was made from, unchanged since, and
/// the plug-in still installed as it was?
pub fn current(&self, s: &Session) -> bool {
let installed = registry::get(self.plugin.id()).is_some_and(|p| p.serial() == self.plugin.serial());
installed && s.active().is_some_and(|st| (st.uid, st.revision) == self.document)
}
/// Runs the plug-in → its output, or why it failed or stopped. It touches no document, so it
/// can run on any thread; inside a [`vectorcraft_doc::cancel::scope`] Cancel stops it between
/// two slices of its budget.
pub fn run(&self) -> Result<Vec<u8>> {
self.plugin.run(&self.input, &self.params).map_err(err)
}
/// Hands in `output`, which [`FilterRun::run`] returned: the next `plugin.run` that makes this
/// same run (same plug-in, `ids` and parameters, the document unchanged) applies it instead of
/// running the plug-in. Any other run drops it.
pub fn hand_in(self, s: &mut Session, output: Vec<u8>) {
s.plugin_output = Some(HandedIn { serial: self.plugin.serial(), input: self.input, params: self.params, output });
}
/// Applies the plug-in's `output` to the active document as one undo step: its objects, and what
/// it keeps with the document from now on.
fn finish(self, s: &mut Session, output: &[u8]) -> Result<Value> {
let m = self.plugin.manifest();
let out = objects::decode_output(output).map_err(err)?;
let (inputs, parent) = (self.inputs, self.parent);
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?;
if let Some(data) = out.document_data {
d.set_plugin_data(&m.id, data);
}
sel.set(ids.iter().copied());
Ok(ids)
})?;
Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
}
/// `plugin.run`: runs filter `id` on `ids` (else the selected paths and compound paths) with its
/// parameters and `_context` (what it keeps with the document included), and applies its output as
/// one undo step ([`objects::apply_output`]; effect records as [`admit_effects`] admits them),
/// with what it keeps with the document from now on. → `{plugin, ids}`: what it made or changed,
/// which is then selected.
/// one undo step ([`objects::apply_output`]; effect records as [`admit_effects`] admits them), with
/// what it keeps with the document from now on: the output handed in for this very run when there
/// is one ([`FilterRun::hand_in`]), else the plug-in's, run now. → `{plugin, ids}`: what it made or
/// changed, which is then selected.
fn run(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.run";
let plugin = plugin_by_id(C, p)?;
let m = plugin.manifest();
match m.kind {
Kind::Filter => {}
Kind::Effect => {
return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id))));
}
Kind::Exporter => {
return Err(bad(C, format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_id(&m.id))));
}
}
// Bad parameters fail before anything runs.
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
let st = s.doc()?;
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
let inputs = objects::filter_targets(&st.doc, &selected);
let parent = st.insertion_parent();
let bounds = st.doc.bounds_of(&inputs, false);
let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first());
let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
// What the plug-in keeps with the document, as it last wrote it.
let data = st.doc.plugin_data(&m.id).cloned().unwrap_or(Value::Null);
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data});
let input = objects::encode_input(&st.doc, &inputs);
let params = serde_json::to_vec(&params).map_err(|e| bad(C, e.to_string()))?;
let out = objects::decode_output(&plugin.run(&input, &params).map_err(err)?).map_err(err)?;
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?;
if let Some(data) = out.document_data {
d.set_plugin_data(&m.id, data);
}
sel.set(ids.iter().copied());
Ok(ids)
})?;
Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
// An output handed in is for this run only (the app hands it in just before), whatever it does.
let handed = s.plugin_output.take();
let r = FilterRun::prepare(s, p)?;
// Handed in for this very run (the app ran it in the background), it is what running the
// plug-in again would return.
let handed = handed.filter(|h| (h.serial, &h.input, &h.params) == (r.plugin.serial(), &r.input, &r.params));
let output = match handed {
Some(h) => h.output,
None => r.run()?,
};
r.finish(s, &output)
}
/// Admits the effect records a filter returned for object `n` (written, its effects still its own)
@@ -154,32 +262,57 @@ const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
/// The `.wasm` extension plug-in files have (File › Open installs them).
pub const EXTS: &[&str] = &["wasm"];
fn install(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.install";
let (plugin, bytes) = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
/// The plug-in module command `c`'s params give, loaded and checked, and its bytes: `path` (a
/// `.wasm` file) or `dataBase64` (the module, with `name` shown as its source).
fn load_given(c: &str, p: &Value) -> Result<(Plugin, Vec<u8>)> {
match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
(_, Some(data)) => {
if data.len() > MAX_DATA_CHARS {
return Err(bad(C, "`dataBase64` is larger than the module limit"));
return Err(bad(c, "`dataBase64` is larger than the module limit"));
}
let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(C, "`dataBase64` is not valid base64"))?;
let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(c, "`dataBase64` is not valid base64"))?;
let plugin = Plugin::load(&bytes, vectorcraft_plugins::Limits::default()).map_err(err)?;
let plugin = match str_param(p, "name") {
Some(name) => plugin.with_source(name),
None => plugin,
};
(plugin, bytes)
Ok((plugin, bytes))
}
(Some(path), None) => load_path(path)?,
(None, None) => return Err(bad(C, "give `path` (a .wasm file) or `dataBase64` (the module)")),
};
(Some(path), None) => load_path(path),
(None, None) => Err(bad(c, "give `path` (a .wasm file) or `dataBase64` (the module)")),
}
}
/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless
/// `allowHeavy` says the user agreed, keeps it for the next start and installs it → its
/// `plugin.list` entry.
fn install(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.install";
let (plugin, bytes) = load_given(C, p)?;
if !p.get("replace").and_then(Value::as_bool).unwrap_or(true) && registry::get(plugin.id()).is_some() {
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
}
// A heavy plug-in is installed once the user agrees: whoever installs it asks them first.
if plugin.manifest().budget == Budget::Heavy && !p.get("allowHeavy").and_then(Value::as_bool).unwrap_or(false) {
let (minutes, times) = vectorcraft_plugins::heavy_terms();
return Err(bad(
C,
format!(
"“{}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees",
plugin.manifest().name
),
));
}
// Installed plug-ins stay installed after a restart (#2541).
s.store_plugin(plugin.id(), &bytes);
Ok(describe(&registry::install(plugin)))
}
/// `plugin.inspect`: what installing the module would install, without installing it.
fn inspect(_: &mut Session, p: &Value) -> Result<Value> {
load_given("plugin.inspect", p).map(|(plugin, _)| describe(&plugin))
}
/// The plug-in in the `.wasm` file `path`, and its module.
#[cfg(not(target_arch = "wasm32"))]
fn load_path(path: &str) -> Result<(Plugin, Vec<u8>)> {
@@ -267,6 +400,8 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
}
/// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove` and
/// `.reload`.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -274,7 +409,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
always,
list
),
@@ -301,10 +436,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Install Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
always,
install
),
cmd!(
query "plugin.inspect",
"Inspect Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have",
always,
inspect
),
cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove),
cmd!(
query "plugin.reload",
+7
View File
@@ -1373,6 +1373,9 @@ pub struct Session {
/// Where the plug-ins `plugin.install` installs are kept; `None`: only for the process
/// ([`Session::attach_plugin_store`]).
plugin_dir: Option<std::path::PathBuf>,
/// A filter's output that a run made in the background handed in for `plugin.run` to apply
/// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it.
plugin_output: Option<cmd::plugin::HandedIn>,
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
pub recent_urls: Vec<String>,
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
@@ -1439,6 +1442,9 @@ impl Default for Session {
}
impl Session {
/// A session with no document open, the default preferences and the Selection tool. It also
/// installs the hooks the document crate calls back into: the loader and file reader of placed
/// documents, and where an image's effects draw its fills and strokes, for clicks.
pub fn new() -> Self {
// Placed documents are read by the native format's loader.
vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document);
@@ -1479,6 +1485,7 @@ impl Session {
pdf_preset_dir: None,
profile_dir: None,
plugin_dir: None,
plugin_output: None,
recent_urls: vec![],
ui_language: None,
journal_note: Default::default(),
+139
View File
@@ -441,3 +441,142 @@ fn installed_plugins_are_kept_in_the_store() {
third.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_none());
}
/// A heavy test filter `id` that counts `steps` down, then adds a triangle named "Plug-in".
fn heavy(id: &str, steps: u64) -> Vec<u8> {
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#);
let output = json!({"objects": [{"path": triangle(), "name": "Plug-in"}]});
wat::parse_str(tpl::spinning(&manifest, steps, &output.to_string())).unwrap()
}
/// A filter that asks for a heavy budget installs only once the user agrees (`allowHeavy`);
/// `plugin.inspect` tells whoever installs it so beforehand, and installs nothing.
#[test]
fn a_heavy_filter_installs_once_the_user_agrees() {
let mut s = session();
let id = "test.engine.heavy";
let data = vectorcraft_format::base64_encode(&heavy(id, 1));
// With the file's name, as the app passes it.
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data, "name": "route.wasm"}));
assert_eq!((inspected["id"].as_str(), inspected["budget"].as_str()), (Some(id), Some("heavy")));
assert_eq!(inspected["source"], "route.wasm");
assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing");
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
assert!(e.contains("heavy budget") && e.contains("5 minutes") && e.contains("allowHeavy"), "{e}");
assert!(vectorcraft_plugins::registry::get(id).is_none());
let info = run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
assert_eq!(info["budget"], "heavy");
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL);
let listed = run(&mut s, "plugin.list", json!({}));
assert!(listed["plugins"].as_array().unwrap().iter().any(|p| p["id"] == id && p["budget"] == "heavy"));
// A light plug-in's entry says nothing of budgets.
assert!(install(&mut s, DESATURATE).get("budget").is_none());
}
/// The user agreed once: a heavy filter kept in the plug-in store is installed again at the next
/// start without asking, with its budget.
#[test]
fn a_stored_heavy_filter_keeps_its_budget() {
let folder = crate::tests_links::Folder::new("plugin-store-heavy");
let id = "test.engine.storedheavy";
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
vectorcraft_plugins::registry::remove(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
let plugin = vectorcraft_plugins::registry::get(id).expect("installed again from the store");
assert_eq!(plugin.limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL);
run(&mut next, "plugin.remove", json!({"id": id}));
}
/// The app runs a heavy filter away from the session ([`cmd::plugin::FilterRun`]) and hands its
/// output in: `plugin.run` with the same params applies it, as one undo step, without running the
/// plug-in again. Only once, and only for the same run: objects that changed since, or the plug-in
/// installed again, run the plug-in instead.
#[test]
fn a_filter_run_made_elsewhere_is_applied_by_plugin_run() {
use cmd::plugin::FilterRun;
let mut s = session();
let id = "test.engine.handin";
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
let p = json!({"id": id, "ids": []});
let named = |s: &Session, ids: &Value| s.doc().unwrap().doc.node(NodeId(ids[0].as_u64().unwrap())).unwrap().name.clone();
// What it computed, as the app hands it in; here marked, so the test sees which output applied.
let r = FilterRun::prepare(&s, &p).unwrap();
assert!(r.current(&s));
let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Plug-in", "Handed in");
r.hand_in(&mut s, output.clone().into_bytes());
let undo = s.doc().unwrap().history.undo.len();
let made = run(&mut s, "plugin.run", p.clone())["ids"].clone();
assert_eq!(named(&s, &made).as_deref(), Some("Handed in"));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
// Once only: the next run runs the plug-in.
let made = run(&mut s, "plugin.run", p.clone())["ids"].clone();
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"));
// Made for objects that changed since, it isn't theirs.
let obj = rect(&mut s, 10.0, "#ff0000");
let on = json!({"id": id, "ids": [obj.0]});
let stale = FilterRun::prepare(&s, &on).unwrap();
run(&mut s, "paint.setFill", json!({"ids": [obj.0], "color": "#00ff00"}));
assert!(!stale.current(&s), "the document changed");
stale.hand_in(&mut s, output.clone().into_bytes());
let made = run(&mut s, "plugin.run", on)["ids"].clone();
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made for objects that changed since");
// A plug-in installed again is another one.
let again = FilterRun::prepare(&s, &p).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
assert!(!again.current(&s));
again.hand_in(&mut s, output.into_bytes());
let made = run(&mut s, "plugin.run", p)["ids"].clone();
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made by the plug-in installed before");
}
/// A heavy filter run away from the session reads what it keeps with the document
/// (`_context.documentData`) and hands its `documentData` back with its output: `plugin.run`
/// applies both as one undo step.
#[test]
fn a_filter_run_made_elsewhere_keeps_its_document_data() {
use cmd::plugin::FilterRun;
let mut s = session();
let id = "test.engine.handindata";
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#);
let install = |s: &mut Session, src: String| {
let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap());
run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
};
// It first keeps the pens it found with the document.
install(&mut s, tpl::returning(&manifest, &json!({"objects": [], "documentData": {"pens": 2}}).to_string()));
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
// Given those pens, it adds one, in the background; the output handed in is marked, so the
// test sees it is the one that applied.
let next = json!({"objects": [{"path": triangle(), "name": "Pen 3"}], "documentData": {"pens": 3}}).to_string();
install(&mut s, tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":{"pens":2}"#, &next));
let p = json!({"id": id, "ids": []});
let r = FilterRun::prepare(&s, &p).unwrap();
let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Pen 3", "Handed in");
r.hand_in(&mut s, output.into_bytes());
let undo = s.doc().unwrap().history.undo.len();
let made = run(&mut s, "plugin.run", p)["ids"].clone();
let st = s.doc().unwrap();
assert_eq!(st.doc.node(NodeId(made[0].as_u64().unwrap())).unwrap().name.as_deref(), Some("Handed in"));
assert_eq!(st.doc.plugin_data(id), Some(&json!({"pens": 3})));
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc.plugin_data(id), Some(&json!({"pens": 2})), "undone with its objects");
}
/// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between
/// two slices of its budget and changes nothing.
#[test]
fn a_cancelled_filter_run_changes_nothing() {
let mut s = session();
let id = "test.engine.cancelled";
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1 << 40)), "allowHeavy": true}));
let before = s.doc().unwrap().doc.clone();
let token = vectorcraft_doc::cancel::CancelToken::new();
token.cancel();
let r = vectorcraft_doc::cancel::scope(&token, None, || s.execute("plugin.run", &json!({"id": id})));
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
assert_eq!(s.doc().unwrap().doc, before);
}
+4 -3
View File
@@ -6,7 +6,8 @@
//! - **no host imports**: no WASI, file system, network, clock or randomness. A module that
//! imports anything is rejected;
//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is
//! also enforced (native builds), so infinite loops end with an error;
//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a
//! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it;
//! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits;
//! - a **fresh instance per run**, so no state leaks between calls.
//!
@@ -33,8 +34,8 @@ mod runtime;
pub mod wat;
pub use dialog::ParamUi;
pub use manifest::{Kind, Manifest, Param, ParamSpec};
pub use runtime::{ABI_VERSION, Limits, Plugin};
pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec};
pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms};
/// What the host puts before a plug-in's id to name what it adds: its live effect
/// (`plugin.<id>`, [`effect`]) or its file format ([`export`]).
+58
View File
@@ -45,6 +45,27 @@ impl Kind {
}
}
/// How much one run of a plug-in may compute.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Budget {
/// The usual budget ([`crate::Limits::default`]).
#[default]
Light,
/// A filter's larger budget for heavy but legitimate work (routing, packing, simulation):
/// [`crate::HEAVY_FUEL`] instructions and [`crate::HEAVY_WALL_TIME_MS`]. The
/// user allows it once, when the plug-in is installed, and the app runs it in the background
/// with Cancel.
Heavy,
}
impl Budget {
/// The usual budget, which a manifest written back leaves out (`skip_serializing_if`).
fn is_light(&self) -> bool {
*self == Budget::Light
}
}
/// One declared parameter.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
@@ -191,6 +212,9 @@ pub struct Manifest {
/// files get. Only exporters declare them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<String>,
/// How much a run may compute: only a filter may be [`Budget::Heavy`].
#[serde(default, skip_serializing_if = "Budget::is_light")]
pub budget: Budget,
}
impl Manifest {
@@ -216,9 +240,12 @@ impl Manifest {
author: String::new(),
params: Vec::new(),
extensions: Vec::new(),
budget: Budget::Light,
}
}
/// Checks what the module declared: its id, name and text lengths, its extensions, a budget its
/// kind may have (only a filter may be heavy), and each parameter and its dialog layout.
fn validate(&self) -> Result<()> {
if !valid_id(&self.id) {
return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id)));
@@ -231,6 +258,15 @@ impl Manifest {
return Err(Error::Manifest("`version`, `description` or `author` is too long".into()));
}
self.validate_extensions()?;
match (self.budget, self.kind) {
(Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {}
(Budget::Heavy, Kind::Effect) => {
return Err(Error::Manifest("a live effect runs while the document is drawn, so it can't be `heavy`".into()));
}
(Budget::Heavy, Kind::Exporter) => {
return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into()));
}
}
for (i, (k, p)) in self.params.iter().enumerate() {
if !valid_key(k) {
return Err(Error::Manifest(format!("invalid parameter name {k:?}")));
@@ -501,6 +537,28 @@ mod tests {
}
}
/// A filter may ask for a heavy budget; a live effect runs while the document is drawn and an
/// exporter has an export's budget, so they can't. Light, the default, isn't written back.
#[test]
fn only_a_filter_asks_for_a_heavy_budget() {
let heavy = Manifest::parse(br#"{"id":"org.example.route","name":"Route","kind":"filter","budget":"heavy"}"#).unwrap();
assert_eq!(heavy.budget, Budget::Heavy);
assert_eq!(serde_json::to_value(&heavy).unwrap()["budget"], json!("heavy"));
for light in [r#"{"id":"a","name":"x","kind":"filter"}"#, r#"{"id":"a","name":"x","kind":"effect","budget":"light"}"#] {
let m = Manifest::parse(light.as_bytes()).unwrap();
assert_eq!(m.budget, Budget::Light);
assert!(serde_json::to_value(&m).unwrap().get("budget").is_none(), "{light}");
}
for bad in [
r#"{"id":"a","name":"x","kind":"effect","budget":"heavy"}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"budget":"heavy"}"#,
r#"{"id":"a","name":"x","kind":"filter","budget":"huge"}"#,
r#"{"id":"a","name":"x","kind":"filter","budget":true}"#,
] {
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
}
}
#[test]
fn strings_and_number_lists_resolve_like_the_other_types() {
let m = Manifest::parse(
+6 -1
View File
@@ -92,7 +92,8 @@ pub struct FolderReport {
}
/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is
/// reported and skipped; the others still load.
/// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]),
/// which installs only once the user agrees.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
load_folder_where(dir, |_| Ok(()))
@@ -114,6 +115,10 @@ pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path)
continue;
}
match load_file(&f, Limits::default()) {
// A heavy plug-in installs once the user agrees, which loading a folder doesn't ask.
Ok(p) if p.manifest().budget == crate::Budget::Heavy => {
report.failed.push((f.display().to_string(), "asks for a heavy budget, which the user allows when installing it on its own".into()));
}
Ok(p) => report.loaded.push(install(p).id().to_string()),
Err(e) => report.failed.push((f.display().to_string(), e.to_string())),
}
+20
View File
@@ -14,6 +14,20 @@ pub const ABI_VERSION: i32 = 1;
/// Fuel handed out per slice; between slices the host checks the deadline.
const FUEL_SLICE: u64 = 20_000_000;
/// Instructions a heavy filter's run may use besides [`Limits::fuel_per_byte`] for each byte of
/// input ([`crate::Budget::Heavy`]): 20 times a light one's. Its output's size can't add to it:
/// the host learns that only once the run has ended.
pub const HEAVY_FUEL: u64 = 1_000_000_000;
/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds).
pub const HEAVY_WALL_TIME_MS: u64 = 300_000;
/// A heavy run's budget as people read it: (minutes it may take, times the instructions a light
/// run gets).
pub fn heavy_terms() -> (u64, u64) {
(HEAVY_WALL_TIME_MS / 60_000, HEAVY_FUEL / Limits::default().fuel_base.max(1))
}
/// Resource limits for running plug-ins.
#[derive(Clone, Debug, PartialEq)]
pub struct Limits {
@@ -156,6 +170,12 @@ impl Plugin {
e => e,
})?;
p.manifest = Manifest::parse(&text)?;
// A heavy filter's runs get the larger budget, which the host lets the user allow or
// refuse before installing it.
if p.manifest.budget == crate::Budget::Heavy {
p.limits.fuel_base = p.limits.fuel_base.max(HEAVY_FUEL);
p.limits.wall_time_ms = p.limits.wall_time_ms.max(HEAVY_WALL_TIME_MS);
}
// The other exports must exist with the right types.
live.func::<i32, i32>("vc_alloc")?;
live.func::<RunArgs, i64>("vc_run")?;
+21 -1
View File
@@ -36,11 +36,31 @@ pub const ECHO: &str = "(i64.or (i64.shl (i64.extend_i32_u (local.get $il)) (i64
/// A plug-in that always returns `output` (whatever it is given).
pub fn returning(manifest: &str, output: &str) -> String {
let run = format!("(i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len());
then_returning(manifest, "", output)
}
/// A plug-in whose `vc_run` runs `first` (instructions, with the locals it declares first), then
/// returns `output`.
fn then_returning(manifest: &str, first: &str, output: &str) -> String {
let run = format!("{first} (i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len());
let extra = format!(r#"(data (i32.const {OUTPUT_AT}) "{}")"#, string(output));
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
}
/// A plug-in that counts `steps` down before it returns `output`. Each step costs a few
/// instructions, so a test picks work that a light budget can't finish and a heavy one can, or
/// that runs until it is cancelled.
pub fn spinning(manifest: &str, steps: u64, output: &str) -> String {
let spin = format!(
"(local $n i64) (local.set $n (i64.const {steps}))
(block $done (loop $step
(br_if $done (i64.le_s (local.get $n) (i64.const 0)))
(local.set $n (i64.sub (local.get $n) (i64.const 1)))
(br $step)))"
);
then_returning(manifest, &spin, output)
}
/// What [`when_given`] looks through.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Given {
+30 -1
View File
@@ -6,7 +6,7 @@
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use vectorcraft_plugins::{Error, Kind, Limits, Plugin, registry, wat as tpl};
use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, heavy_terms, registry, wat as tpl};
/// The example Desaturate plug-in, built from `crates/plugins/example`.
const DESATURATE: &[u8] = include_bytes!("fixtures/desaturate.wasm");
@@ -179,6 +179,20 @@ fn cancelling_stops_a_run_between_two_slices_of_its_budget() {
assert_eq!(e, Error::Limit("instruction budget".into()));
}
/// A filter that asks for a heavy budget gets 20 times the instructions and five times the time of
/// a light one, so it finishes work a light one can't: here 100 million steps of a loop. The
/// budget is its own: what it returns can't add to it, since the host learns that only at the end.
#[test]
fn a_heavy_filter_finishes_what_a_light_one_cannot() {
let heavy = r#"{"id":"test.heavy","name":"Heavy","version":"1","kind":"filter","budget":"heavy"}"#;
let (light, heavy) = (build(&tpl::spinning(MANIFEST, 100_000_000, INPUT)), build(&tpl::spinning(heavy, 100_000_000, INPUT)));
assert_eq!(run_err(&light, Limits::default()).0, Error::Limit("instruction budget".into()));
let p = Plugin::load(&heavy, Limits::default()).unwrap();
assert_eq!((p.limits().fuel_base, p.limits().wall_time_ms), (HEAVY_FUEL, HEAVY_WALL_TIME_MS));
assert_eq!(heavy_terms(), (5, 20));
assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes());
}
#[test]
fn an_exporter_returns_its_file_with_an_export_s_budget() {
use vectorcraft_plugins::export::{self, Target};
@@ -267,3 +281,18 @@ fn folder_loading_skips_bad_files() {
assert!(registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module");
std::fs::remove_dir_all(&dir).ok();
}
/// A heavy filter installs once the user agrees, which loading a folder doesn't ask: it is skipped
/// and reported.
#[test]
fn folder_loading_skips_heavy_filters() {
let dir = std::env::temp_dir().join(format!("vectorcraft-plugins-heavy-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let manifest = r#"{"id":"test.folder.heavy","name":"Heavy","kind":"filter","budget":"heavy"}"#;
std::fs::write(dir.join("heavy.wasm"), build(&tpl::spinning(manifest, 1, INPUT))).unwrap();
let r = registry::load_folder(&dir).unwrap();
assert!(r.loaded.is_empty());
assert!(r.failed.len() == 1 && r.failed[0].1.contains("heavy budget"), "{:?}", r.failed);
assert!(registry::get("test.folder.heavy").is_none());
std::fs::remove_dir_all(&dir).ok();
}
+44 -8
View File
@@ -5,13 +5,15 @@
//! effect dialog (`effect.dialog`). A filter with parameters opens dialog `plugin`: one field per
//! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live
//! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs
//! at once. Exporters are formats of File › Export › Export As; one with parameters opens dialog
//! `pluginExport` once the file is picked, and its Export button writes the file.
//! at once. A heavy filter (its manifest's `budget`) has no Preview, since a run may take minutes:
//! it runs in the background ([`crate::plugin_runs`]). Exporters are formats of File › Export ›
//! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its
//! Export button writes the file.
use std::sync::Arc;
use serde_json::{Map, Value, json};
use vectorcraft_plugins::{Kind, Manifest, Plugin, registry};
use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, registry};
use super::{DialogSpec, form};
use crate::VectorcraftApp;
@@ -80,10 +82,12 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
}
let given = p.get("params").filter(|v| v.as_object().is_some_and(|o| !o.is_empty()));
if m.params.is_empty() || given.is_some() {
return app.run(CMD, json!({"id": id, "params": given.cloned().unwrap_or(Value::Null)}));
return run(app, &plugin, json!({"id": id, "params": given.cloned().unwrap_or(Value::Null)}));
}
let mut fields = fields_for(m);
fields.insert("preview".into(), json!(true));
if m.budget == Budget::Light {
fields.insert("preview".into(), json!(true));
}
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
Ok(Value::Null)
}
@@ -113,16 +117,48 @@ fn params(d: &Dialog) -> Value {
json!({"id": d.str("__plugin"), "params": form::params(d)})
}
/// Runs filter `plugin` with `plugin.run`'s params `p`: at once, or in the background when it is
/// heavy.
fn run(app: &mut VectorcraftApp, plugin: &Plugin, p: Value) -> Result<Value, String> {
match plugin.manifest().budget {
Budget::Light => app.run(CMD, p),
Budget::Heavy => crate::plugin_runs::start(app, p),
}
}
/// The plug-in's description and fields, then the live Preview; a heavy filter has a note instead
/// that it runs in the background, since a run may take minutes.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let Some(plugin) = described(ui, d) else { return true };
form::schema_fields(app, ui, d, plugin.manifest());
let (label, p) = (d.str("__label"), params(d));
form::preview(app, ui, d, &label, CMD, p);
match plugin.manifest().budget {
Budget::Light => {
let (label, p) = (d.str("__label"), params(d));
form::preview(app, ui, d, &label, CMD, p);
}
// No Preview: each run may take minutes.
Budget::Heavy => {
ui.add_space(8.0);
let note = tl!("It may take minutes, so it runs in the background, where you can cancel it.");
ui.label(egui::RichText::new(note).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
}
}
false
}
/// OK: a filter keeps its previewed run as one undo step; a heavy one, which has no preview, starts
/// in the background ([`run`]) and the dialog closes.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
form::commit_preview(app, CMD, params(d))
match registry::get(&d.str("__plugin")) {
Some(plugin) if plugin.manifest().budget == Budget::Heavy => {
let r = run(app, &plugin, params(d));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
_ => form::commit_preview(app, CMD, params(d)),
}
}
/// The dialog kind of an exporter's parameters, after Export As picked the file.
+3
View File
@@ -531,6 +531,8 @@ struct PluginIntField {
}
impl PluginIntField {
/// The dialog for a `seed` in `min..=max` showing `start`, drawn once to find the number,
/// where drags start (`pos`).
fn new(min: i64, max: i64, start: i64) -> Self {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
@@ -544,6 +546,7 @@ impl PluginIntField {
author: String::new(),
params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())],
extensions: Vec::new(),
budget: Default::default(),
};
let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO };
fn number_rect(shape: &egui::Shape, text: &str) -> Option<egui::Rect> {
+49 -17
View File
@@ -41,6 +41,9 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String,
Some((first.label.clone(), done, total, frac, jobs.len() - 1))
}
/// Draws the window for the first export running in the background that wasn't hidden or
/// cancelled, once it has run [`DELAY`] seconds and no other dialog is open, and runs the command
/// of the button pressed.
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let Some((label, done, total, frac, more)) = shown(app, now, ctx) else { return };
@@ -49,11 +52,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
let t = Tokens::get(ctx);
let heading = format!("{}…", crate::i18n::msg(&label));
let mut action = None;
modal::show(ctx, &heading, egui::Id::new("export-progress"), -40.0, 20, |ui| {
ui.set_width(360.0);
modal::heading(ui, &heading);
ui.add_space(12.0);
let pressed = window(ctx, "export-progress", &heading, |ui| {
// One step says nothing a spinner doesn't: the bar would sit at 0 % until the file is written.
if total > 1 {
let step = crate::i18n::fmt(tl!("Artboard {n} of {total}"), &[("n", &(done + 1).min(total).to_string()), ("total", &total.to_string())]);
@@ -67,36 +66,69 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
ui.add_space(4.0);
ui.add(egui::ProgressBar::new(frac).desired_width(360.0).desired_height(6.0).fill(t.accent));
} else {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0).color(t.accent));
ui.label(egui::RichText::new(tl!("Encoding and writing the file…")).color(t.text_dim));
});
busy(ui, tl!("Encoding and writing the file…"));
}
if more > 0 {
ui.add_space(6.0);
let queued = crate::i18n::tn(more as u64, "{n} more export waiting", "{n} more exports waiting");
ui.label(egui::RichText::new(queued).size(11.5).color(t.text_dim));
}
});
let id = match pressed {
Some(Pressed::Cancel) => "file.cancelExport",
Some(Pressed::Hide) => "file.exportInBackground",
None => return,
};
if let Err(e) = app.run(id, json!({})) {
app.status(e);
}
}
/// A button of a progress [`window`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pressed {
/// Cancel, or Esc: stop the work.
Cancel,
/// Continue in Background: close the window; the work goes on, shown in the status bar.
Hide,
}
/// A modal window (`id`) headed `heading`, in the UI language: `body` draws how far the work has
/// got, then come Cancel (also Esc) and Continue in Background → the one pressed. Work that may
/// take a while in the background shows it: exports, and heavy plug-in filters
/// ([`crate::plugin_runs`]).
pub fn window(ctx: &egui::Context, id: &str, heading: &str, body: impl FnOnce(&mut egui::Ui)) -> Option<Pressed> {
let mut pressed = None;
modal::show(ctx, heading, egui::Id::new(id), -40.0, 20, |ui| {
ui.set_width(360.0);
modal::heading(ui, heading);
ui.add_space(12.0);
body(ui);
ui.add_space(16.0);
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::secondary_button(ui, "Cancel").clicked() {
action = Some("file.cancelExport");
pressed = Some(Pressed::Cancel);
}
ui.add_space(8.0);
if widgets::secondary_button(ui, "Continue in Background").clicked() {
action = Some("file.exportInBackground");
pressed = Some(Pressed::Hide);
}
});
});
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
action = Some("file.cancelExport");
}
if let Some(id) = action
&& let Err(e) = app.run(id, json!({}))
{
app.status(e);
pressed = Some(Pressed::Cancel);
}
pressed
}
/// Work that counts no steps, in a [`window`]: a spinner and `what` it does, in the UI language.
pub fn busy(ui: &mut egui::Ui, what: &str) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0).color(t.accent));
ui.label(egui::RichText::new(what).color(t.text_dim));
});
}
#[cfg(test)]
+7
View File
@@ -5396,3 +5396,10 @@ axis Y Axis Y ос
@msg `dither` applies to gradient fills (paint.setFill) `dither` се прилага към градиентни запълки (paint.setFill)
@msg `dither` must be true or false `dither` трябва да бъде true или false
@msg `method` must be classic or perceptual `method` трябва да е classic или perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Да се инсталира ли „{name}“, който иска голям изчислителен бюджет?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Всяко изпълнение може да продължи до {minutes} минути и да свърши {times} пъти повече работа, отколкото е позволено на другите приставки. То се изпълнява във фонов режим и можете да го откажете.
It may take minutes, so it runs in the background, where you can cancel it. Това може да отнеме минути, затова се изпълнява във фонов режим, където можете да го откажете.
The plug-in is computing… Приставката изчислява…
Inspect Plug-in Проверка на приставка
Cancel Plug-in Run Отказ от изпълнението на приставката
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5442,3 +5442,15 @@ axis Y Axis osa Y
@msg `dither` applies to gradient fills (paint.setFill) `dither` platí pro výplně přechodem (paint.setFill)
@msg `dither` must be true or false `dither` musí být true nebo false
@msg `method` must be classic or perceptual `method` musí být classic nebo perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Nainstalovat „{name}“, který žádá o velký výpočetní rozpočet?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Každé spuštění může trvat až {minutes} minut a vykonat {times}krát víc práce, než smějí jiné zásuvné moduly. Běží na pozadí a můžete ho zrušit.
It may take minutes, so it runs in the background, where you can cancel it. Může to trvat několik minut, proto běží na pozadí, kde ho můžete zrušit.
The plug-in is computing… Zásuvný modul počítá…
Inspect Plug-in Prozkoumat zásuvný modul
Cancel Plug-in Run Zrušit běh zásuvného modulu
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} doběhl, ale dokument nebo zásuvný modul se mezitím změnil: spusťte ho znovu
@msg Running “{name}”… Spouští se „{name}“…
@msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy`
@msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): nainstalujte ho s `allowHeavy: true`, jakmile uživatel souhlasí
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5412,3 +5412,10 @@ axis Y Axis Y-akse
@msg `dither` applies to gradient fills (paint.setFill) `dither` gælder for gradientfyld (paint.setFill)
@msg `dither` must be true or false `dither` skal være true eller false
@msg `method` must be classic or perceptual `method` skal være classic eller perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Installér »{name}«, som beder om et stort beregningsbudget?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Hver kørsel kan tage op til {minutes} minutter og udføre {times} gange så meget arbejde, som andre plug-ins må. Den kører i baggrunden, og du kan annullere den.
It may take minutes, so it runs in the background, where you can cancel it. Det kan tage minutter, så det kører i baggrunden, hvor du kan annullere det.
The plug-in is computing… Plug-in'en regner…
Inspect Plug-in Undersøg plug-in
Cancel Plug-in Run Annullér plug-in-kørsel
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5025,3 +5025,15 @@ axis Y Axis Y-Achse
@msg `dither` must be true or false `dither` muss true oder false sein
Perceptual Perzeptiv
@msg `method` must be classic or perceptual `method` muss classic oder perceptual sein
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? „{name}“ installieren, das ein großes Rechenbudget verlangt?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Jede Ausführung kann bis zu {minutes} Minuten dauern und {times}-mal so viel Arbeit leisten, wie andere Plug-ins dürfen. Sie läuft im Hintergrund, und Sie können sie abbrechen.
It may take minutes, so it runs in the background, where you can cancel it. Das kann Minuten dauern, daher läuft es im Hintergrund, wo Sie es abbrechen können.
The plug-in is computing… Das Plug-in rechnet…
Inspect Plug-in Plug-in prüfen
Cancel Plug-in Run Plug-in-Ausführung abbrechen
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} ist fertig, aber das Dokument oder das Plug-in hat sich inzwischen geändert: Führen Sie es erneut aus
@msg Running “{name}”… „{name}“ wird ausgeführt…
@msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein
@msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Installieren Sie es mit `allowHeavy: true`, sobald der Benutzer zustimmt
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5394,3 +5394,15 @@ axis Y Axis Άξονας Y
@msg `dither` applies to gradient fills (paint.setFill) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (paint.setFill)
@msg `dither` must be true or false Το `dither` πρέπει να είναι true ή false
@msg `method` must be classic or perceptual Το `method` πρέπει να είναι classic ή perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Να εγκατασταθεί το «{name}», που ζητά μεγάλο υπολογιστικό προϋπολογισμό;
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Κάθε εκτέλεση μπορεί να διαρκέσει έως {minutes} λεπτά και να κάνει {times} φορές τη δουλειά που επιτρέπεται στα άλλα πρόσθετα. Εκτελείται στο παρασκήνιο και μπορείτε να την ακυρώσετε.
It may take minutes, so it runs in the background, where you can cancel it. Μπορεί να διαρκέσει λεπτά, γι’ αυτό εκτελείται στο παρασκήνιο, όπου μπορείτε να το ακυρώσετε.
The plug-in is computing… Το πρόσθετο υπολογίζει…
Inspect Plug-in Επιθεώρηση προσθέτου
Cancel Plug-in Run Ακύρωση εκτέλεσης προσθέτου
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again Το {_1} ολοκληρώθηκε, αλλά το έγγραφο ή το πρόσθετο άλλαξε στο μεταξύ: εκτελέστε το ξανά
@msg Running “{name}”… Εκτέλεση του «{name}»…
@msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy`
@msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): εγκαταστήστε το με `allowHeavy: true` μόλις συμφωνήσει ο χρήστης
Can't render this file because it contains an unexpected character in line 2045 and column 19.
+12
View File
@@ -5384,3 +5384,15 @@ axis Y Axis Eje Y
@msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a rellenos de degradado (paint.setFill)
@msg `dither` must be true or false `dither` debe ser true o false
@msg `method` must be classic or perceptual `method` debe ser classic o perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? ¿Instalar “{name}”, que pide un presupuesto de cálculo grande?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Cada ejecución puede tardar hasta {minutes} minutos y hacer {times} veces el trabajo que se permite a otros plugins. Se ejecuta en segundo plano y se puede cancelar.
It may take minutes, so it runs in the background, where you can cancel it. Puede tardar minutos, así que se ejecuta en segundo plano, donde se puede cancelar.
The plug-in is computing… El plugin está calculando…
Inspect Plug-in Inspeccionar plugin
Cancel Plug-in Run Cancelar ejecución del plugin
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} terminó, pero el documento o el plugin cambió entretanto: vuelva a ejecutarlo
@msg Running “{name}”… Ejecutando “{name}”…
@msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy`
@msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): instálalo con `allowHeavy: true` cuando el usuario lo acepte
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5473,3 +5473,15 @@ axis Y Axis Y-akseli
@msg `dither` applies to gradient fills (paint.setFill) `dither` koskee liukuväritäyttöjä (paint.setFill)
@msg `dither` must be true or false `dither` on oltava true tai false
@msg `method` must be classic or perceptual `method` on oltava classic tai perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Asennetaanko ”{name}”, joka pyytää suurta laskentabudjettia?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Jokainen ajo voi kestää enintään {minutes} minuuttia ja tehdä {times} kertaa niin paljon työtä kuin muut laajennukset saavat. Se toimii taustalla, ja voit peruuttaa sen.
It may take minutes, so it runs in the background, where you can cancel it. Tämä voi kestää minuutteja, joten se toimii taustalla, missä voit peruuttaa sen.
The plug-in is computing… Laajennus laskee…
Inspect Plug-in Tarkastele laajennusta
Cancel Plug-in Run Peruuta laajennuksen ajo
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} valmistui, mutta asiakirja tai laajennus muuttui sillä välin: suorita se uudelleen
@msg Running “{name}”… Suoritetaan ”{name}”…
@msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy`
@msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): asenna se asetuksella `allowHeavy: true`, kun käyttäjä hyväksyy
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5441,3 +5441,15 @@ axis Y Axis Axe Y
@msg `dither` applies to gradient fills (paint.setFill) `dither` s’applique aux fonds en dégradé (paint.setFill)
@msg `dither` must be true or false `dither` doit valoir true ou false
@msg `method` must be classic or perceptual `method` doit être classic ou perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Installer « {name} », qui demande un budget de calcul important ?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Chaque exécution peut durer jusqu’à {minutes} minutes et faire {times} fois le travail permis aux autres modules externes. Elle s’exécute en arrière-plan, et vous pouvez l’annuler.
It may take minutes, so it runs in the background, where you can cancel it. Cela peut prendre plusieurs minutes : il s’exécute donc en arrière-plan, où vous pouvez l’annuler.
The plug-in is computing… Le module externe calcule…
Inspect Plug-in Examiner le module externe
Cancel Plug-in Run Annuler l’exécution du module externe
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} a terminé, mais le document ou le module externe a changé entre-temps : relancez-le
@msg Running “{name}”… Exécution de « {name} »…
@msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy`
@msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5428,3 +5428,15 @@ axis Y Axis Y tengely
@msg `dither` applies to gradient fills (paint.setFill) A `dither` színátmenetes kitöltésekre vonatkozik (paint.setFill)
@msg `dither` must be true or false `dither`: true vagy false kell
@msg `method` must be classic or perceptual a `method` értéke classic vagy perceptual lehet
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Telepíted a(z) „{name}” bővítményt, amely nagy számítási keretet kér?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Egy futás akár {minutes} percig tarthat, és {times}-szor annyi munkát végezhet, mint amennyit más bővítmények. A háttérben fut, és megszakíthatod.
It may take minutes, so it runs in the background, where you can cancel it. Percekig is tarthat, ezért a háttérben fut, ahol megszakíthatod.
The plug-in is computing… A bővítmény számol…
Inspect Plug-in Bővítmény vizsgálata
Cancel Plug-in Run Bővítmény futásának megszakítása
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} lefutott, de közben megváltozott a dokumentum vagy a bővítmény: futtasd újra
@msg Running “{name}”… Futtatás: „{name}”…
@msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy`
@msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): telepítsd az `allowHeavy: true` megadásával, miután a felhasználó beleegyezett
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5412,3 +5412,10 @@ axis Y Axis Sumbu Y
@msg `dither` applies to gradient fills (paint.setFill) `dither` berlaku untuk isian gradien (paint.setFill)
@msg `dither` must be true or false `dither` harus true atau false
@msg `method` must be classic or perceptual `method` harus classic atau perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Instal “{name}”, yang meminta anggaran komputasi besar?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Setiap kali dijalankan, plug-in ini bisa memakan waktu hingga {minutes} menit dan melakukan {times} kali pekerjaan yang diizinkan untuk plug-in lain. Plug-in ini berjalan di latar belakang, dan Anda dapat membatalkannya.
It may take minutes, so it runs in the background, where you can cancel it. Ini bisa memakan waktu beberapa menit, jadi berjalan di latar belakang, tempat Anda dapat membatalkannya.
The plug-in is computing… Plug-in sedang menghitung…
Inspect Plug-in Periksa plug-in
Cancel Plug-in Run Batalkan jalannya plug-in
Can't render this file because it contains an unexpected character in line 3816 and column 19.
+12
View File
@@ -5059,3 +5059,15 @@ axis Y Axis Asse Y
@msg `dither` must be true or false `dither` deve essere true o false
Perceptual Percettivo
@msg `method` must be classic or perceptual `method` deve essere classic o perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Installare “{name}”, che chiede un budget di calcolo elevato?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Ogni esecuzione può durare fino a {minutes} minuti e svolgere {times} volte il lavoro consentito agli altri plug-in. Viene eseguita in background ed è possibile annullarla.
It may take minutes, so it runs in the background, where you can cancel it. Può richiedere minuti, quindi viene eseguito in background, dove è possibile annullarlo.
The plug-in is computing… Il plug-in sta calcolando…
Inspect Plug-in Esamina plug-in
Cancel Plug-in Run Annulla esecuzione plug-in
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} è terminato, ma nel frattempo il documento o il plug-in è cambiato: va eseguito di nuovo
@msg Running “{name}”… Esecuzione di “{name}”…
@msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy`
@msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): installalo con `allowHeavy: true` quando l’utente acconsente
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5121,3 +5121,15 @@ axis Y Axis Y 軸
@msg `dither` must be true or false `dither` には true または false を指定してください
Perceptual 知覚的
@msg `method` must be classic or perceptual `method` には classic または perceptual を指定してください
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? 大きな計算予算を求める「{name}」をインストールしますか?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 1 回の実行に最大 {minutes} 分かかり、ほかのプラグインに許される {times} 倍の処理を行うことがあります。バックグラウンドで実行され、キャンセルできます。
It may take minutes, so it runs in the background, where you can cancel it. 数分かかることがあるため、バックグラウンドで実行されます。実行中はキャンセルできます。
The plug-in is computing… プラグインが計算中…
Inspect Plug-in プラグインを検査
Cancel Plug-in Run プラグインの実行をキャンセル
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} は完了しましたが、その間にドキュメントまたはプラグインが変更されました。もう一度実行してください
@msg Running “{name}”… 「{name}」を実行中…
@msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません
@msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`budget` は省略してください
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。ユーザーが同意したら `allowHeavy: true` を指定してインストールしてください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5358,3 +5358,15 @@ axis Y Axis Y ღერძი
@msg `dither` applies to gradient fills (paint.setFill) `dither` ვრცელდება გრადიენტულ შევსებებზე (paint.setFill)
@msg `dither` must be true or false `dither` უნდა იყოს true ან false
@msg `method` must be classic or perceptual `method` უნდა იყოს classic ან perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? დავაყენოთ „{name}“, რომელიც დიდ გამოთვლით ბიუჯეტს ითხოვს?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. თითოეულ გაშვებას შეიძლება {minutes} წუთამდე დასჭირდეს და სხვა პლაგინებისთვის დაშვებულზე {times}-ჯერ მეტი სამუშაო შეასრულოს. ის ფონურ რეჟიმში მუშაობს და შეგიძლიათ გააუქმოთ.
It may take minutes, so it runs in the background, where you can cancel it. ამას შეიძლება წუთები დასჭირდეს, ამიტომ ის ფონურ რეჟიმში მუშაობს, სადაც შეგიძლიათ გააუქმოთ.
The plug-in is computing… პლაგინი ითვლის…
Inspect Plug-in პლაგინის შემოწმება
Cancel Plug-in Run პლაგინის გაშვების გაუქმება
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} დასრულდა, მაგრამ ამასობაში დოკუმენტი ან პლაგინი შეიცვალა: გაუშვით ხელახლა
@msg Running “{name}”… მიმდინარეობს „{name}“-ის გაშვება…
@msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy`
@msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `allowHeavy: true` პარამეტრით
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5022,3 +5022,15 @@ axis Y Axis Y축
@msg `dither` must be true or false `dither`은(는) true 또는 false여야 합니다
Perceptual 지각적
@msg `method` must be classic or perceptual `method`는 classic 또는 perceptual이어야 합니다
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? 큰 계산 예산을 요청하는 “{name}”을(를) 설치하시겠습니까?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 한 번 실행에 최대 {minutes}분이 걸리고 다른 플러그인에 허용되는 작업량의 {times}배를 수행할 수 있습니다. 백그라운드에서 실행되며 취소할 수 있습니다.
It may take minutes, so it runs in the background, where you can cancel it. 몇 분이 걸릴 수 있으므로 백그라운드에서 실행되며, 실행 중에 취소할 수 있습니다.
The plug-in is computing… 플러그인이 계산하는 중…
Inspect Plug-in 플러그인 검사
Cancel Plug-in Run 플러그인 실행 취소
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1}이(가) 끝났지만 그동안 문서나 플러그인이 변경되었습니다. 다시 실행하세요
@msg Running “{name}”… “{name}” 실행 중…
@msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다
@msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `budget`은 생략하세요
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 사용자가 동의하면 `allowHeavy: true`로 설치하세요
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5455,3 +5455,10 @@ axis Y Axis Y-as
@msg `dither` applies to gradient fills (paint.setFill) `dither` geldt voor verloopvullingen (paint.setFill)
@msg `dither` must be true or false `dither` moet true of false zijn
@msg `method` must be classic or perceptual `method` moet classic of perceptual zijn
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? „{name}” installeren, dat om een groot rekenbudget vraagt?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Elke run kan tot {minutes} minuten duren en {times} keer zoveel werk doen als andere plug-ins mogen. Hij draait op de achtergrond en je kunt hem annuleren.
It may take minutes, so it runs in the background, where you can cancel it. Dit kan minuten duren, dus het draait op de achtergrond, waar je het kunt annuleren.
The plug-in is computing… De plug-in is aan het rekenen…
Inspect Plug-in Plug-in inspecteren
Cancel Plug-in Run Plug-in-run annuleren
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5294,3 +5294,15 @@ axis Y Axis Oś Y
@msg `dither` applies to gradient fills (paint.setFill) `dither` dotyczy wypełnień gradientem (paint.setFill)
@msg `dither` must be true or false `dither` musi mieć wartość true lub false
@msg `method` must be classic or perceptual `method` musi mieć wartość classic lub perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Zainstalować „{name}”, wtyczkę, która prosi o duży budżet obliczeń?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Każde uruchomienie może trwać do {minutes} minut i wykonać {times} razy więcej pracy, niż mogą inne wtyczki. Działa w tle i można je anulować.
It may take minutes, so it runs in the background, where you can cancel it. Może to potrwać kilka minut, dlatego działa w tle, gdzie można to anulować.
The plug-in is computing… Wtyczka wykonuje obliczenia…
Inspect Plug-in Sprawdź wtyczkę
Cancel Plug-in Run Anuluj działanie wtyczki
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} zakończył działanie, ale w międzyczasie zmienił się dokument lub wtyczka: uruchom ją ponownie
@msg Running “{name}”… Uruchamianie „{name}”…
@msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy`
@msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): zainstaluj wtyczkę z `allowHeavy: true`, gdy użytkownik się zgodzi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5359,3 +5359,15 @@ axis Y Axis Eixo Y
@msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a preenchimentos em gradiente (paint.setFill)
@msg `dither` must be true or false `dither` deve ser true ou false
@msg `method` must be classic or perceptual `method` deve ser classic ou perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Instalar “{name}”, que pede um orçamento de processamento grande?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Cada execução pode levar até {minutes} minutos e fazer {times} vezes o trabalho permitido a outros plug-ins. Ela é executada em segundo plano, e você pode cancelá-la.
It may take minutes, so it runs in the background, where you can cancel it. Pode levar minutos, então é executado em segundo plano, onde você pode cancelá-lo.
The plug-in is computing… O plug-in está processando…
Inspect Plug-in Inspecionar Plug-in
Cancel Plug-in Run Cancelar Execução do Plug-in
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} terminou, mas o documento ou o plug-in mudou nesse meio-tempo: execute-o novamente
@msg Running “{name}”… Executando “{name}”…
@msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy`
@msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): instale-o com `allowHeavy: true` depois que o usuário concordar
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12
View File
@@ -5055,3 +5055,15 @@ axis Y Axis Ось Y
@msg `dither` must be true or false `dither` должно быть true или false
Perceptual Перцепционный
@msg `method` must be classic or perceptual `method` должен быть classic или perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Установить «{name}», которому нужен большой вычислительный бюджет?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Каждый запуск может длиться до {minutes} минут и выполнять в {times} раз больше работы, чем разрешено другим модулям. Он работает в фоне, и его можно отменить.
It may take minutes, so it runs in the background, where you can cancel it. Это может занять несколько минут, поэтому модуль работает в фоне, где его можно отменить.
The plug-in is computing… Модуль выполняет вычисления…
Inspect Plug-in Проверить модуль
Cancel Plug-in Run Отменить запуск модуля
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} завершился, но документ или модуль за это время изменился: запустите его снова
@msg Running “{name}”… Выполняется «{name}»…
@msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy`
@msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): установите его с `allowHeavy: true`, когда пользователь согласится
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5398,3 +5398,15 @@ axis Y Axis Y-axel
@msg `dither` applies to gradient fills (paint.setFill) `dither` gäller övertoningsfyllningar (paint.setFill)
@msg `dither` must be true or false `dither` måste vara true eller false
@msg `method` must be classic or perceptual `method` måste vara classic eller perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Installera ”{name}”, som begär en stor beräkningsbudget?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Varje körning kan ta upp till {minutes} minuter och göra {times} gånger så mycket arbete som andra insticksprogram får. Den körs i bakgrunden och du kan avbryta den.
It may take minutes, so it runs in the background, where you can cancel it. Det kan ta flera minuter, så det körs i bakgrunden, där du kan avbryta det.
The plug-in is computing… Insticksprogrammet räknar…
Inspect Plug-in Granska insticksprogram
Cancel Plug-in Run Avbryt körning av insticksprogram
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} är klar, men dokumentet eller insticksprogrammet ändrades under tiden: kör det igen
@msg Running “{name}”… Kör ”{name}”…
@msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy`
@msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): installera det med `allowHeavy: true` när användaren har godkänt
Can't render this file because it contains an unexpected character in line 5 and column 25.
+12
View File
@@ -5405,3 +5405,15 @@ axis Y Axis Y ekseni
Snap to Perpendicular Dikeye Yasla
Geometric Guides Geometrik Kılavuzlar
@msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Büyük bir hesaplama bütçesi isteyen “{name}” yüklensin mi?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Her çalıştırma {minutes} dakikaya kadar sürebilir ve diğer eklentilere izin verilen işin {times} katını yapabilir. Arka planda çalışır ve iptal edebilirsiniz.
It may take minutes, so it runs in the background, where you can cancel it. Dakikalar sürebileceği için arka planda çalışır; orada iptal edebilirsiniz.
The plug-in is computing… Eklenti hesaplıyor…
Inspect Plug-in Eklentiyi İncele
Cancel Plug-in Run Eklenti Çalıştırmasını İptal Et
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} bitti, ancak bu sırada belge veya eklenti değişti: yeniden çalıştırın
@msg Running “{name}”… “{name}” çalıştırılıyor…
@msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz
@msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): kullanıcı onayladıktan sonra `allowHeavy: true` ile yükleyin
Can't render this file because it contains an unexpected character in line 5 and column 25.
+12
View File
@@ -5039,3 +5039,15 @@ axis Y Axis Вісь Y
@msg `dither` must be true or false `dither` має бути true або false
Perceptual Перцептивний
@msg `method` must be classic or perceptual `method` має бути classic або perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Встановити «{name}», якому потрібен великий обчислювальний бюджет?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Кожен запуск може тривати до {minutes} хвилин і виконувати в {times} разів більше роботи, ніж дозволено іншим модулям. Він працює у фоні, і його можна скасувати.
It may take minutes, so it runs in the background, where you can cancel it. Це може тривати кілька хвилин, тож модуль працює у фоні, де його можна скасувати.
The plug-in is computing… Модуль виконує обчислення…
Inspect Plug-in Перевірити модуль
Cancel Plug-in Run Скасувати запуск модуля
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} завершився, але документ або модуль тим часом змінився: запустіть його знову
@msg Running “{name}”… Виконується «{name}»…
@msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy`
@msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): встановіть його з `allowHeavy: true`, коли користувач погодиться
Can't render this file because it contains an unexpected character in line 733 and column 40.
+12
View File
@@ -5039,3 +5039,15 @@ axis Y Axis Trục Y
@msg `dither` must be true or false `dither` phải là true hoặc false
Perceptual Theo cảm nhận
@msg `method` must be classic or perceptual `method` phải là classic hoặc perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Cài đặt “{name}”, plug-in yêu cầu ngân sách tính toán lớn?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Mỗi lần chạy có thể mất tới {minutes} phút và làm khối lượng việc gấp {times} lần mức cho phép với các plug-in khác. Plug-in chạy trong nền và bạn có thể hủy.
It may take minutes, so it runs in the background, where you can cancel it. Việc này có thể mất vài phút nên plug-in chạy trong nền, nơi bạn có thể hủy.
The plug-in is computing… Plug-in đang tính toán…
Inspect Plug-in Kiểm tra plug-in
Cancel Plug-in Run Hủy lần chạy plug-in
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} đã chạy xong, nhưng tài liệu hoặc plug-in đã thay đổi trong lúc đó: hãy chạy lại
@msg Running “{name}”… Đang chạy “{name}”…
@msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy`
@msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): hãy cài đặt với `allowHeavy: true` khi người dùng đồng ý
Can't render this file because it contains an unexpected character in line 4030 and column 10.
+12
View File
@@ -5282,3 +5282,15 @@ axis Y Axis Y 轴
@msg `dither` applies to gradient fills (paint.setFill) `dither` 适用于渐变填色 (paint.setFill)
@msg `dither` must be true or false `dither` 必须是 true 或 false
@msg `method` must be classic or perceptual `method` 必须是 classic 或 perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? 要安装需要大量计算预算的“{name}”吗?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 每次运行最多可能需要 {minutes} 分钟,并可完成其他插件所允许工作量的 {times} 倍。它在后台运行,可以随时取消。
It may take minutes, so it runs in the background, where you can cancel it. 这可能需要几分钟,因此会在后台运行,运行时可以取消。
The plug-in is computing… 插件正在计算…
Inspect Plug-in 检查插件
Cancel Plug-in Run 取消运行插件
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} 已完成,但文档或插件在此期间已更改:请重新运行
@msg Running “{name}”… 正在运行“{name}”…
@msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy`
@msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):请在用户同意后使用 `allowHeavy: true` 安装
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -5078,3 +5078,15 @@ axis Y Axis Y 軸
@msg `dither` must be true or false `dither` 必須是 true 或 false
Perceptual 感知
@msg `method` must be classic or perceptual `method` 必須是 classic 或 perceptual
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? 要安裝需要大量運算預算的「{name}」嗎?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 每次執行最多可能需要 {minutes} 分鐘,並可完成其他增效模組所允許工作量的 {times} 倍。它會在背景執行,您可以隨時取消。
It may take minutes, so it runs in the background, where you can cancel it. 這可能需要數分鐘,因此會在背景執行,您可以在執行時取消。
The plug-in is computing… 增效模組正在運算…
Inspect Plug-in 檢查增效模組
Cancel Plug-in Run 取消執行增效模組
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} 已完成,但文件或增效模組在此期間已變更:請重新執行
@msg Running “{name}”… 正在執行「{name}」…
@msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy`
@msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):請在使用者同意後以 `allowHeavy: true` 安裝
Can't render this file because it contains an unexpected character in line 6 and column 47.
+42 -14
View File
@@ -26,10 +26,8 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
let opens = opens_as(name);
// A WebAssembly plug-in is installed.
if opens == Some(OpensAs::Plugin) {
let r =
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}))?;
app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or(name)));
return Ok(Value::Null);
let module = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)});
return install_module(app, module).map(|_| Value::Null);
}
if opens == Some(OpensAs::ShortcutSet) {
return dialogs::shortcut_import::open(app, name, bytes).map(|_| Value::Null);
@@ -146,17 +144,47 @@ fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String
crate::picks::open(app, pick).ok_or_else(|| "cancelled".into())
}
/// Object › Plug-ins › Install Plug-in…: installs the `.wasm` at `path`, else a picked one (the
/// web's file picker hands the file to [`open_bytes`], which installs it).
pub fn install_plugin(app: &mut VectorcraftApp, path: Option<String>) -> Result<Value, String> {
let path = match path {
Some(p) => p,
None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null),
None => {
pick_open(app, &FilePick { filters: vec![FilePick::filter("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?
/// Object › Plug-ins › Install Plug-in…: installs the module `p` gives (`path`, or `dataBase64`
/// with `name`), else a picked `.wasm` (the web's file picker hands the file to [`open_bytes`],
/// which installs it) → [`install_module`]'s result.
pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let given = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| !v.is_empty());
if given("path") || given("dataBase64") {
return install_module(app, p.clone());
}
if app.services.open_async.is_some() {
return open_dialog(app).map(|_| Value::Null);
}
let path =
pick_open(app, &FilePick { filters: vec![FilePick::filter("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?;
install_module(app, json!({ "path": path }))
}
/// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64`
/// with `name`) → `plugin.install`'s result. A filter that asks for a heavy budget the user hasn't
/// allowed yet (`allowHeavy`) asks them first, and OK installs it → `{pending}` while it asks.
pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result<Value, String> {
if module.get("allowHeavy").and_then(Value::as_bool) != Some(true) {
let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone()));
let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect());
let info = app.run("plugin.inspect", source.clone())?;
if info["budget"] == "heavy" {
let name = info["name"].as_str().unwrap_or_default();
let (minutes, times) = vectorcraft_plugins::heavy_terms();
let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", name)]);
let detail = crate::i18n::fmt(
tl!(
"Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it."
),
&[("minutes", &minutes.to_string()), ("times", &times.to_string())],
);
let mut allowed = source;
allowed["allowHeavy"] = json!(true);
dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed);
return Ok(json!({ "pending": dialogs::confirm::KIND }));
}
};
let r = app.run("plugin.install", json!({ "path": path }))?;
}
let r = app.run("plugin.install", module)?;
app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default()));
Ok(r)
}
+17 -1
View File
@@ -44,6 +44,7 @@ mod panel_docking;
pub mod panels;
pub mod picks;
pub mod place;
pub mod plugin_runs;
pub mod prefs_dialog;
pub mod print;
pub mod recovery;
@@ -116,6 +117,8 @@ mod tests_picks;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_plugin_runs;
#[cfg(test)]
mod tests_plugins;
#[cfg(test)]
mod tests_printps;
@@ -514,6 +517,8 @@ pub struct VectorcraftApp {
pub background: background::Background,
/// Documents being read off the UI thread ([`opening`]).
pub opening: opening::Opening,
/// Heavy plug-in filters running off the UI thread ([`plugin_runs`]).
pub plugin_runs: plugin_runs::PluginRuns,
/// Data Recovery's timer and startup question ([`recovery`]).
pub recovery: recovery::Timer,
/// Modifiers the keyboard holds (from the host's input), given back after synthetic input.
@@ -562,6 +567,9 @@ const SYSTEM_CLIPBOARD_POLL: f64 = 0.25;
const FONT_INSTALL_POLL: f64 = 2.0;
impl VectorcraftApp {
/// The app around `session`, reaching the platform through `services` (file pickers, the
/// clipboard, files and the like). It starts cataloguing the installed fonts at once, gives
/// Data Recovery the host's store and makes a view for each open document.
pub fn new(mut session: Session, services: Services) -> Self {
// The font menus and the first file opened need the installed fonts: catalog them now.
vectorcraft_text::FontDb::global().scan_in_background();
@@ -635,6 +643,7 @@ impl VectorcraftApp {
paste_chord: Default::default(),
background: Default::default(),
opening: Default::default(),
plugin_runs: Default::default(),
recovery: Default::default(),
host_modifiers: Default::default(),
synthetic_modifiers: false,
@@ -1280,6 +1289,9 @@ impl VectorcraftApp {
}
}
/// [`Self::logic`]'s work, inside its guard: the UI language and styling, the clipboard and
/// font polls, the results of work done in the background (saves, exports, files being opened,
/// heavy plug-in filters) and the window's close button.
fn logic_frame(&mut self, ctx: &egui::Context) {
let lang = self.ui_language();
i18n::set_current(lang);
@@ -1327,9 +1339,10 @@ impl VectorcraftApp {
background::poll(self);
screen_color::poll(self, ctx);
opening::poll(self);
plugin_runs::poll(self);
#[cfg(not(target_arch = "wasm32"))]
self.tick_link_updates(ctx, now);
if !self.background.jobs.is_empty() || !self.opening.pending.is_empty() {
if !self.background.jobs.is_empty() || !self.opening.pending.is_empty() || !self.plugin_runs.running.is_empty() {
// Keep the status bar's progress moving and pick the result up when it arrives.
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
@@ -1530,6 +1543,8 @@ impl VectorcraftApp {
}
}
/// [`Self::ui`]'s work, inside its guard: the bars, panels and canvas, then the dialogs and
/// progress windows over them.
fn ui_frame(&mut self, ui: &mut egui::Ui) {
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
let ctx = ui.ctx().clone();
@@ -1582,6 +1597,7 @@ impl VectorcraftApp {
floating::show(self, &ctx);
dialogs::show(self, &ctx);
export_progress::show(self, &ctx);
plugin_runs::show(self, &ctx);
palette::show(self, &ctx);
if self.custom_titlebar {
titlebar::resize_zones(ui);
+23 -2
View File
@@ -1028,7 +1028,19 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.installPlugin",
"Install Plug-in…",
"",
"{path?: a .wasm file} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too)",
"{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A filter that asks for a heavy budget asks the user first: OK installs it → plugin.install's result | {pending: \"confirm\"} while it asks",
),
(
"plugin.cancelRuns",
"Cancel Plug-in Run",
"",
"{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}",
),
(
"plugin.hideRuns",
"Continue in Background",
"",
"{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}",
),
(
"ui.checkScript",
@@ -1195,6 +1207,9 @@ pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Va
session.check_params(id, params).map_err(|e| e.to_string())
}
/// Runs `id` when the UI runs it itself: one of [`UI_COMMANDS`], or an engine command the UI
/// handles its own way (a Window menu panel item that toggles, …) → `None` to leave it to the
/// engine.
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
// The Window menu's panel items toggle like its other checked items (#2361); the panel's
// other ways in (shortcuts, Show Properties Panel, a tool's double-click) only show it.
@@ -1845,7 +1860,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
}
"ui.printPresetDialog" => crate::dialogs::print::open_preset(app, p),
"plugin.dialog" => crate::dialogs::plugin::open(app, p),
"ui.installPlugin" => io::install_plugin(app, s("path")),
"ui.installPlugin" => io::install_plugin(app, p),
"plugin.cancelRuns" => Ok(crate::plugin_runs::cancel_all(app)),
"plugin.hideRuns" => Ok(crate::plugin_runs::hide_all(app)),
"ui.checkScript" => crate::dialogs::script_check::command(app, p),
"ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app),
"ui.perspectivePresetsDialog" => {
@@ -2405,6 +2422,8 @@ pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool {
|| id.starts_with("edit.paste"))
}
/// Can command `id` run now? An engine command asks the engine, a UI command the app's state;
/// none that would act on text an input method is still composing.
pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
if waits_for_ime(app, id) {
return false;
@@ -2421,6 +2440,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()),
"file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()),
"file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()),
"plugin.cancelRuns" => crate::plugin_runs::any_running(app),
"plugin.hideRuns" => crate::plugin_runs::any_shown(app),
"file.place"
| "file.export.svg"
| "file.export.png"
+4 -2
View File
@@ -29,9 +29,11 @@ pub struct Opening {
}
/// Is a long operation running that should show the busy cursor and status spinner (background
/// save/export with a label, or a file opening off the UI thread)?
/// save/export with a label, a file opening off the UI thread, or a heavy plug-in filter)?
pub fn long_operation_busy(app: &VectorcraftApp) -> bool {
app.background.jobs.iter().any(|j| !j.label.is_empty() && !j.cancelled()) || !app.opening.pending.is_empty()
app.background.jobs.iter().any(|j| !j.label.is_empty() && !j.cancelled())
|| !app.opening.pending.is_empty()
|| !app.plugin_runs.running.is_empty()
}
/// Does opening happen off the UI thread here? Not on wasm (no threads), in unit tests (they look
+207
View File
@@ -0,0 +1,207 @@
//! Heavy plug-in filters, run off the UI thread. A filter whose manifest asks for a heavy budget
//! (`Budget::Heavy`: minutes rather than a minute, for routing, packing or simulation) would freeze
//! the app while it computes, so the app runs it on a thread of its own ([`start`]) while editing
//! goes on: the status bar shows it, and after [`DELAY`] a progress window with Cancel (also Esc,
//! `plugin.cancelRuns`) and Continue in Background (`plugin.hideRuns`). A thread of its own, not
//! the background worker's queue ([`crate::background`]), so saves and exports don't wait behind it.
//!
//! When it is done its output is handed to `plugin.run` with the same parameters and objects
//! ([`FilterRun::hand_in`]), so the edit is one undo step like any run, unless the document or the
//! plug-in changed meanwhile: then nothing changes and the status bar says to run it again. On the
//! web (no threads) it runs at once ([`available`]).
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use serde_json::{Value, json};
use vectorcraft_doc::cancel::{self, CancelToken};
use vectorcraft_engine::EngineError;
use vectorcraft_engine::cmd::plugin::FilterRun;
use crate::VectorcraftApp;
use crate::export_progress::{self, Pressed};
pub use crate::export_progress::DELAY;
/// What a run's thread hands back: the run, and its output or why it has none.
type Done = (FilterRun, Result<Vec<u8>, String>);
/// A heavy filter running.
pub struct Running {
id: u64,
/// What the status bar and the progress window say: `Running “Route Paths”…`.
pub label: String,
/// The filter's name, quoted.
who: String,
/// `plugin.run`'s params that apply its output: the objects it was given as `ids`.
params: Value,
/// Its Cancel.
token: CancelToken,
/// Continue in Background: the progress window no longer shows it.
pub hidden: bool,
done: Receiver<Done>,
}
impl Running {
/// Does the progress window show it: neither hidden (Continue in Background) nor cancelled?
fn shown(&self) -> bool {
!self.hidden && !self.token.is_cancelled()
}
}
/// The heavy filters running.
#[derive(Default)]
pub struct PluginRuns {
pub running: Vec<Running>,
next_id: u64,
}
/// Do heavy filters run off the UI thread here? Not on wasm (no threads).
pub fn available() -> bool {
!cfg!(target_arch = "wasm32")
}
/// Runs filter `p` (`plugin.run`'s params) on a thread of its own → `{background: true, status}`;
/// where no thread can run it, at once (`plugin.run`'s result).
pub fn start(app: &mut VectorcraftApp, p: Value) -> Result<Value, String> {
if !available() {
return app.run("plugin.run", p);
}
// Bad parameters and a missing document fail here, before anything runs.
let run = FilterRun::prepare(&app.session, &p).map_err(|e| e.to_string())?;
let who = format!("“{}”", run.plugin().manifest().name.trim_end_matches('…'));
let mut params = p;
params["ids"] = json!(run.inputs().iter().map(|i| i.0).collect::<Vec<_>>());
let (tx, done) = channel();
let token = CancelToken::new();
let stop = token.clone();
std::thread::Builder::new()
.name("vectorcraft-plugin".into())
.spawn(move || {
let output = vectorcraft_engine::guard::catch_panic(|| cancel::scope(&stop, None, || run.run()))
.map_err(|msg| format!("internal error: {msg} (please report this bug)"))
.and_then(|r| r.map_err(|e| e.to_string()));
// The app may have quit meanwhile: nothing to hand it to.
let _ = tx.send((run, output));
})
.map_err(|e| e.to_string())?;
let label = format!("Running {who}…");
let runs = &mut app.plugin_runs;
runs.next_id += 1;
runs.running.push(Running { id: runs.next_id, label: label.clone(), who, params, token, hidden: false, done });
app.status(label.clone());
Ok(json!({ "background": true, "status": label }))
}
/// Applies the runs that are done (each frame). Not while a dialog is open: its OK acts on the
/// active document, which a run's result changes; the results wait until it is closed.
pub fn poll(app: &mut VectorcraftApp) {
if app.ui.dialog.is_some() {
return;
}
let mut i = 0;
while let Some(r) = app.plugin_runs.running.get(i) {
let done = match r.done.try_recv() {
Ok(done) => Some(done),
Err(TryRecvError::Empty) => {
i += 1;
continue;
}
Err(TryRecvError::Disconnected) => None,
};
let r = app.plugin_runs.running.remove(i);
// The status bar no longer says it is running.
if app.ui.status == r.label {
app.ui.status.clear();
}
finish(app, r, done);
}
}
/// Applies run `r`'s result: its output, as `plugin.run`, while the document and the plug-in are as
/// they were; else why it has none, in the status bar.
fn finish(app: &mut VectorcraftApp, r: Running, done: Option<Done>) {
// Cancel changes nothing, even when the run ended just before it.
if r.token.is_cancelled() {
app.status(EngineError::Cancelled.to_string());
return;
}
let (run, output) = match done {
Some((run, Ok(output))) => (run, output),
Some((_, Err(e))) => {
app.status(format!("{} failed: {e}", r.who));
return;
}
None => {
app.status(format!("{} failed: {e}", r.who, e = "internal error: its thread stopped (please report this bug)"));
return;
}
};
if !run.current(&app.session) {
app.status(format!("{} finished, but the document or the plug-in changed meanwhile: run it again", r.who));
return;
}
run.hand_in(&mut app.session, output);
if let Err(e) = app.run("plugin.run", r.params) {
app.status(format!("{} failed: {e}", r.who));
}
}
/// The progress window's Cancel (also Esc): every heavy filter running stops at the next slice of
/// its budget and changes nothing → `{cancelled: n}`.
pub fn cancel_all(app: &mut VectorcraftApp) -> Value {
let mut n = 0;
for r in app.plugin_runs.running.iter().filter(|r| !r.token.is_cancelled()) {
r.token.cancel();
n += 1;
}
json!({ "cancelled": n })
}
/// The progress window's Continue in Background: the runs go on, shown in the status bar only →
/// `{hidden: n}`.
pub fn hide_all(app: &mut VectorcraftApp) -> Value {
let mut n = 0;
for r in app.plugin_runs.running.iter_mut().filter(|r| r.shown()) {
r.hidden = true;
n += 1;
}
json!({ "hidden": n })
}
/// Is a heavy filter running that hasn't been cancelled?
pub fn any_running(app: &VectorcraftApp) -> bool {
app.plugin_runs.running.iter().any(|r| !r.token.is_cancelled())
}
/// Is a heavy filter running that the progress window shows?
pub fn any_shown(app: &VectorcraftApp) -> bool {
app.plugin_runs.running.iter().any(Running::shown)
}
/// The progress window of the first run it shows, once that has run for [`DELAY`]. Not while a
/// dialog or the export progress window is open: never two modals at once.
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(first) = app.plugin_runs.running.iter().find(|r| r.shown()) else { return };
let now = ctx.input(|i| i.time);
let since = ctx.data_mut(|m| {
let seen = m.get_temp_mut_or_insert_with(egui::Id::new("plugin-run-seen"), || (first.id, now));
if seen.0 != first.id {
*seen = (first.id, now);
}
seen.1
});
let exporting = app.background.jobs.iter().any(|j| j.progress.is_some() && !j.hidden && !j.cancelled());
if now - since < DELAY || app.ui.dialog.is_some() || exporting {
return;
}
let heading = crate::i18n::msg(&first.label);
let pressed = export_progress::window(ctx, "plugin-run-progress", &heading, |ui| export_progress::busy(ui, tl!("The plug-in is computing…")));
let id = match pressed {
Some(Pressed::Cancel) => "plugin.cancelRuns",
Some(Pressed::Hide) => "plugin.hideRuns",
None => return,
};
if let Err(e) = app.run(id, json!({})) {
app.status(e);
}
}
+188
View File
@@ -0,0 +1,188 @@
//! Heavy plug-in filters in the app ([`crate::plugin_runs`]): installing one asks first, its dialog
//! has no Preview, and it runs off the UI thread, its result applied as one undo step once it is
//! done, unless the document changed meanwhile. Cancel stops it; its progress window shows it.
use std::time::{Duration, Instant};
use serde_json::json;
use vectorcraft_engine::Session;
use vectorcraft_plugins::{registry, wat as tpl};
use crate::{VectorcraftApp, dialogs, plugin_runs, theme};
/// An app with a new 200 × 200 pt document.
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app
}
/// A heavy test filter `id` (named "Test `id`") with `params` that counts `steps` down, then adds
/// a triangle.
fn heavy(id: &str, steps: u64, params: &str) -> Vec<u8> {
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy","params":{params}}}"#);
let output = r#"{"objects":[{"path":{"subpaths":[{"anchors":[{"p":[0,0]},{"p":[30,0]},{"p":[15,20]}]}]}}]}"#;
wat::parse_str(tpl::spinning(&manifest, steps, output)).unwrap()
}
/// Installs [`heavy`] filter `id` as the user would once they agreed.
fn install(app: &mut VectorcraftApp, id: &str, steps: u64, params: &str) {
let data = vectorcraft_format::base64_encode(&heavy(id, steps, params));
app.run("plugin.install", json!({"dataBase64": data, "allowHeavy": true})).unwrap();
}
/// Applies the runs' results as the frames would, until none is left (at most 30 s).
fn wait(app: &mut VectorcraftApp) {
let t0 = Instant::now();
while !app.plugin_runs.running.is_empty() && t0.elapsed() < Duration::from_secs(30) {
plugin_runs::poll(app);
std::thread::sleep(Duration::from_millis(5));
}
assert!(app.plugin_runs.running.is_empty(), "still running");
}
/// One headless frame of the dialog layer.
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut out = ctx.run_ui(Default::default(), |ui| dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
}
/// Draws the progress window at `time` seconds → the texts it drew.
fn progress(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64) -> String {
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| plugin_runs::show(app, ui.ctx()));
out.textures_delta.clear();
let mut texts = String::new();
for shape in out.shapes {
if let egui::epaint::Shape::Text(t) = shape.shape {
texts.push_str(t.galley.text());
texts.push('\n');
}
}
texts
}
/// Install Plug-in asks before it installs a filter that asks for a heavy budget, and OK installs
/// it; File › Open of the `.wasm` asks too, and Cancel installs nothing.
#[test]
fn installing_a_heavy_filter_asks_first() {
let mut app = app();
let id = "test.ui.heavy.install";
let bytes = heavy(id, 1, "{}");
let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "route.wasm"})).unwrap();
assert_eq!(r["pending"], dialogs::confirm::KIND);
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.str("message"), format!("Install “Test {id}”, which asks for a heavy budget?"));
assert!(d.str("detail").contains("up to 5 minutes and do 20 times the work"), "{}", d.str("detail"));
assert!(registry::get(id).is_none(), "not before the user agrees");
dialogs::confirm(&mut app).unwrap();
assert_eq!(registry::get(id).map(|p| p.manifest().name.clone()), Some(format!("Test {id}")));
assert_eq!(app.ui.status, format!("Installed plug-in Test {id}"));
// File › Open of the .wasm asks too, and Cancel installs nothing.
registry::remove(id);
crate::io::open_bytes(&mut app, "route.wasm", &bytes, None).unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND));
dialogs::cancel(&mut app);
assert!(registry::get(id).is_none());
}
/// The dialog has no Preview; OK closes it and the filter runs in the background, the status bar
/// saying so, and its result is one undo step named after it, selected.
#[test]
fn a_heavy_filter_runs_off_the_ui_thread_and_applies_as_one_undo_step() {
let mut app = app();
let id = "test.ui.heavy.run";
install(&mut app, id, 1000, r#"{"size":{"type":"number","min":1,"max":9,"default":3}}"#);
app.run("plugin.dialog", json!({"id": id})).unwrap();
assert!(app.ui.dialog.as_ref().unwrap().fields.get("preview").is_none(), "no Preview: a run may take minutes");
frame(&mut app);
assert!(!app.session.in_interaction(), "nothing previewed");
let undo = app.session.doc().unwrap().history.undo.len();
dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.plugin_runs.running.len(), 1);
assert_eq!(app.ui.status, format!("Running “Test {id}”…"));
wait(&mut app);
let st = app.session.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
assert_eq!(st.history.undo.last().unwrap().label, format!("Test {id}"));
assert_eq!(st.selection.objects.len(), 1, "the triangle it added, selected");
assert!(app.ui.status.is_empty(), "{}", app.ui.status);
}
/// A document edited while the filter runs doesn't take its result: nothing changes, and the status
/// bar says to run it again.
#[test]
fn a_heavy_filter_s_result_needs_the_document_as_it_was() {
let mut app = app();
let id = "test.ui.heavy.stale";
install(&mut app, id, 1, "{}");
// Without parameters it runs at once, in the background.
assert_eq!(app.run("plugin.dialog", json!({"id": id})).unwrap()["background"], true);
// An edit made while it runs: its result no longer fits.
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
let doc = app.session.doc().unwrap().doc.clone();
wait(&mut app);
assert_eq!(app.session.doc().unwrap().doc, doc, "nothing changed");
assert_eq!(app.ui.status, format!("“Test {id}” finished, but the document or the plug-in changed meanwhile: run it again"));
}
/// The progress window opens after the delay, headed by the run; Cancel stops the run, which
/// changes nothing, and the window closes.
#[test]
fn cancel_stops_a_heavy_filter_shown_in_its_progress_window() {
let mut app = app();
let id = "test.ui.heavy.cancel";
// It would run for minutes.
install(&mut app, id, 1 << 40, "{}");
let doc = app.session.doc().unwrap().doc.clone();
app.run("plugin.dialog", json!({"id": id})).unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
assert!(!progress(&mut app, &ctx, 0.0).contains("Running"), "not before the delay");
progress(&mut app, &ctx, 1.0); // a new window measures itself unseen first
let shown = progress(&mut app, &ctx, 1.1);
let heading = format!("Running “Test {id}”…");
assert!(shown.contains(&heading) && shown.contains("The plug-in is computing…") && shown.contains("Cancel"), "{shown}");
assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1);
assert!(!progress(&mut app, &ctx, 2.0).contains(&heading), "a cancelled run isn't shown");
wait(&mut app);
assert_eq!(app.session.doc().unwrap().doc, doc);
assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string());
}
/// Continue in Background closes the progress window while the run goes on; Cancel still stops it.
#[test]
fn continue_in_background_hides_the_progress_window() {
let mut app = app();
let id = "test.ui.heavy.hide";
install(&mut app, id, 1 << 40, "{}");
app.run("plugin.dialog", json!({"id": id})).unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
progress(&mut app, &ctx, 0.0);
progress(&mut app, &ctx, 1.0);
let heading = format!("Running “Test {id}”…");
assert!(progress(&mut app, &ctx, 1.1).contains(&heading));
assert_eq!(app.run("plugin.hideRuns", json!({})).unwrap(), json!({"hidden": 1}));
assert!(!progress(&mut app, &ctx, 2.0).contains(&heading));
assert!(app.plugin_runs.running.first().is_some_and(|r| r.hidden), "still running");
assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap(), json!({"cancelled": 1}));
wait(&mut app);
assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string());
}
/// `CRAFT_UI_HEAVY_FILTER_WASM=<folder>` writes a heavy test filter to install in the app
/// (`order-paths-test-filter.wasm`): it computes until it is cancelled or its budget runs out, so
/// its progress window stays up for a picture.
#[test]
fn heavy_test_filter_for_pictures() {
let Some(dir) = std::env::var_os("CRAFT_UI_HEAVY_FILTER_WASM") else { return };
let manifest = r#"{"id":"org.example.order","name":"Order Paths for Plotting (test plug-in)","version":"1","kind":"filter",
"budget":"heavy","description":"Orders the selected paths so the pen travels as little as possible between them.",
"params":{"passes":{"type":"int","min":1,"max":100,"default":20,"label":"Improvement passes"},
"start":{"type":"choice","options":["top left","nearest"],"default":"top left","label":"Start at"}}}"#;
let src = tpl::spinning(manifest, 1 << 40, r#"{"objects":[]}"#);
std::fs::write(std::path::PathBuf::from(dir).join("order-paths-test-filter.wasm"), wat::parse_str(src).unwrap()).unwrap();
}
+6 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -4412,7 +4412,9 @@ id `plugin.<id>`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc
`effect.list` lists it. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
`document.export {format: "plugin.<id>", params?, path?}` writes it, as does a `path` ending in one of its extensions
that no built-in format has. A failing plug-in returns an error and leaves the document as it was (an export writes
no file).
no file). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its
`plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run`
runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it.
```json
{"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}}
+33 -6
View File
@@ -23,12 +23,13 @@ This page is the contract for plug-in authors and the reference for the host (`c
| Command | Params | What it does |
|---|---|---|
| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, size, source}], "folder": report}` |
| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters)) |
| `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). |
| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected. |
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). |
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. |
| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have. |
| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. |
| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped. |
| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so is a heavy filter (nobody was asked). |
Live effects are applied like any effect: `effect.apply {"effect": "plugin.<id>", "params": {…}}`, edited with
`effect.setParams`, listed by `effect.list` (menu `Effect › Plug-ins`) and baked by Object › Expand Appearance.
@@ -45,7 +46,8 @@ parameters it runs at once. Parameters too many for the window's height scroll i
heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use
Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the
same kind once the file is picked, and its Export button writes the file. **File › Open** of a `.wasm` file installs
a plug-in too (on the web this is how plug-ins get in).
a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the
background ([below](#heavy-filters)).
**Preference folder.** Edit › Preferences › Plug-ins & Scratch Disks › *Additional Plug-ins Folder*: when set, every
`*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds;
@@ -67,14 +69,37 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem
| Module size | 32 MiB | rejected |
| Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails |
| Output | 64 MiB of JSON | `Limit("output size")` |
| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; an export 10 G + 4,000 per byte | `Limit("instruction budget")` |
| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per export (desktop; checked every 20 M instructions) | `Limit("time budget")` |
| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export 10 G + 4,000 per byte | `Limit("instruction budget")` |
| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run or export (desktop; checked every 20 M instructions) | `Limit("time budget")` |
| Call depth | 1,024 frames | trap |
| Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected |
An export runs where the export runs: with Background Export on, on the export's worker, whose Cancel stops the
plug-in between two of those 20 M-instruction slices; nothing is written then.
### Heavy filters
Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a
simulation, which a minute and 50 M instructions don't cover. Such a filter declares `"budget": "heavy"` in its
manifest (only a filter may: a live effect runs whenever the document is drawn, and an exporter has an export's
budget). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s
(`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns how
much there is only once the run has ended.
- **Installing asks first.** Install Plug-in… and File › Open show what the filter asks for, and install it once the
user clicks OK; `plugin.install` refuses it without `allowHeavy: true`, which an agent passes once the user agreed
(`plugin.inspect` tells it beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed again
at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder
asks nobody.
- **It runs in the background.** Its dialog has no Preview. OK (or the menu item, without parameters) runs it on a
thread of its own while editing goes on, shown in the status bar and, after a moment, in a progress window with
Cancel (also Esc) and Continue in Background (`plugin.cancelRuns`, `plugin.hideRuns`). When it is done its result is
applied as `plugin.run` with the same parameters and objects, as one undo step, unless the document or the plug-in
changed meanwhile: then nothing changes, and the status bar says to run it again. On the web, which has no threads,
it runs at once; the instruction budget alone bounds it there.
- `plugin.run` itself runs a heavy filter where it is called, as it runs any filter: an MCP client waits for it, and
its `notifications/cancelled` stops it.
Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed
modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the
document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that
@@ -126,6 +151,8 @@ pointers are offsets into the module's memory.
- `name`: the menu label, or an exporter's format name in Export As (1–64 printable characters). `kind`: `"filter"`
(object filter), `"effect"` (live effect) or `"exporter"`. Exporters came after the first ABI v1 hosts, which refuse
the kind when the plug-in is installed.
- `budget` (filters only): `"light"` (the default) or `"heavy"`, for work that takes minutes ([heavy
filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget.
- `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot
(`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`):
`document.export` then writes the exporter's format only when asked for by its format id.
File diff suppressed because one or more lines are too long