mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
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
208 lines
8.1 KiB
Rust
208 lines
8.1 KiB
Rust
//! 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);
|
|
}
|
|
}
|