mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
3e0c1d6870
commit
79cb38f678
+197
-53
@@ -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(¶ms).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(¶ms).map_err(|e| bad(C, e.to_string()))?;
|
||||
let out = objects::decode_output(&plugin.run(&input, ¶ms).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(®istry::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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
@@ -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", ×.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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+3
-1
@@ -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
@@ -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
Reference in New Issue
Block a user