mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
While an export runs in the background (Background Export), a dialog shows what is being exported and how far it has got: artboard by artboard for a raster export of several artboards, busy otherwise. Cancel (also Esc) stops it and writes no file; Continue in Background leaves it to the status bar. It opens after 0.4 s and never over another dialog. Built on the shared vectorcraft_doc::cancel (#2546): fileio::progress::Progress wraps its CancelToken and a progress sink, so a cancelled export stops drawing and keeps no half-drawn pattern tile or effect raster. - ui: background jobs carry their Progress; export_progress draws the dialog; UI commands file.cancelExport and file.exportInBackground. - i18n: the strings in every language with complete menus, Danish, Dutch and Indonesian included; "{n} more exports waiting" is a plural (trn). - docs: ui-parity, gaps (G12) and ROADMAP rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dt49z1ubK9x1bPq51APgz3
232 lines
9.1 KiB
Rust
232 lines
9.1 KiB
Rust
//! Background Save and Background Export (Preferences → File Handling): a save or an export encodes
|
|
//! and writes a snapshot of the document on a worker thread while editing goes on. The status bar
|
|
//! shows what is in progress; the result is applied on the UI thread when it arrives (a save marks
|
|
//! the document saved as of its snapshot, so edits made meanwhile keep it modified).
|
|
//!
|
|
//! It needs a thread-safe writer from the host ([`crate::Services::write_shared`], desktop);
|
|
//! without one (the web, which downloads) the work runs at once, as with the preference off.
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_engine::cmd::fileio::progress::Progress;
|
|
use vectorcraft_engine::file_access::ScopeHandle;
|
|
|
|
use crate::VectorcraftApp;
|
|
|
|
/// Writes a file; callable from any thread.
|
|
pub type SharedWriteFn = std::sync::Arc<dyn Fn(&str, &[u8]) -> Result<(), String> + Send + Sync>;
|
|
|
|
/// Writes the file at a path with what the filler it is given writes into it (atomically, as
|
|
/// `vectorcraft_format::write_atomic_with`); callable from any thread.
|
|
pub type SharedStreamFn =
|
|
std::sync::Arc<dyn Fn(&str, &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<(), String> + Send + Sync>;
|
|
|
|
/// A writer for the work: the host's file writer (or browser download) on the UI thread, the
|
|
/// shared one on the worker.
|
|
pub type Writer<'a> = &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>;
|
|
|
|
/// Encodes and writes a snapshot (no access to the app) → the result handed to [`Then`].
|
|
type Work = Box<dyn FnOnce(Writer) -> Result<Value, String> + Send>;
|
|
|
|
/// Applies a result on the UI thread (document state, status, recent files) → the command result.
|
|
type Then = Box<dyn FnOnce(&mut VectorcraftApp, Result<Value, String>) -> Result<Value, String>>;
|
|
|
|
/// A job's id and the work's result, back from the worker.
|
|
type Done = (u64, Result<Value, String>);
|
|
|
|
struct Task {
|
|
id: u64,
|
|
work: Work,
|
|
write: SharedWriteFn,
|
|
/// The automation roots in force where the work was started, in force where it runs.
|
|
scope: ScopeHandle,
|
|
}
|
|
|
|
/// A job in progress.
|
|
pub struct Job {
|
|
id: u64,
|
|
/// What the status bar and `ui.inspect` show ("Saving Poster.vectorcraft"); empty for work they
|
|
/// don't show.
|
|
pub label: String,
|
|
/// The document the job reads (`DocState::uid`), if any.
|
|
pub doc: Option<u64>,
|
|
/// How far an export has got, and its Cancel ([`crate::export_progress`] shows it).
|
|
pub progress: Option<Arc<Progress>>,
|
|
/// Continue in Background: the progress dialog no longer shows this job.
|
|
pub hidden: bool,
|
|
then: Then,
|
|
}
|
|
|
|
impl Job {
|
|
pub fn id(&self) -> u64 {
|
|
self.id
|
|
}
|
|
|
|
/// Was the job cancelled (it writes nothing more; its result is still on its way)?
|
|
pub fn cancelled(&self) -> bool {
|
|
self.progress.as_ref().is_some_and(|p| p.cancelled())
|
|
}
|
|
}
|
|
|
|
/// The worker and the jobs it has not finished.
|
|
#[derive(Default)]
|
|
pub struct Background {
|
|
worker: Option<(Sender<Task>, Receiver<Done>)>,
|
|
/// Jobs in the order they were started (the worker runs them in that order).
|
|
pub jobs: Vec<Job>,
|
|
next_id: u64,
|
|
}
|
|
|
|
impl Background {
|
|
/// The worker's channels, started on first use (`None` on wasm, which has no threads).
|
|
fn worker(&mut self) -> Option<&Sender<Task>> {
|
|
if cfg!(target_arch = "wasm32") {
|
|
return None;
|
|
}
|
|
if self.worker.is_none() {
|
|
let (tx, tasks) = channel::<Task>();
|
|
let (done_tx, done) = channel();
|
|
std::thread::Builder::new()
|
|
.name("vectorcraft-save".into())
|
|
.spawn(move || {
|
|
while let Ok(Task { id, work, write, scope }) = tasks.recv() {
|
|
let r = scope
|
|
.run(|| vectorcraft_engine::guard::catch_panic(|| work(&mut |p: &str, b: &[u8]| write(p, b))))
|
|
.unwrap_or_else(|msg| Err(format!("internal error: {msg} (please report this bug)")));
|
|
if done_tx.send((id, r)).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
})
|
|
.ok()?;
|
|
self.worker = Some((tx, done));
|
|
}
|
|
self.worker.as_ref().map(|(tx, _)| tx)
|
|
}
|
|
|
|
/// Is a job reading document `uid` still running?
|
|
pub fn busy_with(&self, uid: u64) -> bool {
|
|
self.jobs.iter().any(|j| j.doc == Some(uid))
|
|
}
|
|
|
|
/// Cancel every export in progress → how many there were.
|
|
pub fn cancel_exports(&mut self) -> usize {
|
|
let mut n = 0;
|
|
for p in self.jobs.iter().filter(|j| !j.cancelled()).filter_map(|j| j.progress.as_ref()) {
|
|
p.cancel();
|
|
n += 1;
|
|
}
|
|
n
|
|
}
|
|
|
|
/// Hide the exports in progress from the progress dialog (they go on) → how many there were.
|
|
pub fn hide_exports(&mut self) -> usize {
|
|
let mut n = 0;
|
|
for j in self.jobs.iter_mut().filter(|j| j.progress.is_some() && !j.hidden && !j.cancelled()) {
|
|
j.hidden = true;
|
|
n += 1;
|
|
}
|
|
n
|
|
}
|
|
}
|
|
|
|
/// Run `work` on a worker thread when `enabled` and the host can write from one, else now; `then`
|
|
/// gets its result on the UI thread. → `then`'s result, or `{background: true, status}` while the
|
|
/// work runs (the status bar shows `label`). With an empty `label` neither the status bar nor
|
|
/// `ui.inspect` shows the work, and the reply has no `status`.
|
|
pub fn run(
|
|
app: &mut VectorcraftApp,
|
|
enabled: bool,
|
|
label: String,
|
|
doc: Option<u64>,
|
|
work: impl FnOnce(Writer) -> Result<Value, String> + Send + 'static,
|
|
then: impl FnOnce(&mut VectorcraftApp, Result<Value, String>) -> Result<Value, String> + 'static,
|
|
) -> Result<Value, String> {
|
|
run_with_progress(app, enabled, label, doc, None, work, then)
|
|
}
|
|
|
|
/// [`run`] for work that reports to `progress` (an export): the progress dialog shows it while it
|
|
/// runs in the background, with Cancel.
|
|
pub fn run_with_progress(
|
|
app: &mut VectorcraftApp,
|
|
enabled: bool,
|
|
label: String,
|
|
doc: Option<u64>,
|
|
progress: Option<Arc<Progress>>,
|
|
work: impl FnOnce(Writer) -> Result<Value, String> + Send + 'static,
|
|
then: impl FnOnce(&mut VectorcraftApp, Result<Value, String>) -> Result<Value, String> + 'static,
|
|
) -> Result<Value, String> {
|
|
let mut work: Work = Box::new(work);
|
|
let shared = app.services.write_shared.clone().filter(|_| enabled && app.services.download.is_none());
|
|
if let Some(write) = shared {
|
|
let id = app.background.next_id;
|
|
if let Some(tx) = app.background.worker() {
|
|
match tx.send(Task { id, work, write, scope: ScopeHandle::capture() }) {
|
|
Ok(()) => {
|
|
let bg = &mut app.background;
|
|
bg.next_id += 1;
|
|
bg.jobs.push(Job { id, label: label.clone(), doc, progress, hidden: false, then: Box::new(then) });
|
|
if label.is_empty() {
|
|
return Ok(json!({ "background": true }));
|
|
}
|
|
let status = format!("{label}…");
|
|
app.status(status.clone());
|
|
return Ok(json!({ "background": true, "status": status }));
|
|
}
|
|
// The worker stopped: start a new one next time, and do this one now.
|
|
Err(e) => {
|
|
app.background.worker = None;
|
|
work = e.0.work;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let services = &mut app.services;
|
|
let r = work(&mut |p: &str, b: &[u8]| crate::io::write_to(services, p, b));
|
|
then(app, r)
|
|
}
|
|
|
|
/// Apply the results that arrived (each frame).
|
|
pub fn poll(app: &mut VectorcraftApp) {
|
|
while let Some(done) = app.background.worker.as_ref().and_then(|(_, rx)| rx.try_recv().ok()) {
|
|
finish(app, done);
|
|
}
|
|
}
|
|
|
|
/// Wait for every job and apply its result (before quitting, closing a document, or another save
|
|
/// of a document a job is reading).
|
|
pub fn wait_all(app: &mut VectorcraftApp) {
|
|
wait_until(app, |_| false);
|
|
}
|
|
|
|
/// Apply the jobs' results as they arrive (in the order the jobs were started) until `ready` holds
|
|
/// or no job is left.
|
|
pub fn wait_until(app: &mut VectorcraftApp, ready: impl Fn(&VectorcraftApp) -> bool) {
|
|
while !ready(app) && !app.background.jobs.is_empty() {
|
|
let Some(done) = app.background.worker.as_ref().and_then(|(_, rx)| rx.recv().ok()) else {
|
|
// The worker is gone: its jobs can't finish.
|
|
for job in std::mem::take(&mut app.background.jobs) {
|
|
apply(app, job, Err("the background worker stopped".into()));
|
|
}
|
|
app.background.worker = None;
|
|
return;
|
|
};
|
|
finish(app, done);
|
|
}
|
|
}
|
|
|
|
fn finish(app: &mut VectorcraftApp, (id, r): Done) {
|
|
let Some(i) = app.background.jobs.iter().position(|j| j.id == id) else { return };
|
|
let job = app.background.jobs.remove(i);
|
|
apply(app, job, r);
|
|
}
|
|
|
|
/// Hand a job its result; a failure goes to the status bar (the call that started it returned).
|
|
fn apply(app: &mut VectorcraftApp, job: Job, r: Result<Value, String>) {
|
|
if let Err(e) = (job.then)(app, r) {
|
|
app.status(format!("{} failed: {e}", job.label));
|
|
}
|
|
}
|