Files
vectorcraft/crates/ui-egui/src/plugin_runs.rs
T
dotCooCoo 96e20d548d Plug-ins: a light filter that reports progress runs in the background with Cancel
A light filter of plug-in API level 2 with the `progress` permission ran
on the UI thread, so its progress reports and `vc_cancelled` never
reached a progress window. It now runs in the background under its light
budget, as a heavy filter does, when it runs at once from Object ›
Plug-ins or its shortcut, or when OK runs it with Preview off. The
progress window shows its share and Cancel stops it. With Preview on, OK
keeps the previewed run. A filter that lists a `commandAllowlist` still
runs on the UI thread.

Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md.
2026-11-11 12:18:18 -08:00

220 lines
8.9 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`]).
//!
//! A filter of plug-in API level 2 may report how far it has got (`vc_progress`): the progress
//! window then shows a bar with the share done. Its Cancel also makes the filter's `vc_cancelled`
//! return 1, so a filter that checks it stops at once; one that doesn't stops at the next slice of
//! its budget. A light filter that may report its progress (the `progress` permission) and calls
//! no commands runs here too, under its light budget, so its progress and Cancel show while it
//! computes.
use std::sync::Arc;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use serde_json::{Value, json};
use vectorcraft_engine::EngineError;
use vectorcraft_engine::cmd::fileio::progress::{self, Progress};
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,
/// How far it has got, as it reports it, and its Cancel.
pub progress: Arc<Progress>,
/// 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.progress.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 shown = Progress::new();
let reporting = Arc::clone(&shown);
std::thread::Builder::new()
.name("vectorcraft-plugin".into())
.spawn(move || {
let output = vectorcraft_engine::guard::catch_panic(|| progress::scope(&reporting, || 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, progress: shown, 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.progress.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 filter running in the background stops (at once when it checks
/// `vc_cancelled`, else 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.progress.cancelled()) {
r.progress.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.progress.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::unique("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 reported = first.progress.fraction();
let pressed = export_progress::window(ctx, "plugin-run-progress", &heading, |ui| match reported {
Some(share) => export_progress::share(ui, tl!("The plug-in is computing…"), share as f32),
None => 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);
}
}